
Using MongoDB with Next.js App Router and Server Actions
For years, putting MongoDB behind a React app meant building two things: the UI, and a separate REST or GraphQL API for the UI to call. Every new feature needed a fetch call, an endpoint, request validation on both sides, and some state management to keep the screen in sync with the database.
The Next.js App Router removes most of that ceremony. Server Components run on the server, so they can query MongoDB directly and send rendered UI to the browser. Server Actions let a form or button call a server function that writes to the database, with no hand-written endpoint in between. And the caching APIs (use cache, cacheTag, updateTag) let you decide exactly which reads are cached and when they're refreshed after a write.
This guide builds a small notes app to show the full loop: a connection module that survives hot reloading, a server-only data layer, cached reads, Server Actions for creating and deleting notes, form state with useActionState, dynamic routes, and the mistakes that bite people when mixing database code with React components. It targets recent Next.js (16.x) with Cache Components enabled and the MongoDB Node.js driver 6.x.
Project Setup
Create an app and install the driver:
npx create-next-app@latest notes-app --typescript --app
cd notes-app
npm install mongodb server-only zod
Add your connection string to .env.local:
MONGODB_URI="mongodb+srv://notes_app:s3cret@cluster0.abcd1.mongodb.net/?appName=notes-app"
MONGODB_DB="notes"
Don't prefix these with NEXT_PUBLIC_. That prefix inlines the value into the browser bundle, which is the last place you want a database password.
Enable Cache Components in your Next config. This turns on the use cache directive and the caching model used throughout this guide:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
The Connection Module
In a long-running Node.js server, you'd create one MongoClient at module level and be done. Next.js adds a wrinkle: in development, hot module reloading re-evaluates modules on every change, and each evaluation would create a new client with a new connection pool. After an hour of editing, you can have dozens of orphaned pools open against your cluster.
The standard fix is to stash the client on globalThis in development:
// lib/mongodb.ts
import "server-only";
import { MongoClient, type Db } from "mongodb";
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error("MONGODB_URI is not set");
}
const globalForMongo = globalThis as typeof globalThis & {
_mongoClient?: MongoClient;
};
const client =
globalForMongo._mongoClient ??
new MongoClient(uri, {
appName: "notes-app",
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
});
if (process.env.NODE_ENV !== "production") {
globalForMongo._mongoClient = client;
}
export function getDb(): Db {
return client.db(process.env.MONGODB_DB);
}
There's no explicit connect() call. The driver connects lazily on the first operation, which works well in environments where a module might be loaded but never used.
The import "server-only" line makes the build fail if a Client Component ever imports this file, directly or through a chain of imports. It's a cheap, strong guarantee that your connection string never ends up in JavaScript sent to the browser.
If you deploy to a serverless platform, each function instance keeps its own pool, so a modest maxPoolSize matters more than usual. Using MongoDB in Serverless Functions Without Exhausting Connections covers that in depth.
A Server-Only Data Layer
Rather than scattering queries across pages, keep them in a data module. That gives you one place to handle types, serialization, and caching.
// lib/notes.ts
import "server-only";
import { ObjectId } from "mongodb";
import { cacheLife, cacheTag } from "next/cache";
import { getDb } from "./mongodb";
type NoteDoc = {
_id: ObjectId;
title: string;
body: string;
createdAt: Date;
};
export type Note = {
id: string;
title: string;
body: string;
createdAt: Date;
};
function notes() {
return getDb().collection<NoteDoc>("notes");
}
function toNote(doc: NoteDoc): Note {
return {
id: doc._id.toHexString(),
title: doc.title,
body: doc.body,
createdAt: doc.createdAt,
};
}
export async function listNotes(): Promise<Note[]> {
"use cache";
cacheLife("hours");
cacheTag("notes");
const docs = await notes().find().sort({ createdAt: -1 }).limit(50).toArray();
return docs.map(toNote);
}
export async function getNote(id: string): Promise<Note | null> {
"use cache";
cacheLife("hours");
cacheTag("notes", `note:${id}`);
if (!/^[0-9a-f]{24}$/i.test(id)) return null;
const doc = await notes().findOne({ _id: new ObjectId(id) });
return doc ? toNote(doc) : null;
}
export async function insertNote(input: { title: string; body: string }) {
const { insertedId } = await notes().insertOne({
_id: new ObjectId(),
...input,
createdAt: new Date(),
});
return insertedId.toHexString();
}
export async function removeNote(id: string) {
if (!/^[0-9a-f]{24}$/i.test(id)) return;
await notes().deleteOne({ _id: new ObjectId(id) });
}
Two things are happening here.
Serialization. The toNote function converts _id into a plain string. Values that cross from server to client (props passed to Client Components) and values returned from cached functions must be serializable. Strings, numbers, plain objects, arrays, and Date are fine. An ObjectId is a class instance and isn't, so returning raw documents is a common source of cryptic errors. Converting at the data layer means the rest of your app never sees an ObjectId at all.
Caching. The "use cache" directive caches the function's return value. cacheLife("hours") sets how long the cached value is considered fresh, and cacheTag labels the entry so a Server Action can invalidate it after a write. The arguments to a cached function become part of the cache key, so getNote("abc") and getNote("def") are cached separately.
Tagging each note with both "notes" and note:${id} lets you invalidate everything at once or just one note.
Reading Data in a Server Component
With the data layer in place, a page is just an async component that awaits it:
// app/page.tsx
import Link from "next/link";
import { listNotes } from "@/lib/notes";
import { NewNoteForm } from "./new-note-form";
import { DeleteButton } from "./delete-button";
export default async function HomePage() {
const notes = await listNotes();
return (
<main className="mx-auto max-w-2xl p-6">
<h1 className="text-2xl font-bold">Notes</h1>
<NewNoteForm />
<ul className="mt-6 space-y-3">
{notes.map((note) => (
<li key={note.id} className="flex items-center justify-between">
<Link href={`/notes/${note.id}`}>{note.title}</Link>
<DeleteButton id={note.id} />
</li>
))}
</ul>
</main>
);
}
Because listNotes is cached, its result can be included in the page's prerendered static shell. That also means the build needs access to your database, since Next.js runs cached functions during prerendering. If your CI environment can't reach MongoDB, that's the first build error you'll see.
Uncached, Per-Request Reads
Not everything should be cached. A dashboard showing live order counts, or anything that depends on the current user, should run fresh on each request. With Cache Components, you leave out "use cache" and wrap the component in Suspense, so the rest of the page can be prerendered while this part streams in at request time:
// app/stats/page.tsx
import { Suspense } from "react";
import { getDb } from "@/lib/mongodb";
async function LiveCount() {
const count = await getDb().collection("notes").countDocuments();
return <p className="text-4xl font-bold">{count}</p>;
}
export default function StatsPage() {
return (
<main className="p-6">
<h1>Total notes</h1>
<Suspense fallback={<p>Counting...</p>}>
<LiveCount />
</Suspense>
</main>
);
}
If you forget the Suspense boundary around uncached data, Next.js tells you in development with a blocking-route warning and a suggested fix. That's a helpful nudge: it forces you to decide, per component, whether data is cached or streamed.
Writing Data with Server Actions
A Server Action is an async function marked with "use server" that the client can invoke. Put your actions in their own file so both Server and Client Components can import them:
// app/actions.ts
"use server";
import { z } from "zod";
import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
import { insertNote, removeNote } from "@/lib/notes";
import { getCurrentUser } from "@/lib/auth";
const noteSchema = z.object({
title: z.string().trim().min(1, "Title is required").max(120),
body: z.string().trim().max(5000).default(""),
});
export type FormState = {
errors?: { title?: string[]; body?: string[] };
message?: string;
};
export async function createNote(
prevState: FormState,
formData: FormData,
): Promise<FormState> {
const user = await getCurrentUser();
if (!user) {
return { message: "You must be signed in." };
}
const parsed = noteSchema.safeParse({
title: formData.get("title"),
body: formData.get("body") ?? "",
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const id = await insertNote(parsed.data);
updateTag("notes");
redirect(`/notes/${id}`);
}
export async function deleteNote(id: string) {
const user = await getCurrentUser();
if (!user) {
throw new Error("Unauthorized");
}
await removeNote(id);
updateTag("notes");
updateTag(`note:${id}`);
}
Here's what each piece does:
- Authentication inside the action. Server Actions are reachable by direct POST requests, not just through your UI. Treat every action like a public API endpoint: check who's calling and whether they're allowed.
getCurrentUserstands in for whatever auth library you use. - Validation.
FormDatavalues are strings (or files) supplied by the client, so validate them before they get near a query. Zod rejects anything unexpected and gives you field-level errors to show in the form. updateTag("notes")immediately expires every cache entry taggednotes. The next render of the home page runslistNotesagain and shows the new note.updateTagis designed for this read-your-own-writes case and can only be called from Server Actions.redirectsends the user to the new note's page. It works by throwing a special control-flow exception, so any code after it won't run. CallupdateTagfirst.
For changes where a short delay is acceptable (for example, a webhook reporting that content changed elsewhere), revalidateTag("notes", "max") marks the entry stale and refreshes it in the background instead of expiring it immediately. Unlike updateTag, it also works in Route Handlers.
A Form with Validation Feedback
To show errors and a pending state, make the form a Client Component and use React's useActionState hook:
// app/new-note-form.tsx
"use client";
import { useActionState } from "react";
import { createNote, type FormState } from "./actions";
const initialState: FormState = {};
export function NewNoteForm() {
const [state, formAction, pending] = useActionState(createNote, initialState);
return (
<form action={formAction} className="mt-4 space-y-2">
<input
name="title"
placeholder="Title"
className="w-full rounded border p-2"
aria-invalid={Boolean(state.errors?.title)}
/>
{state.errors?.title && (
<p className="text-sm text-red-600">{state.errors.title[0]}</p>
)}
<textarea
name="body"
placeholder="Write something..."
className="w-full rounded border p-2"
/>
{state.message && <p className="text-sm text-red-600">{state.message}</p>}
<button
type="submit"
disabled={pending}
className="rounded bg-black px-4 py-2 text-white"
>
{pending ? "Saving..." : "Add note"}
</button>
</form>
);
}
useActionState wraps the action so that it receives the previous state as its first argument (which is why createNote takes prevState before formData), and it gives you the latest returned state plus a pending flag. On success, the action redirects, so the form never needs to handle a success state at all.
Passing Arguments to Actions
Delete buttons need to know which note to delete. Rather than a hidden input, you can bind the ID to the action:
// app/delete-button.tsx
"use client";
import { useTransition } from "react";
import { deleteNote } from "./actions";
export function DeleteButton({ id }: { id: string }) {
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() => startTransition(() => deleteNote(id))}
className="text-sm text-red-600"
>
{pending ? "Deleting..." : "Delete"}
</button>
);
}
In a Server Component, you can also write <form action={deleteNote.bind(null, note.id)}> with a submit button. That version works even before JavaScript loads, since it's a real HTML form submission.
Either way, remember that the id argument comes from the client and can be anything. In a real app, deleteNote should confirm the note belongs to the current user before deleting it, for example by including an owner field in the delete filter: deleteOne({ _id, ownerId: user.id }).
Dynamic Routes
The note detail page reads the id from the URL. In current Next.js, params is a promise. With Cache Components, and without generateStaticParams, param values are request-time data, so their access must sit inside a Suspense boundary:
// app/notes/[id]/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { getNote } from "@/lib/notes";
export default function NotePage({ params }: PageProps<"/notes/[id]">) {
return (
<main className="mx-auto max-w-2xl p-6">
<Suspense fallback={<p>Loading note...</p>}>
{params.then(({ id }) => (
<NoteContent id={id} />
))}
</Suspense>
</main>
);
}
async function NoteContent({ id }: { id: string }) {
const note = await getNote(id);
if (!note) notFound();
return (
<article>
<h1 className="text-2xl font-bold">{note.title}</h1>
<time className="text-sm text-gray-500">
{note.createdAt.toLocaleString()}
</time>
<p className="mt-4 whitespace-pre-wrap">{note.body}</p>
</article>
);
}
PageProps is a globally available type helper generated by Next.js, so you get a typed params without importing anything. Because getNote is cached per ID, repeated visits to the same note don't hit MongoDB until its tag is invalidated.
Without Cache Components
If you haven't enabled cacheComponents, the older caching model applies, and it behaves differently in one important way: a page that queries MongoDB but doesn't use any request-time APIs (like cookies() or searchParams) can be prerendered once at build time and served as static HTML. Your database changes then never show up.
In that model, opt pages into request-time rendering with connection():
import { connection } from "next/server";
export default async function Page() {
await connection();
const notes = await listNotes();
// ...
}
And after a write in a Server Action, use revalidatePath("/") to refresh the affected route. Check the docs bundled with your installed Next.js version, since caching defaults have changed across major releases.
Route Handlers Still Have a Place
Server Actions are for mutations triggered by your own UI. When something outside your app needs to talk to your data (a mobile client, a webhook, a third-party integration), a Route Handler is still the right tool:
// app/api/notes/route.ts
import { listNotes } from "@/lib/notes";
export async function GET() {
const notes = await listNotes();
return Response.json(notes);
}
Both share the same data layer, so there's no duplication.
Common Pitfalls
Creating a new MongoClient per request or per module reload. Use one module-level client, and cache it on globalThis in development to survive hot reloading.
Passing raw documents to Client Components. ObjectId and other BSON class instances aren't serializable. Map documents to plain objects in your data layer.
Skipping auth in Server Actions. An action is a public POST endpoint in disguise. Verify the session and resource ownership inside every action.
Importing database code into a Client Component. Add import "server-only" to every module that touches MongoDB, so the mistake fails the build instead of shipping.
Calling redirect before updateTag. redirect throws, so anything after it never runs. Invalidate first, then redirect.
Forgetting that cached reads run at build time. With use cache, prerendering executes your queries. Make sure the build environment can reach the database, or keep those reads uncached behind Suspense.
Returning huge documents from cached functions. Cached return values are serialized and stored. Project only the fields the UI needs.
Conclusion
The App Router turns a MongoDB-backed app into a much smaller system. Server Components read through a server-only data layer, use cache with cacheTag decides which reads are cached, Server Actions validate input and write to the database, and updateTag makes the UI reflect the change immediately. The pieces that remain your responsibility are the same ones any backend needs: one shared client, plain serializable data at the boundary, validation, and authorization on every write.
A good next step is to add an ownerId field to notes, filter listNotes by the current user (passing the user ID into the cached function as an argument so each user gets their own cache entry), and include the owner in every delete filter. That single change exercises every concept in this guide.


