What you'll learn

By the end of this you'll understand the concrete decisions that shaped Baseline's content architecture — the routing model, the data layer, the rendering strategy, and the parts that looked fine on paper and then immediately fell over. You'll be able to apply the same thinking to any content-heavy Next.js project.

This is a build log, not a tutorial. There's no single exercise at the end. Instead you'll see a live project evolve from "skeleton" to "something you'd actually maintain."

Who this is for

  • Developers building content sites or documentation platforms with Next.js App Router
  • Engineers who've read the docs but want to see how architecture decisions play out under real constraints
  • Anyone who's ever said "we'll clean this up later" and wondered what later actually looks like

You can skip this if your site has fewer than ten pages. At that scale, architecture is procrastination. Come back when your routing file starts to itch.

What is the Baseline content hub? Plain English

Baseline is a multi-topic blog covering AI tools, developer tutorials, career advice, and resource roundups. Each topic is a "hub" with its own URL namespace, its own set of content types (tutorials, news, case studies, reviews), and its own index pages. Under the hood it's a single Next.js App Router codebase reading from a MongoDB collection.

The content hub is the code that makes all of that work: the routes, the repository layer, the rewrite pipeline, and the rendering templates. Think of it as the scaffolding that lets you add a new article without touching routing code.

Prerequisites

  • Working knowledge of Next.js App Router (app/ directory, server components, dynamic routes)
  • Basic MongoDB familiarity (find, aggregation)
  • Comfortable reading TypeScript

Setup from zero

Step 1 — Define a content schema before touching routes

The first thing we did was lock down a Zod schema for a Content document. One type, one collection, one schema — every article type (tutorial, case study, review, etc.) is a value in a discriminated field, not a separate collection.

export const contentSchema = z.object({
  slug: z.string().min(1),
  type: z.enum(contentTypes),
  hub: z.enum(hubs),
  title: z.string().min(1),
  excerpt: z.string().min(1),
  body: z.string().min(1),
  tags: z.array(z.string()).default([]),
  entities: z.array(z.string()).default([]),
  seo: seoSchema,
  status: z.enum(["draft", "published"]).default("published"),
  publishedAt: z.string(),
  updatedAt: z.string(),
  authorId: z.string(),
  readingTime: z.string(),
});

Why this matters: if you reach for a schema after writing routes, you spend three hours renaming fields. Do it first.

Step 2 — Map types to URL segments

export const typePathSegment: Record<ContentType, string> = {
  tutorial: "tutorials",
  "case-study": "case-studies",
  review: "reviews",
  // …
};

A contentPath() helper uses this map to build the canonical URL for any document. Routes never hardcode strings — they call the helper. One change propagates everywhere.

Step 3 — Build a repository layer over MongoDB

export async function getContentBySlug(slug: string): Promise<Content | null> {
  const db = await getDb();
  const raw = await db.collection("content").findOne({ slug, status: "published" });
  if (!raw) return null;
  return contentSchema.parse(raw);
}

The repository is the only place that talks to MongoDB. Pages call the repository. Nothing else. This sounds obvious until it isn't — the first version had three pages doing their own findOne calls and the schema drift started within a week.

Step 4 — Generate static params from the collection

export async function generateStaticParams() {
  const slugs = await getAllSlugs({ type: "case-study", hub: "resources" });
  return slugs.map((slug) => ({ slug }));
}

Next.js calls this at build time. At request time, the page just calls getContentBySlug. No runtime MongoDB queries on the critical path.

Step 5 — Wire SEO at the route level

export async function generateMetadata({ params }: Props) {
  const post = await getContentBySlug(params.slug);
  if (!post) return {};
  return {
    title: post.seo.title,
    description: post.seo.description,
    alternates: { canonical: `https://baseline.sh${contentPath(post)}` },
  };
}

Metadata and the page function call the same repository function. Next.js deduplicates the fetch via the request cache — no double round trips.

The mental model

The mental model for a content hub is: data flows in one direction, from collection to schema to repository to page.

Routes don't know about MongoDB. Templates don't know about slugs. The repository is the chokepoint — everything passes through it and comes out typed and validated. If something breaks at runtime, it broke at the repository boundary, which makes debugging fast.

Key terms

Hub — a top-level URL namespace (/ai, /developers, /resources) that groups related content types.

Rewrite — a TypeScript record that overrides a seeded document with a richer, human-edited version. Applied at build time; the seed is the fallback.

Repository — the module that owns all database reads. Returns typed Content objects, never raw documents.

Static params — the list of slugs Next.js needs to pre-render dynamic routes at build time.

Type path segment — the URL piece that corresponds to a content type (e.g. case-studycase-studies).

Step-by-step

The first related-posts implementation did a tag-based $in query on every page load. On a cold Lambda it added 400 ms. We moved it to a separate endpoint called with low priority after first paint, and pre-computed the top three related slugs during the seed phase.

Approach: build time pre-computation + deferred client request for fresher picks.

Outcome: LCP unaffected; related posts appear after ~300 ms client-side.

Problem: the rewrite pipeline ran on every request

The original design parsed all rewrite files on import and merged them in a module-level Map. Fine in development. In production the map was rebuilding on every cold start because of edge runtime module isolation. We moved the merge to a build-time script that writes a single JSON manifest.

Approach: compile step, not runtime merge.

Outcome: cold start reduced by ~180 ms on the first request.

Problem: index pages showed draft content in preview

generateStaticParams fetched slugs without a status: "published" filter. Drafts sneaked into the build. One line fix, but it caused a confused support email first.

Working examples

A case-study page in the final architecture:

app/
  case-studies/
    [slug]/
      page.tsx    ← calls getContentBySlug, returns ArticleShell
    page.tsx       ← calls getContentList({ type: "case-study" })

The ArticleShell template receives the typed Content object and renders title, excerpt, byline, body (via MDX renderer), and related posts. No prop drilling beyond the Content type.

Patterns

The single collection pattern — one MongoDB collection, one schema, type is a field. Fewer joins, simpler queries, easier migrations.

The repository-as-chokepoint pattern — all DB reads go through the repository module. Validation errors surface in one place.

The compile-time merge pattern — apply rewrites during build, not at runtime. Keeps edge functions lean.

Common mistakes

Skipping Zod on the way in. You trust the seed data until you don't. One malformed document crashes a page at build time with a cryptic error. Parse on the way out of the repository, every time.

Dynamic routes without generateStaticParams on a content site. You end up with server-side rendering for every slug request. Fine for low traffic; expensive at scale and bad for TTFB.

Little tip

If your generateStaticParams is slow, project only the slug field in the MongoDB query. You don't need the full document — just the key.

Little tip

Name your rewrite phases with numbers (phase1.ts, phase22.ts) rather than feature names. When you're debugging a regression you want to know the order things were applied, not hunt through content-improvements-final-v3.ts.

Troubleshooting

Build time explodes after adding rewrites. Check whether your rewrite files are being re-parsed per module. Move the merge to a build script or a singleton module with cache().

Metadata doesn't match body. You have two separate DB calls — one in generateMetadata, one in the page. Confirm Next.js is deduplicating via request memoization. If not, wrap the repository call in cache() from React.

Index page shows 404 for a slug that exists. The slug is in the collection but status is "draft". The list query filters by published; the detail route doesn't — or vice versa. Make the filter consistent.

Checklist

  • [ ] Zod schema defined before any route code
  • [ ] Repository layer owns all DB reads
  • [ ] contentPath() helper used everywhere — no hardcoded URL strings
  • [ ] generateStaticParams filters by status: "published"
  • [ ] generateMetadata shares the repository call with the page function
  • [ ] Rewrite pipeline runs at build time, not request time
  • [ ] Index pages tested with draft content in DB to confirm no leakage

Practice task

Fork the repository, add a new hub called "tools" with a single content type "tool-review". Wire up the schema, the repository filter, the typePathSegment entry, and a /tools/[slug] route. Verify it builds and the metadata is correct.

FAQ

Why not use MDX files instead of MongoDB?
Files are simpler for ten articles. A database gives you filtered queries, pagination, and the ability to update content without a deploy. At Baseline's scale, the deploy-on-every-post model stopped working around article 50.

Why Zod instead of TypeScript interfaces?
TypeScript types are erased at runtime. Zod validates the actual data from MongoDB — it catches the case where a field is missing or has the wrong type before it crashes a page.

Can this pattern work with a headless CMS?
Yes. Swap the MongoDB findOne for a CMS API call in the repository. The rest of the architecture doesn't change.

What to learn next

  • Deep dive into Next.js cache() and request memoization — essential once your repository functions are called from both metadata and page functions
  • MongoDB aggregation pipelines for related-content queries
  • next build output analysis — understanding which routes are static, dynamic, or ISR
  • [Next.js App Router basics](/developers/nextjs/nextjs-app-router-basics)
  • [MongoDB schema design for content sites](/resources/mongodb-schema-content-sites)
  • [Building a JSON formatter tool](/case-studies/json-formatter-tool-build)

Takeaways

The Baseline content hub works because it treats the data contract as first-class — the schema, the repository, the path helpers all enforce the same model. Every time we skipped a step in that chain, something broke in production within a week.

If you remember only one thing: define your content schema before you write a single route, and make the repository the only thing that talks to the database. Everything else can be refactored. Those two constraints are genuinely hard to retrofit.