mini-mern-project-full-crud-app-with-nextjs-14-mon

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

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

Feature Tech
Frontend Next.js 14 (App Router)
Backend Next.js API Routes
Database MongoDB (Atlas)
CRUD Create, Read, Update, Delete Tasks
UI Tailwind CSS + Responsive
Deployment Ready Vercel

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

Action Route Method
List / GET
Add /add/api/tasks POST
Edit /edit/[id]/api/tasks/[id] PUT
Delete Button → /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

Feature Add
Authentication NextAuth.js
Validation Zod
Search/Filter Client-side
Dark Mode Tailwind
Drag & Drop React DnD

Congratulations! You built a real MERN app

Last updated: Nov 10, 2025

mini-mern-project-full-crud-app-with-nextjs-14-mon

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

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

Feature Tech
Frontend Next.js 14 (App Router)
Backend Next.js API Routes
Database MongoDB (Atlas)
CRUD Create, Read, Update, Delete Tasks
UI Tailwind CSS + Responsive
Deployment Ready Vercel

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

Action Route Method
List / GET
Add /add/api/tasks POST
Edit /edit/[id]/api/tasks/[id] PUT
Delete Button → /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

Feature Add
Authentication NextAuth.js
Validation Zod
Search/Filter Client-side
Dark Mode Tailwind
Drag & Drop React DnD

Congratulations! You built a real MERN app

Last updated: Nov 10, 2025