Build a Next.js and MongoDB Blog: Schema, Indexes, Caching - NextGenBeing
Back to discoveries

Build a Next.js and MongoDB Blog: Schema, Indexes, Caching

Build a Next.js App Router blog on MongoDB: post and tag schema, connection reuse, indexes, and static vs dynamic rendering with measured laptop timings.

DevOps 8 min read
Bekzod Erkinov

Bekzod Erkinov

Apr 18, 2026 • 233 views
Build a Next.js and MongoDB Blog: Schema, Indexes, Caching
Photo by Sammyayot254 on Unsplash
Size:
Height:
8 min read 2,576 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
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 mongodb driver 7.7.0 (latest on npm when I checked, 2026-10-07).
  • MongoDB 8.3.11 from the mongo:8 image: 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 long revalidate (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() or headers() makes a route dynamic anyway.
  • Dev mode proves nothing here. Pages are always rendered on demand in next dev, per the caching guide. Measure with next 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 dev processes were still alive and holding connections, so the counts were inflated. Kill the dev server, restart MongoDB, then measure.
  • next build needs the database. generateStaticParams queries MongoDB at build time, so MONGODB_URI must 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.
  • localhost on Windows. I switched my benchmark client to 127.0.0.1 to 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 text index.

What to do next

  1. Start MongoDB with the docker run line above, seed some posts, and copy the globalThis client module.
  2. Create the three indexes, then run explain("executionStats") on your own queries and compare docs examined to docs returned.
  3. Use generateStaticParams plus revalidate = 3600 for posts and the list; keep force-dynamic for per-user or search routes.
  4. Add a token-protected revalidatePath endpoint and call it from wherever you publish.
  5. Benchmark next start, not next dev, on your own hardware before deciding anything about hosting.
Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

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

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.