Build a Full-Stack CRUD App with MongoDB, Mongoose, and Next.js 15

MongoDB is the world's most popular NoSQL database — and Mongoose makes it type-safe and elegant in TypeScript. In this tutorial you will build a complete task management application from scratch using MongoDB Atlas, Mongoose ODM, and Next.js 15 App Router with Server Actions.
What You Will Learn
By the end of this tutorial, you will:
- Set up a MongoDB Atlas cluster and connect it to Next.js
- Define Mongoose schemas with TypeScript type inference
- Build full CRUD operations using Next.js Server Actions
- Implement form validation with Zod
- Add search and filtering with MongoDB queries
- Handle pagination with cursor-based and offset patterns
- Deploy to production with connection pooling best practices
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed (
node --version) - TypeScript experience (types, interfaces, async/await)
- Next.js familiarity (App Router, Server Components)
- A MongoDB Atlas account (free tier works perfectly)
- A code editor — VS Code or Cursor recommended
Why MongoDB + Mongoose?
MongoDB is a document database that stores data in flexible, JSON-like documents. Combined with Mongoose, you get schema validation, type safety, and a powerful query API. Here is how it compares:
| Feature | MongoDB + Mongoose | PostgreSQL + Prisma | SQLite + Drizzle |
|---|---|---|---|
| Data model | Documents (JSON) | Relational tables | Relational tables |
| Schema | Flexible, optional | Strict, required | Strict, required |
| Type safety | Mongoose + TS generics | Auto-generated types | Inferred types |
| Scaling | Horizontal (sharding) | Vertical (read replicas) | Single file |
| Nested data | Native embedding | JSON columns or joins | JSON columns or joins |
| Free tier | Atlas 512 MB forever | Neon 0.5 GB | Local, unlimited |
| Best for | Flexible schemas, rapid prototyping | Complex relations, transactions | Edge, embedded |
Choose MongoDB when your data is naturally hierarchical, your schema evolves frequently, or you need horizontal scaling.
Step 1: Create the Next.js Project
Scaffold a new Next.js 15 project with TypeScript and Tailwind CSS:
npx create-next-app@latest task-manager --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd task-managerInstall MongoDB and Mongoose dependencies:
npm install mongoose zod
npm install -D @types/mongooseYour project structure will look like this:
task-manager/
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── tasks/
│ ├── lib/
│ │ ├── mongodb.ts
│ │ └── actions/
│ └── models/
│ └── task.ts
├── .env.local
└── package.json
Step 2: Set Up MongoDB Atlas
Create a Free Cluster
- Go to MongoDB Atlas and sign up or log in
- Click Build a Database and select the M0 Free tier
- Choose your preferred cloud provider and region (select the closest to your users)
- Click Create Deployment
Configure Access
- Create a database user with a username and password
- In Network Access, click Add IP Address and select Allow Access from Anywhere (for development — restrict this in production)
- Click Connect on your cluster, select Drivers, and copy the connection string
Add the Connection String
Create a .env.local file in your project root:
MONGODB_URI=mongodb+srv://<username>:<password>@<cluster>.mongodb.net/task-manager?retryWrites=true&w=majorityReplace <username>, <password>, and <cluster> with your actual Atlas credentials.
Step 3: Create the MongoDB Connection Utility
MongoDB connections in serverless environments need special handling. Next.js Server Components and Server Actions create new function invocations, so you must cache the connection to avoid exhausting the connection pool.
Create src/lib/mongodb.ts:
import mongoose from "mongoose";
const MONGODB_URI = process.env.MONGODB_URI!;
if (!MONGODB_URI) {
throw new Error("Please define the MONGODB_URI environment variable in .env.local");
}
interface MongooseCache {
conn: typeof mongoose | null;
promise: Promise<typeof mongoose> | null;
}
declare global {
var mongooseCache: MongooseCache | undefined;
}
const cached: MongooseCache = global.mongooseCache ?? { conn: null, promise: null };
if (!global.mongooseCache) {
global.mongooseCache = cached;
}
export async function connectDB(): Promise<typeof mongoose> {
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
maxPoolSize: 10,
};
cached.promise = mongoose.connect(MONGODB_URI, opts).then((m) => m);
}
cached.conn = await cached.promise;
return cached.conn;
}This pattern caches the connection across hot reloads in development and across function invocations in production. The global variable survives Next.js module reloads.
Step 4: Define the Mongoose Schema
Mongoose schemas define the shape of your documents and provide validation, defaults, and middleware hooks.
Create src/models/task.ts:
import mongoose, { Schema, Document, Model } from "mongoose";
export interface ITask {
title: string;
description: string;
status: "todo" | "in-progress" | "done";
priority: "low" | "medium" | "high";
dueDate?: Date;
tags: string[];
createdAt: Date;
updatedAt: Date;
}
export interface ITaskDocument extends ITask, Document {}
const taskSchema = new Schema<ITaskDocument>(
{
title: {
type: String,
required: [true, "Title is required"],
trim: true,
maxlength: [200, "Title cannot exceed 200 characters"],
},
description: {
type: String,
required: [true, "Description is required"],
trim: true,
maxlength: [2000, "Description cannot exceed 2000 characters"],
},
status: {
type: String,
enum: ["todo", "in-progress", "done"],
default: "todo",
},
priority: {
type: String,
enum: ["low", "medium", "high"],
default: "medium",
},
dueDate: {
type: Date,
},
tags: {
type: [String],
default: [],
},
},
{
timestamps: true,
}
);
taskSchema.index({ status: 1, priority: 1 });
taskSchema.index({ title: "text", description: "text" });
taskSchema.index({ createdAt: -1 });
const Task: Model<ITaskDocument> =
mongoose.models.Task || mongoose.model<ITaskDocument>("Task", taskSchema);
export default Task;Key design decisions:
timestamps: trueautomatically managescreatedAtandupdatedAt- Indexes on
statusandpriorityspeed up filtered queries - Text index on
titleanddescriptionenables full-text search mongoose.models.Task ||prevents model recompilation during hot reload
Step 5: Create Zod Validation Schemas
Define validation schemas that work on both server and client.
Create src/lib/validations/task.ts:
import { z } from "zod";
export const createTaskSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(200, "Title cannot exceed 200 characters"),
description: z
.string()
.min(1, "Description is required")
.max(2000, "Description cannot exceed 2000 characters"),
status: z.enum(["todo", "in-progress", "done"]).default("todo"),
priority: z.enum(["low", "medium", "high"]).default("medium"),
dueDate: z.string().optional().transform((val) => (val ? new Date(val) : undefined)),
tags: z
.string()
.optional()
.transform((val) => (val ? val.split(",").map((t) => t.trim()).filter(Boolean) : [])),
});
export const updateTaskSchema = createTaskSchema.partial();
export type CreateTaskInput = z.infer<typeof createTaskSchema>;
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>;Step 6: Build Server Actions
Server Actions are the cleanest way to handle form submissions and data mutations in Next.js 15. They run on the server and can directly access your database.
Create src/lib/actions/task-actions.ts:
"use server";
import { revalidatePath } from "next/cache";
import { connectDB } from "@/lib/mongodb";
import Task from "@/models/task";
import { createTaskSchema, updateTaskSchema } from "@/lib/validations/task";
export type ActionState = {
success: boolean;
message: string;
errors?: Record<string, string[]>;
};
export async function createTask(
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
await connectDB();
const raw = {
title: formData.get("title") as string,
description: formData.get("description") as string,
status: formData.get("status") as string,
priority: formData.get("priority") as string,
dueDate: formData.get("dueDate") as string,
tags: formData.get("tags") as string,
};
const result = createTaskSchema.safeParse(raw);
if (!result.success) {
return {
success: false,
message: "Validation failed",
errors: result.error.flatten().fieldErrors,
};
}
await Task.create(result.data);
revalidatePath("/tasks");
return { success: true, message: "Task created successfully" };
}
export async function updateTask(
id: string,
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
await connectDB();
const raw = {
title: formData.get("title") as string,
description: formData.get("description") as string,
status: formData.get("status") as string,
priority: formData.get("priority") as string,
dueDate: formData.get("dueDate") as string,
tags: formData.get("tags") as string,
};
const result = updateTaskSchema.safeParse(raw);
if (!result.success) {
return {
success: false,
message: "Validation failed",
errors: result.error.flatten().fieldErrors,
};
}
const task = await Task.findByIdAndUpdate(id, result.data, { new: true });
if (!task) {
return { success: false, message: "Task not found" };
}
revalidatePath("/tasks");
return { success: true, message: "Task updated successfully" };
}
export async function deleteTask(id: string): Promise<ActionState> {
await connectDB();
const task = await Task.findByIdAndDelete(id);
if (!task) {
return { success: false, message: "Task not found" };
}
revalidatePath("/tasks");
return { success: true, message: "Task deleted successfully" };
}
export async function toggleTaskStatus(
id: string,
newStatus: "todo" | "in-progress" | "done"
): Promise<ActionState> {
await connectDB();
const task = await Task.findByIdAndUpdate(id, { status: newStatus }, { new: true });
if (!task) {
return { success: false, message: "Task not found" };
}
revalidatePath("/tasks");
return { success: true, message: "Status updated" };
}Each action follows a consistent pattern: connect, validate, execute, revalidate. The ActionState type provides a predictable shape for handling results on the client.
Step 7: Build the Data Access Layer
Create query functions for reading data. These run in Server Components.
Create src/lib/actions/task-queries.ts:
import { connectDB } from "@/lib/mongodb";
import Task, { ITaskDocument } from "@/models/task";
export interface TaskFilters {
status?: string;
priority?: string;
search?: string;
page?: number;
limit?: number;
}
export interface PaginatedTasks {
tasks: SerializedTask[];
total: number;
page: number;
totalPages: number;
}
export interface SerializedTask {
_id: string;
title: string;
description: string;
status: "todo" | "in-progress" | "done";
priority: "low" | "medium" | "high";
dueDate: string | null;
tags: string[];
createdAt: string;
updatedAt: string;
}
function serializeTask(task: ITaskDocument): SerializedTask {
return {
_id: task._id.toString(),
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
tags: task.tags,
createdAt: task.createdAt.toISOString(),
updatedAt: task.updatedAt.toISOString(),
};
}
export async function getTasks(filters: TaskFilters = {}): Promise<PaginatedTasks> {
await connectDB();
const { status, priority, search, page = 1, limit = 10 } = filters;
const query: Record<string, unknown> = {};
if (status && status !== "all") {
query.status = status;
}
if (priority && priority !== "all") {
query.priority = priority;
}
if (search) {
query.$text = { $search: search };
}
const skip = (page - 1) * limit;
const [tasks, total] = await Promise.all([
Task.find(query).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
Task.countDocuments(query),
]);
return {
tasks: (tasks as unknown as ITaskDocument[]).map(serializeTask),
total,
page,
totalPages: Math.ceil(total / limit),
};
}
export async function getTaskById(id: string): Promise<SerializedTask | null> {
await connectDB();
const task = await Task.findById(id).lean();
if (!task) return null;
return serializeTask(task as unknown as ITaskDocument);
}
export async function getTaskStats() {
await connectDB();
const [total, todo, inProgress, done] = await Promise.all([
Task.countDocuments(),
Task.countDocuments({ status: "todo" }),
Task.countDocuments({ status: "in-progress" }),
Task.countDocuments({ status: "done" }),
]);
return { total, todo, inProgress, done };
}The serializeTask function converts Mongoose documents to plain objects — necessary because Server Components cannot pass Mongoose instances to Client Components.
Step 8: Build the Task List Page
Create the main tasks page as a Server Component.
Create src/app/tasks/page.tsx:
import Link from "next/link";
import { getTasks, getTaskStats } from "@/lib/actions/task-queries";
import { TaskCard } from "./task-card";
import { TaskFilters } from "./task-filters";
import { Pagination } from "./pagination";
interface PageProps {
searchParams: Promise<{
status?: string;
priority?: string;
search?: string;
page?: string;
}>;
}
export default async function TasksPage({ searchParams }: PageProps) {
const params = await searchParams;
const page = parseInt(params.page || "1", 10);
const [{ tasks, total, totalPages }, stats] = await Promise.all([
getTasks({
status: params.status,
priority: params.priority,
search: params.search,
page,
limit: 10,
}),
getTaskStats(),
]);
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold">Tasks</h1>
<p className="text-gray-500 mt-1">
{stats.total} total · {stats.todo} to do ·{" "}
{stats.inProgress} in progress · {stats.done} done
</p>
</div>
<Link
href="/tasks/new"
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
>
New Task
</Link>
</div>
<TaskFilters />
{tasks.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p className="text-lg">No tasks found</p>
<p className="mt-2">Create your first task to get started.</p>
</div>
) : (
<div className="space-y-4 mt-6">
{tasks.map((task) => (
<TaskCard key={task._id} task={task} />
))}
</div>
)}
{totalPages > 1 && (
<Pagination currentPage={page} totalPages={totalPages} />
)}
</div>
);
}Step 9: Build the Task Card Component
Create a Client Component for each task with status toggling and delete actions.
Create src/app/tasks/task-card.tsx:
"use client";
import { useTransition } from "react";
import Link from "next/link";
import { deleteTask, toggleTaskStatus } from "@/lib/actions/task-actions";
import type { SerializedTask } from "@/lib/actions/task-queries";
const statusColors = {
todo: "bg-gray-100 text-gray-800",
"in-progress": "bg-blue-100 text-blue-800",
done: "bg-green-100 text-green-800",
};
const priorityColors = {
low: "bg-slate-100 text-slate-700",
medium: "bg-yellow-100 text-yellow-800",
high: "bg-red-100 text-red-800",
};
const nextStatus: Record<string, "todo" | "in-progress" | "done"> = {
todo: "in-progress",
"in-progress": "done",
done: "todo",
};
export function TaskCard({ task }: { task: SerializedTask }) {
const [isPending, startTransition] = useTransition();
const handleStatusToggle = () => {
startTransition(async () => {
await toggleTaskStatus(task._id, nextStatus[task.status]);
});
};
const handleDelete = () => {
if (!confirm("Are you sure you want to delete this task?")) return;
startTransition(async () => {
await deleteTask(task._id);
});
};
return (
<div
className={`border rounded-lg p-4 transition ${
isPending ? "opacity-50" : ""
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<button
onClick={handleStatusToggle}
className={`px-2 py-1 rounded-full text-xs font-medium ${
statusColors[task.status]
}`}
>
{task.status}
</button>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
priorityColors[task.priority]
}`}
>
{task.priority}
</span>
{task.dueDate && (
<span className="text-xs text-gray-500">
Due {new Date(task.dueDate).toLocaleDateString()}
</span>
)}
</div>
<Link href={`/tasks/${task._id}`} className="group">
<h3 className="text-lg font-semibold group-hover:text-blue-600 transition">
{task.title}
</h3>
</Link>
<p className="text-gray-600 mt-1 line-clamp-2">{task.description}</p>
{task.tags.length > 0 && (
<div className="flex gap-1 mt-2">
{task.tags.map((tag) => (
<span
key={tag}
className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded text-xs"
>
{tag}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 ml-4">
<Link
href={`/tasks/${task._id}/edit`}
className="text-gray-400 hover:text-blue-600 transition"
>
Edit
</Link>
<button
onClick={handleDelete}
className="text-gray-400 hover:text-red-600 transition"
>
Delete
</button>
</div>
</div>
</div>
);
}Step 10: Build the Task Form
Create a reusable form component for creating and editing tasks.
Create src/app/tasks/task-form.tsx:
"use client";
import { useActionState } from "react";
import { createTask, updateTask, ActionState } from "@/lib/actions/task-actions";
import type { SerializedTask } from "@/lib/actions/task-queries";
const initialState: ActionState = {
success: false,
message: "",
};
export function TaskForm({ task }: { task?: SerializedTask }) {
const action = task
? updateTask.bind(null, task._id)
: createTask;
const [state, formAction, isPending] = useActionState(action, initialState);
return (
<form action={formAction} className="space-y-6 max-w-2xl">
{state.message && !state.success && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{state.message}
</div>
)}
{state.success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
{state.message}
</div>
)}
<div>
<label htmlFor="title" className="block text-sm font-medium mb-1">
Title
</label>
<input
id="title"
name="title"
type="text"
defaultValue={task?.title}
required
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter task title"
/>
{state.errors?.title && (
<p className="text-red-500 text-sm mt-1">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-1">
Description
</label>
<textarea
id="description"
name="description"
defaultValue={task?.description}
required
rows={4}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Describe the task"
/>
{state.errors?.description && (
<p className="text-red-500 text-sm mt-1">
{state.errors.description[0]}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="status" className="block text-sm font-medium mb-1">
Status
</label>
<select
id="status"
name="status"
defaultValue={task?.status || "todo"}
className="w-full border rounded-lg px-3 py-2"
>
<option value="todo">To Do</option>
<option value="in-progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
<div>
<label htmlFor="priority" className="block text-sm font-medium mb-1">
Priority
</label>
<select
id="priority"
name="priority"
defaultValue={task?.priority || "medium"}
className="w-full border rounded-lg px-3 py-2"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
</div>
<div>
<label htmlFor="dueDate" className="block text-sm font-medium mb-1">
Due Date (optional)
</label>
<input
id="dueDate"
name="dueDate"
type="date"
defaultValue={task?.dueDate ? task.dueDate.split("T")[0] : ""}
className="w-full border rounded-lg px-3 py-2"
/>
</div>
<div>
<label htmlFor="tags" className="block text-sm font-medium mb-1">
Tags (comma-separated)
</label>
<input
id="tags"
name="tags"
type="text"
defaultValue={task?.tags.join(", ")}
className="w-full border rounded-lg px-3 py-2"
placeholder="frontend, urgent, bug"
/>
</div>
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition"
>
{isPending
? task
? "Updating..."
: "Creating..."
: task
? "Update Task"
: "Create Task"}
</button>
</form>
);
}Step 11: Build the Filter Component
Create src/app/tasks/task-filters.tsx:
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useState } from "react";
export function TaskFilters() {
const router = useRouter();
const searchParams = useSearchParams();
const [search, setSearch] = useState(searchParams.get("search") || "");
const updateFilter = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value && value !== "all") {
params.set(key, value);
} else {
params.delete(key);
}
params.delete("page");
router.push(`/tasks?${params.toString()}`);
},
[router, searchParams]
);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
updateFilter("search", search);
};
return (
<div className="flex flex-wrap items-center gap-4">
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search tasks..."
className="border rounded-lg px-3 py-2 w-64"
/>
<button
type="submit"
className="bg-gray-100 px-3 py-2 rounded-lg hover:bg-gray-200 transition"
>
Search
</button>
</form>
<select
value={searchParams.get("status") || "all"}
onChange={(e) => updateFilter("status", e.target.value)}
className="border rounded-lg px-3 py-2"
>
<option value="all">All Status</option>
<option value="todo">To Do</option>
<option value="in-progress">In Progress</option>
<option value="done">Done</option>
</select>
<select
value={searchParams.get("priority") || "all"}
onChange={(e) => updateFilter("priority", e.target.value)}
className="border rounded-lg px-3 py-2"
>
<option value="all">All Priority</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
);
}Step 12: Build the Create and Edit Pages
Create src/app/tasks/new/page.tsx:
import Link from "next/link";
import { TaskForm } from "../task-form";
export default function NewTaskPage() {
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="mb-8">
<Link href="/tasks" className="text-blue-600 hover:underline">
← Back to Tasks
</Link>
<h1 className="text-3xl font-bold mt-4">Create New Task</h1>
</div>
<TaskForm />
</div>
);
}Create src/app/tasks/[id]/edit/page.tsx:
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTaskById } from "@/lib/actions/task-queries";
import { TaskForm } from "../../task-form";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function EditTaskPage({ params }: PageProps) {
const { id } = await params;
const task = await getTaskById(id);
if (!task) notFound();
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="mb-8">
<Link href="/tasks" className="text-blue-600 hover:underline">
← Back to Tasks
</Link>
<h1 className="text-3xl font-bold mt-4">Edit Task</h1>
</div>
<TaskForm task={task} />
</div>
);
}Step 13: Build the Task Detail Page
Create src/app/tasks/[id]/page.tsx:
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTaskById } from "@/lib/actions/task-queries";
interface PageProps {
params: Promise<{ id: string }>;
}
const statusLabels = {
todo: "To Do",
"in-progress": "In Progress",
done: "Done",
};
export default async function TaskDetailPage({ params }: PageProps) {
const { id } = await params;
const task = await getTaskById(id);
if (!task) notFound();
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<div className="mb-6">
<Link href="/tasks" className="text-blue-600 hover:underline">
← Back to Tasks
</Link>
</div>
<div className="border rounded-lg p-6">
<div className="flex items-center gap-3 mb-4">
<span className="px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
{statusLabels[task.status]}
</span>
<span className="px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
{task.priority} priority
</span>
</div>
<h1 className="text-2xl font-bold mb-4">{task.title}</h1>
<p className="text-gray-700 whitespace-pre-wrap mb-6">
{task.description}
</p>
{task.tags.length > 0 && (
<div className="flex gap-2 mb-6">
{task.tags.map((tag) => (
<span
key={tag}
className="bg-gray-100 text-gray-600 px-3 py-1 rounded-full text-sm"
>
{tag}
</span>
))}
</div>
)}
<div className="border-t pt-4 text-sm text-gray-500 space-y-1">
{task.dueDate && (
<p>Due: {new Date(task.dueDate).toLocaleDateString()}</p>
)}
<p>Created: {new Date(task.createdAt).toLocaleDateString()}</p>
<p>Updated: {new Date(task.updatedAt).toLocaleDateString()}</p>
</div>
<div className="mt-6">
<Link
href={`/tasks/${task._id}/edit`}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
>
Edit Task
</Link>
</div>
</div>
</div>
);
}Step 14: Add the Pagination Component
Create src/app/tasks/pagination.tsx:
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
interface PaginationProps {
currentPage: number;
totalPages: number;
}
export function Pagination({ currentPage, totalPages }: PaginationProps) {
const searchParams = useSearchParams();
const createPageUrl = (page: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", page.toString());
return `/tasks?${params.toString()}`;
};
return (
<div className="flex items-center justify-center gap-2 mt-8">
{currentPage > 1 && (
<Link
href={createPageUrl(currentPage - 1)}
className="px-3 py-2 border rounded-lg hover:bg-gray-50 transition"
>
Previous
</Link>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<Link
key={page}
href={createPageUrl(page)}
className={`px-3 py-2 border rounded-lg transition ${
page === currentPage
? "bg-blue-600 text-white border-blue-600"
: "hover:bg-gray-50"
}`}
>
{page}
</Link>
))}
{currentPage < totalPages && (
<Link
href={createPageUrl(currentPage + 1)}
className="px-3 py-2 border rounded-lg hover:bg-gray-50 transition"
>
Next
</Link>
)}
</div>
);
}Step 15: Add a Database Seed Script
Create a script to populate your database with sample data for testing.
Create src/scripts/seed.ts:
import mongoose from "mongoose";
import dotenv from "dotenv";
dotenv.config({ path: ".env.local" });
const MONGODB_URI = process.env.MONGODB_URI!;
const taskSchema = new mongoose.Schema(
{
title: { type: String, required: true },
description: { type: String, required: true },
status: { type: String, enum: ["todo", "in-progress", "done"], default: "todo" },
priority: { type: String, enum: ["low", "medium", "high"], default: "medium" },
dueDate: Date,
tags: [String],
},
{ timestamps: true }
);
const Task = mongoose.model("Task", taskSchema);
const sampleTasks = [
{
title: "Set up project authentication",
description: "Implement NextAuth.js with Google and GitHub providers. Include session management and protected routes.",
status: "done",
priority: "high",
tags: ["auth", "security"],
},
{
title: "Design the dashboard layout",
description: "Create responsive dashboard with sidebar navigation, header with user menu, and main content area.",
status: "in-progress",
priority: "high",
dueDate: new Date("2026-04-20"),
tags: ["design", "frontend"],
},
{
title: "Implement API rate limiting",
description: "Add rate limiting middleware using Upstash Redis. Configure per-route limits and return proper 429 responses.",
status: "todo",
priority: "medium",
dueDate: new Date("2026-04-25"),
tags: ["api", "security", "backend"],
},
{
title: "Write unit tests for task actions",
description: "Cover all CRUD operations with unit tests using Vitest. Mock the MongoDB connection for isolated testing.",
status: "todo",
priority: "medium",
tags: ["testing"],
},
{
title: "Add dark mode support",
description: "Implement system-aware dark mode with manual toggle. Use CSS variables for theme colors.",
status: "todo",
priority: "low",
tags: ["design", "frontend"],
},
{
title: "Optimize MongoDB queries",
description: "Review and add proper indexes. Implement aggregation pipelines for the analytics dashboard.",
status: "todo",
priority: "medium",
dueDate: new Date("2026-05-01"),
tags: ["database", "performance"],
},
];
async function seed() {
await mongoose.connect(MONGODB_URI);
console.log("Connected to MongoDB");
await Task.deleteMany({});
console.log("Cleared existing tasks");
await Task.insertMany(sampleTasks);
console.log(`Seeded ${sampleTasks.length} tasks`);
await mongoose.disconnect();
console.log("Done!");
}
seed().catch(console.error);Add a seed script to your package.json:
{
"scripts": {
"seed": "npx tsx src/scripts/seed.ts"
}
}Run it:
npm run seedStep 16: Test Your Application
Start the development server and verify everything works:
npm run devOpen http://localhost:3000/tasks and test:
- Create — Click "New Task", fill in the form, submit
- Read — View the task list with filters and pagination
- Update — Click "Edit" on a task, modify fields, submit
- Delete — Click "Delete" and confirm
- Filter — Use the status and priority dropdowns
- Search — Type a keyword and search
- Status toggle — Click the status badge to cycle through states
Advanced Patterns
Aggregation Pipelines
MongoDB's aggregation framework is powerful for analytics. Here is how to get task completion rates by priority:
export async function getCompletionRatesByPriority() {
await connectDB();
const results = await Task.aggregate([
{
$group: {
_id: "$priority",
total: { $sum: 1 },
completed: {
$sum: { $cond: [{ $eq: ["$status", "done"] }, 1, 0] },
},
},
},
{
$project: {
priority: "$_id",
total: 1,
completed: 1,
rate: {
$round: [{ $multiply: [{ $divide: ["$completed", "$total"] }, 100] }, 1],
},
},
},
{ $sort: { rate: -1 } },
]);
return results;
}Mongoose Middleware (Hooks)
Add pre/post hooks for common operations:
taskSchema.pre("save", function (next) {
if (this.isModified("status") && this.status === "done") {
this.set("completedAt", new Date());
}
next();
});
taskSchema.post("save", function (doc) {
console.log(`Task "${doc.title}" saved with status: ${doc.status}`);
});Virtual Fields
Add computed fields without storing them:
taskSchema.virtual("isOverdue").get(function () {
if (!this.dueDate || this.status === "done") return false;
return new Date() > this.dueDate;
});
taskSchema.set("toJSON", { virtuals: true });Production Best Practices
1. Connection Pooling
The connection utility we built handles pooling, but tune it for production:
const opts = {
bufferCommands: false,
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
};2. Index Management
Always verify your indexes are being used:
# In MongoDB Atlas, use the Performance Advisor
# Or in mongosh:
db.tasks.find({ status: "todo" }).explain("executionStats")3. Environment Variables
Use different connection strings per environment:
# .env.local (development)
MONGODB_URI=mongodb+srv://dev-user:***@dev-cluster.mongodb.net/task-manager
# Production (Vercel/Railway)
MONGODB_URI=mongodb+srv://prod-user:***@prod-cluster.mongodb.net/task-manager4. Error Handling
Wrap database operations with proper error handling:
export async function createTask(/* ... */): Promise<ActionState> {
try {
await connectDB();
// ... operation
} catch (error) {
if (error instanceof mongoose.Error.ValidationError) {
return { success: false, message: "Invalid data provided" };
}
return { success: false, message: "An unexpected error occurred" };
}
}5. Deploy to Vercel
MongoDB Atlas works perfectly with Vercel's serverless functions:
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel
# Add your environment variable
vercel env add MONGODB_URIMake sure your Atlas cluster allows connections from Vercel's IP ranges (or use 0.0.0.0/0 with strong authentication).
Troubleshooting
"MongoServerError: bad auth"
Your connection string credentials are wrong. Double-check the username and password in Atlas under Database Access.
"MongooseServerSelectionError: connection timed out"
Your IP is not whitelisted. Go to Atlas Network Access and add your current IP or 0.0.0.0/0 for development.
"OverwriteModelError: Cannot overwrite model"
This happens during hot reload. The mongoose.models.Task || pattern in the model file prevents this.
"buffering timed out after 10000ms"
The connection failed silently. Ensure MONGODB_URI is set and the cluster is running. Check with:
node -e "console.log(process.env.MONGODB_URI)"Next Steps
Now that you have a working MongoDB + Next.js application, explore these enhancements:
- Authentication — Add user accounts with NextAuth.js so each user sees only their tasks
- Real-time updates — Use MongoDB Change Streams with Server-Sent Events for live updates
- File attachments — Store files in MongoDB GridFS or UploadThing
- Full-text search — Upgrade to MongoDB Atlas Search for fuzzy matching and autocomplete
- Caching — Add Redis caching for frequently accessed queries
Conclusion
You have built a complete full-stack CRUD application with MongoDB Atlas, Mongoose, and Next.js 15 App Router. The application includes type-safe schemas, Server Actions for mutations, Zod validation, full-text search, filtering, pagination, and production-ready connection management.
MongoDB's flexible document model makes it ideal for rapid prototyping and applications with evolving schemas. Combined with Mongoose's validation layer and Next.js Server Actions, you get a productive, type-safe development experience with minimal boilerplate.
The complete source code for this tutorial is available and ready to extend for your own projects.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Full-Stack App with Prisma ORM and Next.js 15 App Router
Learn how to build a full-stack application with Prisma ORM, Next.js 15 App Router, and PostgreSQL. This tutorial covers schema modeling, migrations, Server Actions, CRUD operations, relations, and production deployment.

Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production
Learn how to build a type-safe full-stack application using Drizzle ORM with Next.js 15. This hands-on tutorial covers schema design, migrations, Server Actions, CRUD operations, and deployment with PostgreSQL.

Neon Serverless Postgres with Next.js App Router: Build a Full-Stack App with Database Branching
Learn how to build a full-stack Next.js application powered by Neon serverless Postgres. This tutorial covers the Neon serverless driver, database branching for preview deployments, connection pooling, and production-ready patterns.