Bekzod Erkinov
Listen to Article
Loading...Table of contents · 8 sections
Build a Next.js and MongoDB Blog: Schema, Indexes, Caching
Most "nextjs mongodb blog" tutorials stop at "it renders a list of posts". This one gets you a working App Router blog on MongoDB, then answers the three questions that bite later: why dev mode piles up connections, which indexes you actually need, and when static generation beats dynamic rendering.
One caveat up front: I can't reproduce a high-traffic site on one machine, so I make no claims about scale. Everything below was measured on a laptop (i7-8550U, 32 GB RAM, Windows 11, MongoDB in Docker, Next.js on the host), and I label the numbers that way. Read them as ratios, not capacity planning.
What I ran
- Next.js 16.4.0 and the
mongodbdriver 7.7.0 (latest on npm when I checked, 2026-10-07). - MongoDB 8.3.11 from the
mongo:8image:docker run -d --name blogmongo -p 27018:27017 mongo:8. - 50,000 seeded posts, each with a ~4 KB body and two tags drawn from 40; every 20th is a draft. A blog this size is unrealistic. I used it so missing indexes show up as real numbers.
No ORM. The plain driver is enough for a blog, and one less abstraction makes the connection behaviour easier to see.
Schema: embed tags, don't make a tags collection
{
slug: "post-49990", // unique, used in URLs
title: "Post number 49990",
excerpt: "…",
body: "…", // markdown or HTML
tags: ["tag-5", "tag-27"], // plain strings, embedded
status: "published", // or "draft"
publishedAt: ISODate("…"),
updatedAt: ISODate("…")
}
Tags are an array of strings on the post. A blog reads "posts with tag X" far more often than it edits a tag, and a tag has no data of its own beyond its name. Embedding means one query per tag page and no joins. If tags later need descriptions or images, add a tags collection keyed by name; the embedded strings keep working. The cost: renaming a tag means an updateMany.
Keep body out of list queries with a projection ({ body: 0 }). Cards don't need 4 KB each.
The connection gotcha: dev mode creates a client per edit
In next dev, a file edit makes the server re-evaluate changed modules. If the module that does new MongoClient(...) is re-evaluated, you get a new client with a new connection pool, and the old one is never closed. The official Next.js MongoDB example stores the client on globalThis in development for this reason. My version:
// lib/mongodb.js
import { MongoClient } from "mongodb";
const uri = process.env.MONGODB_URI;
const options = { appName: "blog-demo", maxPoolSize: 10 };
let client;
if (process.env.NODE_ENV === "development") {
// survive HMR: module is re-evaluated on every edit, globalThis is not
globalThis._mongoClient ??= new MongoClient(uri, options);
client = globalThis._mongoClient;
} else {
client = new MongoClient(uri, options);
}
export default client;
I didn't want to take that on faith. I started next dev against a freshly restarted MongoDB, edited the client module six times (one page request after each edit), and read serverStatus().connections.current:
| Variant | Connections at start | After 6 edits |
|---|---|---|
Plain new MongoClient at module level |
9 | 25 |
globalThis guard |
7 | 8 |
The counts include a few from my own mongosh calls, so compare the change, not the absolute value: +16 versus +1.
I also edited lib/posts.js, which imports the client but isn't the client module. With the unguarded version that added only 2 connections over six edits. So the leak is real but depends on what you edit; it's worst when the client module itself changes. Either way the guard costs three lines.
In production there's no HMR, so a module-level client is fine. The MongoDB docs say the same for serverless: define the client outside the handler so warm invocations reuse it. The driver docs give maxPoolSize a default of 100 and note each client opens up to two extra monitoring connections per server. That's why I set maxPoolSize: 10. On serverless, where many instances each hold a pool, a smaller pool plus maxIdleTimeMS (the Atlas Lambda page suggests 60000) helps you stay under your cluster's connection limit. I didn't test serverless, only the local dev behaviour above.
Queries and indexes
Three queries cover most of a blog:
const posts = () => client.db("blog").collection("posts");
const card = { projection: { body: 0 } };
export const listPosts = (page = 1, size = 20) =>
posts().find({ status: "published" }, card)
.sort({ publishedAt: -1 }).skip((page - 1) * size).limit(size).toArray();
export const getPost = (slug) => posts().findOne({ slug, status: "published" });
export const listByTag = (tag, size = 20) =>
posts().find({ status: "published", tags: tag }, card)
.sort({ publishedAt: -1 }).limit(size).toArray();
One index per query shape:
await posts.createIndex({ slug: 1 }, { unique: true });
await posts.createIndex({ status: 1, publishedAt: -1 });
await posts.createIndex({ tags: 1, status: 1, publishedAt: -1 });
The third is a multikey index because tags is an array. A compound index can contain at most one array field, which is another reason to keep exactly one array on the post. Equality fields go first (tags, status) and the sort field last, so the index returns rows already sorted.
I ran explain("executionStats") before and after on the 50k collection (laptop numbers):
| Query | No indexes | With indexes |
|---|---|---|
| By slug | COLLSCAN, 50,000 docs examined, 95 ms | 1 doc examined, 1 ms |
| Tag page, newest 20 | in-memory SORT, 50,000 docs, 165 ms | 20 keys, 20 docs, 5 ms |
| List page 1 | in-memory SORT, 50,000 docs, 212 ms | 20 keys, 20 docs, 0 ms |
The shape of the change matters more than the milliseconds: from "examines everything" to "examines what it returns". The milliseconds will differ on your hardware; the docs-examined column won't. If that number is far larger than the number of posts returned on your own explain output, an index is missing.
One note on skip: page 1 is cheap, but skip(n) still walks n index entries, so deep pagination slows down. Nobody reads page 400 of a blog, but if you expose it, paginate by a publishedAt cursor instead. I didn't measure that.
Measured from Node against the local container with the indexes in place, 500 runs each: findOne by slug had p50 3.97 ms and p95 10.82 ms; the 20-card tag query p50 4.35 ms and p95 10.88 ms. That's roughly 4 ms of floor on this machine for driver plus a 4 KB document.
Static, ISR, or dynamic
Next.js's ISR guide (version 16.4.0 when I read it) defines the options. generateStaticParams plus export const revalidate = N prerenders pages at build and serves them from cache; after N seconds the next request still gets the stale page while a fresh one regenerates in the background. export const dynamic = "force-dynamic" renders on every request. I used one of each:
// app/blog/[slug]/page.js - static + revalidate
export const revalidate = 3600;
export async function generateStaticParams() {
return (await topSlugs(100)).map((slug) => ({ slug }));
}
export default async function Post({ params }) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return <article><h1>{post.title}</h1><p>{post.body}</p></article>;
}
The dynamic route is the same component with export const dynamic = "force-dynamic" and no generateStaticParams.
next build prerendered 103 pages: the 100 newest slugs, /blog, and two framework pages. Any other existing slug is rendered on first request and cached afterwards; I saw x-nextjs-cache: MISS on a first request (about 1,036 ms on a cold server, including startup) and HIT after (about 20 ms). Unknown slugs 404.
Timings from next start (production build), 200 sequential requests from a Node script, plus 2,000 requests at 50 concurrent. Laptop numbers:
| Route | p50 | p95 | Throughput at 50 concurrent |
|---|---|---|---|
/blog/post-49990 (static, HIT) |
15.5 ms | 18.7 ms | 255 req/s |
/dyn/post-49990 (force-dynamic) |
24.8 ms | 42.0 ms | 114 req/s |
/tags/tag-5 (dynamic) |
16.3 ms | 38.0 ms | not run |
Two things stand out. A cache HIT still costs about 15 ms here, so most of that is the Node server and my test client, not MongoDB. And one run isn't stable: an earlier run gave 305 req/s for the static page against 255 here, so don't trust any figure to better than about ±20%. The useful signal is the ratio: static served roughly 2x the throughput of dynamic with a ~4 ms query behind it. The gap grows with query cost, since a static hit never touches the database.
I'm not claiming what this means at hundreds of thousands of monthly readers; I can't generate that load here. One thing the docs flag that a single machine hides: with several instances, the default filesystem cache is per instance, so revalidatePath only invalidates the instance that receives the call, and you'd need a shared cache handler.
Decision rule
- Post pages: static +
revalidate. Content changes rarely and every reader gets the same HTML. Use a longrevalidate(the docs recommend an hour over a second) and invalidate on demand after edits. - Post list and home page: static +
revalidate, same reasoning. - Tag pages: either. With 40 tags and a 4 ms query, dynamic is fine. If tag pages get real traffic, prerender them with
generateStaticParams. - Per-user or free-text search routes: dynamic. Reading
cookies()orheaders()makes a route dynamic anyway. - Dev mode proves nothing here. Pages are always rendered on demand in
next dev, per the caching guide. Measure withnext build && next start.
Invalidating after an edit
A one-hour revalidate means a typo fix shows up an hour late. A route handler can call revalidatePath:
// app/api/revalidate/route.js
import { revalidatePath } from "next/cache";
export async function POST(req) {
const { slug } = await req.json();
revalidatePath(`/blog/${slug}`);
return Response.json({ revalidated: slug });
}
I tested it: fetched the page (HIT), changed the title directly in MongoDB (page still showed the old title), POSTed to the route, fetched again. The header became MISS and the page showed EDITED title. As the docs say, revalidatePath invalidates and the next request regenerates.
This endpoint has no authentication. That's fine for a demo and wrong for anything exposed, so check a secret token before deploying it. Because the data functions use the driver rather than fetch, Next's fetch cache doesn't apply to them; the route-level revalidate is what caches the page. If you want to cache a single query across routes with tags, the docs show unstable_cache for that. I didn't need it and didn't run it.
Gotchas I hit
- My first connection test was wrong. Old
next devprocesses were still alive and holding connections, so the counts were inflated. Kill the dev server, restart MongoDB, then measure. next buildneeds the database.generateStaticParamsqueries MongoDB at build time, soMONGODB_URImust be set in your build environment and the database reachable from it.- Draft leaks. Every query filters
status: "published". A slug lookup without that filter would serve drafts to anyone who guesses the URL. localhoston Windows. I switched my benchmark client to127.0.0.1to avoid name-resolution noise; the numbers barely moved.
What I didn't test
- Running the Next.js app itself in Docker. Only MongoDB ran in a container.
- Atlas, serverless platforms, or more than one app instance.
- Mongoose, auth, an editor, or image handling.
- Text search. I didn't run a
textindex.
What to do next
- Start MongoDB with the
docker runline above, seed some posts, and copy theglobalThisclient module. - Create the three indexes, then run
explain("executionStats")on your own queries and compare docs examined to docs returned. - Use
generateStaticParamsplusrevalidate = 3600for posts and the list; keepforce-dynamicfor per-user or search routes. - Add a token-protected
revalidatePathendpoint and call it from wherever you publish. - Benchmark
next start, notnext dev, on your own hardware before deciding anything about hosting.
Keep reading
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log In