Loading...
Development

Mini MERN Project: Full CRUD App with Next.js 14 + MongoDB Build a Complete Task Manager in 30 Minutes

Mini MERN Project: Full CRUD App with Next.js 14 + MongoDB

Build a Complete Task Manager in 30 Minutes


PROJECT OVERVIEW

FeatureTech
FrontendNext.js 14 (App Router)
BackendNext.js API Routes
DatabaseMongoDB (Atlas)
CRUDCreate, Read, Update, Delete Tasks
UITailwind CSS + Responsive
Deployment ReadyVercel

PROJECT STRUCTURE

mern-task-app/
├── app/
│   ├── page.tsx              ← Home (Task List)
│   ├── add/page.tsx          ← Add Task
│   ├── edit/[id]/page.tsx    ← Edit Task
│   └── api/tasks/
│       ├── route.ts          ← GET/POST
│       └── [id]/route.ts     ← PUT/DELETE
├── components/
│   ├── TaskCard.tsx
│   └── TaskForm.tsx
├── lib/
│   └── mongodb.ts
├── public/
├── styles/
│   └── globals.css
├── .env.local
├── next.config.js
├── tailwind.config.ts
└── package.json

STEP 1: SETUP PROJECT

npx create-next-app@latest mern-task-app --typescript --tailwind --eslint --app --src-dir
cd mern-task-app

STEP 2: INSTALL DEPENDENCIES

npm install mongoose

STEP 3: MONGODB CONNECTION (lib/mongodb.ts)

// lib/mongodb.ts
import { MongoClient, Db } from 'mongodb';

const uri = process.env.MONGODB_URI!;
const options = {};

let client: MongoClient;
let clientPromise: Promise<MongoClient>;

if (!process.env.MONGODB_URI) {
  throw new Error('Add MongoDB URI to .env.local');
}

if (process.env.NODE_ENV === 'development') {
  // In development, use a global variable
  if (!global._mongoClientPromise) {
    client = new MongoClient(uri, options);
    global._mongoClientPromise = client.connect();
  }
  clientPromise = global._mongoClientPromise;
} else {
  client = new MongoClient(uri, options);
  clientPromise = client.connect();
}

export async function getDb(): Promise<Db> {
  const client = await clientPromise;
  return client.db('taskdb');
}

export default clientPromise;

STEP 4: ENVIRONMENT VARIABLES

Create .env.local:

MONGODB_URI=mongodb+srv://<user>:<password>@cluster0.xxxxx.mongodb.net/taskdb?retryWrites=true&w=majority

Get free MongoDB Atlas: mongodb.com/cloud/atlas


STEP 5: TASK MODEL (API)

app/api/tasks/route.ts (GET & POST)

import { NextResponse } from 'next/server';
import { getDb } from '@/lib/mongodb';

export async function GET() {
  try {
    const db = await getDb();
    const tasks = await db.collection('tasks').find({}).toArray();
    return NextResponse.json(tasks);
  } catch (error) {
    return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 });
  }
}

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const db = await getDb();
    const result = await db.collection('tasks').insertOne({
      ...body,
      completed: false,
      createdAt: new Date(),
    });
    return NextResponse.json({ _id: result.insertedId, ...body }, { status: 201 });
  } catch (error) {
    return NextResponse.json({ error: 'Failed to create task' }, { status: 500 });
  }
}

app/api/tasks/[id]/route.ts (PUT & DELETE)

import { NextResponse } from 'next/server';
import { getDb } from '@/lib/mongodb';
import { ObjectId } from 'mongodb';

export async function PUT(
  request: Request,
  { params }: { params: { id: string } }
) {
  try {
    const body = await request.json();
    const db = await getDb();
    const result = await db.collection('tasks').updateOne(
      { _id: new ObjectId(params.id) },
      { $set: body }
    );
    if (result.matchedCount === 0) {
      return NextResponse.json({ error: 'Task not found' }, { status: 404 });
    }
    return NextResponse.json({ message: 'Updated' });
  } catch (error) {
    return NextResponse.json({ error: 'Failed to update' }, { status: 500 });
  }
}

export async function DELETE(
  request: Request,
  { params }: { params: { id: string } }
) {
  try {
    const db = await getDb();
    const result = await db.collection('tasks').deleteOne({
      _id: new ObjectId(params.id),
    });
    if (result.deletedCount === 0) {
      return NextResponse.json({ error: 'Task not found' }, { status: 404 });
    }
    return NextResponse.json({ message: 'Deleted' });
  } catch (error) {
    return NextResponse.json({ error: 'Failed to delete' }, { status: 500 });
  }
}

STEP 6: UI COMPONENTS

components/TaskCard.tsx

'use client';

import { Task } from '@/types';

export default function TaskCard({ task, onEdit, onDelete }: {
  task: Task;
  onEdit: () => void;
  onDelete: () => void;
}) {
  return (
    <div className="bg-white p-4 rounded-lg shadow hover:shadow-md transition-shadow">
      <h3 className="font-semibold text-lg">{task.title}</h3>
      <p className="text-gray-600 mt-1">{task.description}</p>
      <div className="flex justify-between items-center mt-4">
        <span className={`px-2 py-1 text-xs rounded-full ${
          task.completed ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
        }`}>
          {task.completed ? 'Done' : 'Pending'}
        </span>
        <div className="flex gap-2">
          <button
            onClick={onEdit}
            className="text-blue-600 hover:text-blue-800 text-sm font-medium"
          >
            Edit
          </button>
          <button
            onClick={onDelete}
            className="text-red-600 hover:text-red-800 text-sm font-medium"
          >
            Delete
          </button>
        </div>
      </div>
    </div>
  );
}

components/TaskForm.tsx

'use client';

import { useState } from 'react';

export default function TaskForm({ initialData, onSubmit, submitText }: {
  initialData?: any;
  onSubmit: (data: any) => void;
  submitText: string;
}) {
  const [title, setTitle] = useState(initialData?.title || '');
  const [description, setDescription] = useState(initialData?.description || '');
  const [completed, setCompleted] = useState(initialData?.completed || false);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onSubmit({ title, description, completed });
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label className="block text-sm font-medium text-gray-700">Title</label>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm p-2 border"
          required
        />
      </div>
      <div>
        <label className="block text-sm font-medium text-gray-700">Description</label>
        <textarea
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm p-2 border"
          rows={3}
        />
      </div>
      <div className="flex items-center">
        <input
          type="checkbox"
          checked={completed}
          onChange={(e) => setCompleted(e.target.checked)}
          className="h-4 w-4 text-blue-600 rounded"
        />
        <label className="ml-2 text-sm text-gray-700">Completed</label>
      </div>
      <button
        type="submit"
        className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700"
      >
        {submitText}
      </button>
    </form>
  );
}

STEP 7: PAGES

app/page.tsx (Home - List Tasks)

import TaskCard from '@/components/TaskCard';
import Link from 'next/link';
import { Task } from '@/types';

export const revalidate = 0; // Always fresh

async function getTasks() {
  const res = await fetch(`${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/tasks`, {
    cache: 'no-store'
  });
  return res.json();
}

export default async function Home() {
  const tasks: Task[] = await getTasks();

  return (
    <main className="max-w-4xl mx-auto p-6">
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-3xl font-bold text-gray-800">My Tasks</h1>
        <Link
          href="/add"
          className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
        >
          + Add Task
        </Link>
      </div>

      {tasks.length === 0 ? (
        <p className="text-center text-gray-500 py-12">No tasks yet. Create one!</p>
      ) : (
        <div className="grid gap-4 md:grid-cols-2">
          {tasks.map((task) => (
            <TaskCard
              key={task._id}
              task={task}
              onEdit={() => window.location.href = `/edit/${task._id}`}
              onDelete={async () => {
                if (confirm('Delete this task?')) {
                  await fetch(`/api/tasks/${task._id}`, { method: 'DELETE' });
                  window.location.reload();
                }
              }}
            />
          ))}
        </div>
      )}
    </main>
  );
}

app/add/page.tsx

'use client';

import TaskForm from '@/components/TaskForm';
import { useRouter } from 'next/navigation';

export default function AddTask() {
  const router = useRouter();

  const handleSubmit = async (data: any) => {
    const res = await fetch('/api/tasks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    if (res.ok) router.push('/');
  };

  return (
    <main className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">Add New Task</h1>
      <TaskForm onSubmit={handleSubmit} submitText="Create Task" />
      <button
        onClick={() => router.back()}
        className="mt-4 text-blue-600 hover:underline"
      >
        ← Back
      </button>
    </main>
  );
}

app/edit/[id]/page.tsx

import TaskForm from '@/components/TaskForm';
import { notFound } from 'next/navigation';

async function getTask(id: string) {
  const res = await fetch(`${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/tasks/${id}`, {
    cache: 'no-store'
  });
  if (!res.ok) return null;
  return res.json();
}

export default async function EditTask({ params }: { params: { id: string } }) {
  const task = await getTask(params.id);
  if (!task) notFound();

  return (
    <main className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">Edit Task</h1>
      <form action={`/api/tasks/${params.id}`} method="PUT">
        <input type="hidden" name="_method" value="PUT" />
        <TaskForm
          initialData={task}
          onSubmit={async (data) => {
            await fetch(`/api/tasks/${params.id}`, {
              method: 'PUT',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify(data),
            });
            window.location.href = '/';
          }}
          submitText="Update Task"
        />
      </form>
      <button
        onClick={() => window.location.href = '/'}
        className="mt-4 text-blue-600 hover:underline"
      >
        ← Back
      </button>
    </main>
  );
}

STEP 8: TYPES (types/index.ts)

// types/index.ts
export type Task = {
  _id: string;
  title: string;
  description: string;
  completed: boolean;
  createdAt: string;
};

STEP 9: RUN PROJECT

npm run dev

Open: http://localhost:3000


FEATURES WORKING

ActionRouteMethod
List/GET
Add/add/api/tasksPOST
Edit/edit/[id]/api/tasks/[id]PUT
DeleteButton → /api/tasks/[id]DELETE

DEPLOY TO VERCEL

git init
git add .
git commit -m "First commit"

Go to vercel.com → Import Project → Deploy

Set MONGODB_URI in Vercel Environment Variables



You now have a FULL MERN CRUD App with Next.js + MongoDB!


NEXT STEPS

FeatureAdd
AuthenticationNextAuth.js
ValidationZod
Search/FilterClient-side
Dark ModeTailwind
Drag & DropReact DnD

Congratulations! You built a real MERN app