Deploying a Fully-Featured Knowledge Base with Next.js and MDX
A knowledge base is a different problem from a marketing site's blog, even though both start as "pages made of Markdown." A knowledge base needs a table of contents that reflects a real hierarchy, search that returns the right article out of hundreds, and a way for one page to embed live components (a copyable code block, a collapsible admonition) inside otherwise-static prose — none of which plain Markdown gives you, and all of which MDX and Next.js's static generation do. Automating docs from TypeDoc covers generating MDX from source comments; this guide covers the reader-facing side once that MDX exists — turning a folder of .mdx files into a fast, searchable, navigable site.
The running example is a docs site with three nested categories (Getting Started, API Reference, Guides) and forty articles, where a reader searches "rate limit," gets the right three results in under 100ms client-side, and one long API reference page auto-builds a sticky table of contents from its own headings. You'll see why content is treated as data (not routes), how static generation turns that data into pages at build time, how to build the table of contents and search index from the same source, and what breaks when an MDX file has a frontmatter typo.
Content as data, not as routes
The foundational decision is treating every .mdx file as a data record — slug, frontmatter (title, category, order), and compiled body — rather than manually wiring one Next.js route per article. A content/ directory of nested folders (content/getting-started/installation.mdx) gets walked at build time, each file's frontmatter parsed with gray-matter, and the result assembled into an array the rest of the app queries like a small database: getArticleBySlug(), getArticlesByCategory(), getAllSlugs() for generateStaticParams().
This indirection is what makes the table of contents, search index, and "related articles" section all possible without hand-maintaining three parallel lists — they're all derived views over the same content array, computed once at build time. The alternative (a literal file per route, with navigation links hardcoded in a layout component) works for five pages and becomes an increasingly error-prone manual sync the moment a new article needs to appear in three places (nav, search, sitemap) at once.
Quick reference
gray-mattersplits frontmatter YAML from MDX body; validate required fields (title,category) at build time so a missing field fails the build, not a page render.- Compute a
slugfrom the file path, not from frontmatter, so a renamed file can't silently orphan its old URL without you noticing in the diff. - Sort articles by an explicit
orderfrontmatter field within a category — alphabetical file-name order rarely matches the reading sequence a knowledge base needs. - Keep the content array as a build-time module export, not a runtime fetch, so every page that needs it (search index, sitemap, nav) reads the same computed data with zero extra I/O.
Remember this
Parse every MDX file into one shared content array at build time — the nav, search index, and related-articles list are all derived views of that array, not independently maintained lists.
Static generation and the build-time render
With the content array in hand, generateStaticParams() returns every slug so Next.js pre-renders each article at build time — no per-request MDX compilation, no client-side waterfall to fetch content before showing anything. The MDX body compiles through next-mdx-remote or @next/mdx into a React tree at build time as well, which is what lets an MDX file embed live components (<Callout>, <CodeBlock language="ts">) inside otherwise-static prose — the component boundary is resolved once, not on every page view.
The operational trade-off: static generation means new content requires a rebuild to appear, which is fine for docs that change on a release cadence but wrong for a knowledge base that non-engineers edit hourly through a CMS. If that's your case, pair static generation with Incremental Static Regeneration (revalidate on the page, or on-demand revalidatePath triggered from your CMS webhook) so an editor's save shows up within seconds instead of waiting for the next deploy.
Quick reference
generateStaticParams()needs every valid slug at build time — a slug missing from that list 404s even if the file exists on disk.- On-demand
revalidatePath()from a CMS webhook gives near-instant updates without the blanket cost ofrevalidate: 0on every page. - Compile MDX once per build, cache the compiled tree — recompiling the same unchanged file on every request is the single most common perf mistake in MDX sites.
- Server Components can render most of an MDX page; reserve Client Components for genuinely interactive pieces (see server vs. client components).
Remember this
Static generation with generateStaticParams() removes per-request MDX compilation; add on-demand revalidation only where editors need faster-than-deploy updates.
Table of contents and client-side search from the same data
The table of contents for one article is built by walking the compiled MDX's heading nodes (an MDX AST plugin like remark-toc or a custom rehype visitor collecting h2/h3 text and generating anchor ids) at build time, not by re-parsing the rendered HTML in the browser — that keeps the TOC in sync with the actual heading text with zero runtime cost. Site-wide search is the same shape at a larger scale: build a lightweight search index (FlexSearch or Fuse.js, both small enough to ship client-side) from every article's title, headings, and a truncated body excerpt, generated once at build time and served as a static JSON asset the client fetches once on first search-box focus.
Client-side search only stays viable up to a few hundred articles' worth of index size — past that, ship a server endpoint backed by a real search service instead of shipping the whole index to the browser. For forty articles, a FlexSearch index is typically under 100KB gzipped and returns results in single-digit milliseconds with no network round trip after the initial load.
Quick reference
- Generate heading ids deterministically (
slugify(headingText)) at the same build step that produces the TOC, so anchor links and TOC entries never drift apart. - Truncate indexed body text to a few hundred characters per article — indexing full article bodies bloats the client bundle for marginal ranking improvement.
- Debounce the search input (150–250ms) before querying the index; querying on every keystroke is wasted work for a local index that answers in microseconds anyway.
- Past roughly 300–500 articles, move search server-side (Algolia, Meilisearch, or a Postgres full-text index) — see full-text search: Elasticsearch vs. pgvector for that trade-off.
Remember this
Build the TOC and the search index from the same compiled MDX pass at build time — both are derived data, and client-side search only scales to a few hundred articles before it needs a server.
When a frontmatter typo breaks the build
The realistic failure: someone renames a category (getting-started → get-started) in one file's frontmatter but not in the sidebar navigation config, or misspells a required field (titel instead of title). Without validation, this either crashes the build with an unhelpful stack trace deep inside the MDX compiler, or worse, silently renders with undefined where the title should be and ships a broken page to production. The fix is a schema check (Zod) run against every file's frontmatter immediately after parsing, before it enters the shared content array — fail the build with the offending file's path and field name, not three layers removed inside a rendering function.
This is the same discipline as validating any external input at the boundary: MDX frontmatter written by a human in a text editor is exactly as untrusted as a form submission, and the cost of catching a typo at build time (a red CI check) is far lower than catching it after deploy (a live page with a blank title).
Quick reference
- Define a Zod schema for frontmatter (
z.object({ title: z.string(), category: z.enum([...]), order: z.number() })) and.parse()every file at load time. - Fail the build on a schema violation — don't fall back to a default title silently, or the typo ships and nobody notices until a reader reports it.
- Validate category values against the actual list of nav categories, not a free-text string, so a renamed category and a stale frontmatter value are caught by the type system.
- Run this validation in CI on every PR that touches
content/, not only locally — a contributor without the full toolchain configured is the most likely source of a typo.
Remember this
Validate every MDX file's frontmatter against a Zod schema at build time — treat content files as untrusted input with the same discipline as an API request body.
Key takeaway
Build a content/docs/ folder with three MDX files across two categories, each with title, category, and order frontmatter, plus a page and index generation script following the pattern above. Expected result: npm run build (or next build) produces three static pages, a sidebar grouped by category in order, and a search-index.json that a client-side FlexSearch query for one article's title returns correctly. Then break it — delete the title field from one file's frontmatter and rebuild. Recovery: the Zod-validated content loader should fail the build immediately with that file's path and the missing field name, not proceed to a broken page. Pass criterion: three pages render with correct TOC anchors, search returns the right article in under 100ms client-side, and the deliberately broken frontmatter fails the build with a specific, actionable error instead of a generic stack trace.
Related Articles
Explore this topic