How it's built

For years this site lived in WordPress. The articles were fine. Themes, plugins, and a CMS that never cared whether a MongoDB code sample looked right were not.

Start here

The rewrite keeps the same public article URLs. Readers should not notice the CMS change. I should notice it every time I write.

Posts live in MongoDB. The admin is built for technical posts with real code blocks. The Next.js app ships through GitHub Actions. Authoring and Shipping below go into those two paths.

Philosophy

  • One admin. A handful of careful posts. I needed a publishing tool for me, not a CMS for a team that does not exist. No users table. No roles. No page builder.
  • If it does not help writing, it does not ship. No ORM. No plugin system I will never use. A dependency has to make authoring safer or clearer, or it stays out.
  • Keep the routes thin. Pages render. Publishing rules live in services. Repositories talk to MongoDB. Secrets and ObjectId values stay on the server.
  • Do not break the bookmarks. Existing article paths stay at /slug/. I can change how content is stored. I cannot casually change what people already share.

Did I use AI?

Yes. It is 2026. I used large language models on this project, and I would not pretend otherwise.

What I did not do is throw prompts at a chat box until something sorta worked. That approach produces code that looks finished and falls apart the first time you read it.

I wrote the plans first: what the product is, how the data should look, what to build next. Then I mixed hand coding and agent help, and reviewed the result as I went. Project docs and rules kept the model from wandering. I still own every choice on this page.

The LLM is a tool in the workflow, not the workflow itself.

Stack

Next.js App Router
App Router with Server Components by default. Client JavaScript only where the Lexical editor actually needs the browser.
TypeScript
TypeScript for strict typing across routes, services, and the shapes we store in MongoDB.
MongoDB Atlas
Where posts and their revisions live in Atlas. Official Node.js driver. No ORM.
Zod
Zod checks untrusted form and action input on the server before anything hits the database.
Lexical
Lexical editor state for drafts and published versions of an article.
Shiki
Shiki for syntax highlighting on public code panels. Same look you see in the articles.
Storybook
Storybook runs locally with the shared primitives and composites so I can keep those components organized and visually inspectable.
GitHub Actions
GitHub Actions validates on pull requests and main (format, lint, unit tests, build, E2E), then deploys main when those checks pass.
Runtime
Cloudflare in front, reverse proxy to Next.js, MongoDB Atlas for data.

Components

The public site and the admin share the same buttons, type, and cards. If I change how a heading looks, it should change on an article card and in the editor, not in two places that happen to match.

The small controls live in src/components/ui/. Bigger blocks that more than one page needs live one folder down in composites/. Storybook is where I look at them without loading a whole page.

  • Primitives Button, Badge, TextLink, TextField, InlineCode, Heading, and Text. Article cards and the post editor import the same files.
  • Composites Callout, TopicCard, Breadcrumb, SectionToc, CodePanel, and GuideCard. They stay presentational. Home cards, search, and this page wrap them with the data they need.
  • Storybook I run npm run storybook locally on port 6006. Each story sits next to its component, so I can inspect a GuideCard or a CodePanel without opening the site. Deploys also build a static catalog for the same stories.

Architecture

Say you open /world-cup-finding-the-goal-with-match/ looking for a $match example. The app looks up that slug, checks that the article is published, loads the published version, and renders it. If I have only saved a draft, you get a 404. Unfinished edits stay off the public site.

Under the hood it is a thin stack: pages call services, services call repositories, repositories talk to MongoDB with the official Node.js driver. Publishing logic stays out of the UI so the routes can stay boring. The Database section below walks through the actual documents and fields.

Browser
   │
   ├─ Public pages (Server Components)
   └─ Admin (Lexical Client Components)
            │
            ▼
     Server Actions
            │
            ▼
        Services
     (publish, drafts, slugs)
            │
            ▼
      Repositories
            │
            ▼
      MongoDB Atlas
       posts + postRevisions

Why it's built this way

  • Publishing adds a new version. It does not overwrite the old one. Each publish creates a new revision document. The previous one stays in the database. The live article just points at whichever revision is current.
  • Drafts and the public page are separate. I can keep editing a draft without changing what readers see until I publish again.
  • Trusted HTML is derived on the server. The browser is not the authority for article HTML. Validated Lexical JSON is what we store for editing; the server builds the HTML the public site renders.
  • Content integrity is not a deploy concern. Releasing the app and changing articles are different workflows. A code deploy is not allowed to wipe or rewrite the post library.
  • ObjectId values and Dates stay on the server. Client Components get strings. Same class of gotcha as making sure a TTL field is a real Date, not a string that only looks like one.

Authoring

WordPress gave me an admin too. What it did not give me was an editor that treated a mongosh example as first-class content instead of something a theme might mangle on the way out.

The editor is Lexical. Code blocks are first-class (language included), and public pages highlight them with Shiki. Save Draft and Publish are explicit. Preview stays behind an authenticated path so unfinished work does not leak. Every Save Draft, Publish, restore, or delete checks auth again on the server. The UI is not trusted as a gate.

Each of those actions inserts a new immutable revision. Lexical JSON is the editing source; the revision also carries the HTML and plain text the server derived for that save.

Shipping code

Application code ships through GitHub Actions. Pull requests and pushes to main run validation: format check, lint, unit tests, build, and E2E. Deploy runs from main after those jobs succeed.

That pipeline only updates the Next.js app. Publishing still happens in the admin against MongoDB. The deploy job is not a content migration.

Database

Two collections, a few pointers, and a clear rule about what the public site is allowed to read.

Why MongoDB

When you open an article on Learn Mongo, you are reading a document, not joining five tables to rebuild a post. Title, excerpt, body, SEO fields: that is one package I load and render.

A relational database could run this. I picked MongoDB because the data already looks like documents, I teach MongoDB for a living, and I wanted the site itself to practice what the articles preach. That also means caring about document shape when it matters.

The classroom version of that idea is familiar: given a pile of recipe documents, which ingredients show up most often? You answer with $unwind and $group, not by normalizing the pantry into five tables first.

Co-location

Store together what you read together. A published revision is one trip to the database: the body you see, the description in the browser tab, the slug that built the URL. The same idea shows up when related values live in an embedded array instead of another table.

I still keep the article's identity separate from each saved version of its content. The post document changes when routing or taxonomy changes. A revision document does not get edited after it is created. Different jobs, different collections.

Collections

Everything hangs off two collections. Meet the shapes here, then Pointers walks through how those fields move when I save, publish, or restore.

A document in posts is identity and routing state. It owns the public slug, status (draft or published), categories, tags, and timestamps. It also holds two pointers: currentDraftRevisionId for what I am editing, and publishedRevisionId for what the public site may show. Think of it as the scoreboard entry for an article, not the article body itself.

posts document (trimmed)
{
  "_id": "…",
  "slug": "world-cup-finding-the-goal-with-match",
  "status": "published",
  "currentDraftRevisionId": "…",
  "publishedRevisionId": "…",
  "categories": ["Aggregation Framework"],
  "tags": ["$match", "World Cup"],
  "publishedAt": "2022-11-28T00:00:00.000Z",
  "createdAt": "…",
  "updatedAt": "…"
}

A document in postRevisions is one immutable copy of the article content. It stores title, excerpt, Lexical JSON as the editing source, derived HTML and plain text for the public page, and SEO fields. revisionNumber counts up per post. type records why it was created (draft, publish, or import). Once inserted, that revision document is not edited in place.

postRevisions document (trimmed)
{
  "_id": "…",
  "postId": "…",
  "revisionNumber": 4,
  "type": "publish",
  "title": "World Cup: Finding the Goal with $match",
  "slug": "world-cup-finding-the-goal-with-match",
  "excerpt": "…",
  "content": {
    "format": "lexical",
    "editorState": { "…": "…" },
    "html": "<p>…</p>",
    "plainText": "…"
  },
  "seo": {
    "description": "…"
  },
  "createdAt": "…",
  "createdBy": "admin"
}

Pointers and revisions

currentDraftRevisionId is the revision I am editing. publishedRevisionId is the revision the public site is allowed to show. They can be the same right after a publish. They often are not while I am mid-draft.

Save Draft inserts a new draft revision and moves only the draft pointer. Publish inserts a new publish revision and moves both pointers. On first publish, publishedAt is set and kept for listing and sort order. Later republishes move the pointers without rewriting history. Unpublish flips status back to draft without creating another revision, so the old published copy is still there when I republish.

Restore loads an older revision into the editor. The next save creates a new draft revision. History is never silently mutated or quietly repointed. Older revisions stay in the collection either way. History is just documents that neither pointer currently names.

Indexes

A few indexes exist because of the queries and constraints this site actually runs. Each one backs a concrete find, sort, or uniqueness rule. The declarations sit in the snippets below; here is what each one is for.

On the posts collection, a unique slug index backs equality lookups when someone hits an article URL. Uniqueness is the other half: two posts cannot claim the same path.

A compound index on status plus publishedAt backs listing queries that keep only published posts and sort newest first. That is the shape behind the article index, homepage cards, and RSS.

On postRevisions, an index on postId plus createdAt backs history-style lists (one post, newest revision first). A unique index on postId plus revisionNumber backs allocating the next revision number, and makes a collision fail cleanly if something retries.

Header search uses a separate Atlas Search index on postRevisions, not a classic text index. It maps title for both full-text and autocomplete, plus content.plainText for body matches. Search below walks through the query that uses it.

Declaring the indexes is the easy part. Rolling a new one out safely in production is where people get careful.

posts indexes (mongosh)
db.posts.createIndex({ slug: 1 }, { unique: true });

db.posts.createIndex({
  status: 1,
  publishedAt: -1,
});
postRevisions indexes (mongosh)
db.postRevisions.createIndex({
  postId: 1,
  createdAt: -1,
});

db.postRevisions.createIndex(
  {
    postId: 1,
    revisionNumber: 1,
  },
  {
    unique: true,
  },
);
Atlas Search index (trimmed definition)
db.postRevisions.createSearchIndex({
  name: "postRevisions_search",
  definition: {
    mappings: {
      dynamic: false,
      fields: {
        title: [
          { type: "string", analyzer: "lucene.english" },
          {
            type: "autocomplete",
            tokenization: "edgeGram",
            minGrams: 1,
            maxGrams: 15,
          },
        ],
        content: {
          type: "document",
          fields: {
            plainText: {
              type: "string",
              analyzer: "lucene.english",
            },
          },
        },
      },
    },
  },
});

ObjectId boundary

Inside repositories and services, IDs are ObjectIds. When data crosses into a Client Component, those become hex strings. Dates become ISO strings. A TTL index only works when that timestamp is a real Date, not a string that looks like one.

Same class of gotcha as geo coordinates wanting [longitude, latitude], not the other way around. Get the type wrong at the boundary and something subtle breaks later. So serialization is explicit, not accidental.

Public read path

The public article page is one aggregation, not a pile of ad hoc finds. It is picky on purpose, and the Indexes above are built for the shapes in that query.

First stage: $match. Keep only a post whose slug matches the URL, whose status is "published", and that already has a publishedRevisionId. A draft never gets past this filter, so unfinished work cannot become a public page by accident.

Next: $lookup from publishedRevisionId on posts into _id on postRevisions, then $unwind that array. The body, title, and SEO fields come from that joined revision document, not from whatever happens to sit on the draft pointer.

If nothing survives those stages, the route returns 404. If one document does, that is the article.

Public article load (trimmed aggregation)
[
  {
    $match: {
      slug: "world-cup-finding-the-goal-with-match",
      status: "published",
      publishedRevisionId: { $exists: true },
    },
  },
  {
    $lookup: {
      from: "postRevisions",
      localField: "publishedRevisionId",
      foreignField: "_id",
      as: "publishedRevision",
    },
  },
  { $unwind: "$publishedRevision" },
  // schemaVersion gate omitted
  { $limit: 1 },
]

Listings (article index, homepage cards, RSS, sitemap) use the same published filter and sort by { publishedAt: -1 }. Same invariant, no need for a second full pipeline walkthrough here.

Code spotlights

Publish creates a revision

Publish does not edit the last revision in place. It builds trusted content from Lexical JSON, inserts a new publish revision, then points both currentDraftRevisionId and publishedRevisionId at that document.

publishing service (trimmed)
const revision = await insertRevision({
  postId,
  revisionNumber,
  type: "publish",
  title,
  slug,
  excerpt,
  content, // lexical + derived html + plainText
  seo,
  createdBy: "admin",
});

await updatePostById(postId, {
  currentDraftRevisionId: revision._id,
  publishedRevisionId: revision._id,
  status: "published",
  publishedAt,
});

Serialize before the client

ObjectId values and Dates never cross into Client Components as raw MongoDB types. Hex strings and ISO strings do.

post serializers (trimmed)
export function serializePost(post: PostDocument) {
  return {
    id: post._id.toHexString(),
    slug: post.slug,
    status: post.status,
    currentDraftRevisionId:
      post.currentDraftRevisionId.toHexString(),
    publishedRevisionId:
      post.publishedRevisionId?.toHexString(),
    publishedAt: post.publishedAt?.toISOString(),
    // …
  };
}

Reserved slugs stay out of articles

Routes like /book/ and /how-its-built/ are not fair game for a post slug. Validation rejects reserved values before they hit MongoDB.

slug validation (trimmed)
const RESERVED_POST_SLUGS = [
  "admin",
  "articles",
  "book",
  "how-its-built",
  // …
] as const;

if (isReservedPostSlug(slug)) {
  return { ok: false, error: "This slug is reserved." };
}

Where concepts live

There is no public tutorial repo to wander through here. When I need to find something, I think in layers.

LayerPurpose
Public UIHome, articles, book, and this guide. Mostly Server Components.
Admin UIPost list, Lexical editor, drafts, publish, and preview. Client components only where the browser is required.
Shared UIShared buttons, type, and cards in components/ui. Storybook is where I inspect them.
ServicesDraft and publish workflows, slug rules, revision creation, route revalidation.
RepositoriesCollection access with the official driver. Queries, inserts, updates, ObjectId handling.
Content modelposts for identity and pointers. postRevisions for immutable copies of article content.

Where to go next

Learn Mongo, VerseSee, and the AI image search app share a pattern: server-first Next.js, MongoDB as the source of truth, thin routes that call services and repositories, and a How it's built page that explains the choices in public.

Want more MongoDB from here, or the same kind of behind-the-scenes write-up on those other projects?