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

Noqta Team
By Noqta Team ·

Loading the Text to Speech Audio Player...

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:

FeatureMongoDB + MongoosePostgreSQL + PrismaSQLite + Drizzle
Data modelDocuments (JSON)Relational tablesRelational tables
SchemaFlexible, optionalStrict, requiredStrict, required
Type safetyMongoose + TS genericsAuto-generated typesInferred types
ScalingHorizontal (sharding)Vertical (read replicas)Single file
Nested dataNative embeddingJSON columns or joinsJSON columns or joins
Free tierAtlas 512 MB foreverNeon 0.5 GBLocal, unlimited
Best forFlexible schemas, rapid prototypingComplex relations, transactionsEdge, 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-manager

Install MongoDB and Mongoose dependencies:

npm install mongoose zod
npm install -D @types/mongoose

Your 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

  1. Go to MongoDB Atlas and sign up or log in
  2. Click Build a Database and select the M0 Free tier
  3. Choose your preferred cloud provider and region (select the closest to your users)
  4. Click Create Deployment

Configure Access

  1. Create a database user with a username and password
  2. In Network Access, click Add IP Address and select Allow Access from Anywhere (for development — restrict this in production)
  3. 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=majority

Replace <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: true automatically manages createdAt and updatedAt
  • Indexes on status and priority speed up filtered queries
  • Text index on title and description enables 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 &middot; {stats.todo} to do &middot;{" "}
            {stats.inProgress} in progress &middot; {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">
          &larr; 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">
          &larr; 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">
          &larr; 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 seed

Step 16: Test Your Application

Start the development server and verify everything works:

npm run dev

Open http://localhost:3000/tasks and test:

  1. Create — Click "New Task", fill in the form, submit
  2. Read — View the task list with filters and pagination
  3. Update — Click "Edit" on a task, modify fields, submit
  4. Delete — Click "Delete" and confirm
  5. Filter — Use the status and priority dropdowns
  6. Search — Type a keyword and search
  7. 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-manager

4. 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_URI

Make 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.


Want to read more tutorials? Check out our latest tutorial on Build a Real-Time App with Supabase and Next.js 15: Complete Guide.

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