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_URIin 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
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_URIin 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