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
ObjectIdvalues 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, andText. Article cards and the post editor import the same files. - Composites
Callout,TopicCard,Breadcrumb,SectionToc,CodePanel, andGuideCard. They stay presentational. Home cards, search, and this page wrap them with the data they need. - Storybook I run
npm run storybooklocally on port 6006. Each story sits next to its component, so I can inspect aGuideCardor aCodePanelwithout 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 + postRevisionsWhy 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.
ObjectIdvalues and Dates stay on the server. Client Components get strings. Same class of gotcha as making sure a TTL field is a realDate, not a string that only looks like one.
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.
{
"_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.
{
"_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.
db.posts.createIndex({ slug: 1 }, { unique: true });
db.posts.createIndex({
status: 1,
publishedAt: -1,
});db.postRevisions.createIndex({
postId: 1,
createdAt: -1,
});
db.postRevisions.createIndex(
{
postId: 1,
revisionNumber: 1,
},
{
unique: true,
},
);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.
[
{
$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.
Search
The header Search control opens a full-viewport overlay (also via Cmd/Ctrl+K). Typing a character (after a short debounce) calls a Server Action. No internal HTTP API hop. The action validates the query, asks the repository for matches, and returns titles and excerpts the client can navigate. Esc, the close control, or the backdrop dismisses the overlay. There is still no dedicated /search route.
Searchable text lives on revisions, so the aggregation starts on postRevisions with $search. That stage must come first. A compound.should clause matches title autocomplete (edge n-grams for prefixes), title full-text, and content.plainText, with title boosted above body. The score comes from { $meta: "searchScore" }.
Matching a draft or an old publish snapshot would be useless (and wrong). After scoring, the pipeline $lookups posts and keeps only rows where this revision is the post's publishedRevisionId and status is published. Same published-only invariant as the public article page, approached from the other collection.
[
{
$search: {
index: "postRevisions_search",
compound: {
should: [
{
autocomplete: {
query: "match",
path: "title",
score: { boost: { value: 10 } },
},
},
{
text: {
query: "match",
path: "title",
score: { boost: { value: 8 } },
},
},
{
text: {
query: "match",
path: "content.plainText",
score: { boost: { value: 1 } },
},
},
],
minimumShouldMatch: 1,
},
},
},
{ $match: { schemaVersion: { $gte: 2 } } },
{ $addFields: { score: { $meta: "searchScore" } } },
{
$lookup: {
from: "posts",
let: { revisionId: "$_id" },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ["$publishedRevisionId", "$$revisionId"] },
{ $eq: ["$status", "published"] },
],
},
},
},
{ $project: { _id: 1, slug: 1 } },
{ $limit: 1 },
],
as: "post",
},
},
{ $unwind: "$post" },
{ $sort: { score: -1 } },
{ $limit: 12 },
]Autocomplete on title is what makes short prefixes useful. Body text still participates through content.plainText, so a full word in an article can surface even when it is not in the title, just at a lower boost.
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.
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.
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.
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.
| Layer | Purpose |
|---|---|
| Public UI | Home, articles, book, and this guide. Mostly Server Components. |
| Admin UI | Post list, Lexical editor, drafts, publish, and preview. Client components only where the browser is required. |
| Shared UI | Shared buttons, type, and cards in components/ui. Storybook is where I inspect them. |
| Services | Draft and publish workflows, slug rules, revision creation, route revalidation. |
| Repositories | Collection access with the official driver. Queries, inserts, updates, ObjectId handling. |
| Content model | posts 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?