MDX in Next.js 16 — Frontmatter Parsing Error Broke 200 Blog Pages at Build Time
A missing space in YAML frontmatter caused 200 MDX blog pages to fail at build time.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- ✓Node.js 18+
- ✓Next.js 14+ project with App Router
- ✓Basic familiarity with Markdown and YAML
- A missing space in YAML frontmatter (
title:"Post"instead oftitle: "Post") caused 200 MDX pages to fail silently at build time — only caught when the build failed @next/mdxcompiles MDX files into React components at build time — no runtime parsing overhead, but frontmatter errors break the entire build- Draft mode works with
draftMode().enable()fromnext/headers— it only functions with on-demand ISR, NOT static exports (output: 'export') - remark plugins transform Markdown during compilation (syntax highlighting, auto-link headings) — rehype plugins transform the resulting HTML (table of contents, image optimisation)
- Frontmatter validation with Zod at build time prevents silent failures — validate before MDX compilation, not during
Imagine building a house where the architect left a single measurement off one blueprint page. Instead of noticing the missing dimension, the construction crew builds every room — and discovers at the end that one wall is missing. That's MDX frontmatter: a tiny metadata error in one of 200 blog posts can abort the entire build, requiring you to find which post has the typo among 200 files. A linter that catches missing measurements before construction starts would have saved hours.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
MDX combines Markdown with JSX, letting you write content in Markdown while embedding React components — charts, interactive demos, custom callouts — directly in your blog posts. Next.js supports MDX through the @next/mdx package, which compiles MDX files into React components at build time.
This compilation-at-build-time approach is fast — there is no runtime parsing overhead. But it introduces a catch: any MDX file with invalid frontmatter, syntax errors, or broken JSX imports will fail the entire build. In a production incident, a single YAML frontmatter parsing error — a missing space in title:"Post" instead of title: "Post" — caused the build to fail silently, and 200 blog pages didn't make it to production.
This article covers: configuring @next/mdx with App Router, frontmatter validation strategies, draft mode integration with headless CMS previews (including the critical gotcha that draft mode works only with on-demand ISR, not static export), remark and rehype plugin development, production patterns for large MDX collections, and how to recover when your build fails because one file has bad YAML.
By the end, you will have a production-grade MDX pipeline that validates frontmatter at build time, provides draft previews for editors, and catches syntax errors before they abort your deploy.
@next/mdx Setup — The Right Way With App Router
Setting up MDX in Next.js looks simple in the docs: install @next/mdx, configure it in next.config.ts, add MDX files. The reality: there are three different configuration approaches and only one works correctly with the App Router.
For the Pages Router, @next/mdx extends the Webpack config to compile MDX files into page components. For the App Router, the recommended approach is @next/mdx-remote (or the newer next-mdx-remote-client) which compiles MDX at request time or build time via a loader function. The key difference: @next/mdx with App Router requires the experimental.mdxRs flag (Rust-based MDX compiler) for optimal performance.
The configuration: install @next/mdx, @mdx-js/mdx, and @mdx-js/react. In next.config.ts, add withMDX wrapper. For frontmatter, use gray-matter (or front-matter) to parse YAML separately from MDX compilation — @next/mdx does not parse frontmatter into props automatically in the App Router.
The production pattern: separate frontmatter parsing from MDX compilation. Parse frontmatter with gray-matter at build time to generate a content index (JSON file with all posts' metadata). Then MDX compilation is a pure transform — no YAML involved. This separation means a frontmatter error never blocks MDX compilation.
experimental.mdxRs: true for 5-10x faster MDX compilation. The Rust-based compiler supports most remark/rehype plugins but has some limitations with custom JSX runtime configurations. Test with your plugin stack before enabling in CI.Frontmatter Validation — Crash CI, Not Production
The single most important rule for MDX at scale: validate frontmatter as a prebuild step and fail CI immediately on any invalid file. Do not trust that 50 writers across your organisation will write valid YAML every time. They will not.
A Zod schema defines the exact shape of valid frontmatter: required fields (title, publishedDate, author), optional fields (tags, description, canonical), and field types (string, date, array of strings). The prebuild script reads every MDX file, parses the frontmatter with gray-matter, validates against the schema, and aggregates all errors into a single report.
The report format: file path, field name, expected type, actual value. This lets developers fix all frontmatter issues in one pass rather than fixing one file, rebuilding, discovering the next error, and iterating.
For the content pipeline, validated frontmatter feeds into a content index — a JSON file generated during prebuild that contains all posts' metadata. The Next.js app reads this index at build time to generate post listings, RSS feeds, sitemaps, and related posts. The index is the single source of truth and is guaranteed to contain only valid data because invalid files crash the build.
next build in a getStaticPaths or generateStaticParams function means the build fails mid-way — confusing and slow. Run validation as a separate prebuild script that fails fast and gives a complete error report. Add it to your CI pipeline: node scripts/validate-frontmatter.js && next build.Draft Mode — The Headless CMS Preview Gotcha
Draft mode in Next.js lets you preview unpublished content by setting a cookie that forces ISR regeneration with draft data. Editors click 'Preview' in their headless CMS, which hits an API route that calls draftMode().enable(), then redirects to the page — which rerenders with draft content included.
The critical gotcha: draft mode ONLY works with on-demand ISR. It does NOT work with output: 'export' (static export) because static export generates all pages at build time — there are no running server instances to handle the draft cookie and trigger ISR regeneration. If your deployment uses static export for performance, draft mode cannot work.
Even within ISR, the flow requires: (1) An API route that authenticates the preview request (verify a secret token from the CMS), calls draftMode().enable(), and redirects to the page. (2) Your page or data fetching logic checks draftMode().isEnabled and includes unpublished content when true. (3) The cookie set by draftMode().enable() is HTTP-only and tied to the specific browser session — it is not shareable.
The practical implication: if your architecture demands static export (e.g., hosting on a CDN like S3+CloudFront), you need an alternative preview approach. Options: a separate preview environment (staging subdomain), client-side fetching of draft content after hydration, or a hybrid app where the CMS preview points to a separate ISR-hosted version of the same app.
output: 'export', draft mode will NOT work. Static export generates everything at build time — there is no server runtime to set cookies or trigger ISR. Choose: static export (fastest, no draft preview) or ISR (slightly slower per first request, full draft mode support). You cannot have both.output: 'standalone', which added 50ms to cold-start requests but enabled draft previews. Rule: decide on draft mode requirements before choosing your output strategy. If editors need previews, ISR is mandatory from day one.output: 'export' (static export).remark and rehype Plugins — When and How to Customise the Pipeline
remark and rehype are the plugin systems for unified.js — the ecosystem for processing Markdown and HTML. remark plugins operate on the Markdown AST (mdAST) before it is compiled to HTML. rehype plugins operate on the HTML AST (hAST) after compilation.
The mental model: remark transforms your Markdown source. rehype transforms the HTML output. If you want to add syntax highlighting to code blocks, you use a remark plugin (remark-prism or remark-shiki) that wraps code blocks with syntax-highlighted HTML during the Markdown phase. If you want to add a table of contents to the rendered HTML, you use a rehype plugin (rehype-slug + rehype-toc) that operates on the final HTML.
The production insight: most sites only need 2-3 plugins. The most common are remark-gfm (GitHub-flavoured Markdown: tables, strikethrough, task lists), rehype-slug (adds IDs to headings for anchor links), and rehype-prism-plus (syntax highlighting). Each additional plugin adds ~10-50ms to build time per file — with 500 files and 5 plugins, that's 25-250 seconds of additional build time.
Plugin order matters. Some plugins depend on others: rehype-toc needs rehype-slug to have run first (headings need IDs before a TOC can reference them). Always verify the plugin documentation for ordering requirements.
Content Collections — From Flat Files to Structured Data
Flat MDX files in a directory work for small blogs. For production sites with 100+ posts and multiple content types, you need a content layer — an abstraction between the file system and your pages that provides typed, filtered, and sorted post data.
The content layer reads all MDX files, parses frontmatter (validated against Zod), and builds a typed index. The index provides methods: getAllPosts(), getPostBySlug(slug), getPostsByTag(tag), getRelatedPosts(currentSlug). Each method returns typed objects with validated frontmatter and computed fields (reading time, excerpt, previous/next post).
In the App Router, this index is generated at build time and imported by pages. For static generation with generateStaticParams, the index provides the list of slugs. For ISR pages, the index provides frontmatter for SEO metadata (title, description, open graph). The index is also used server-side only — it never ships to the browser.
The performance benefit: the content index is a single JSON file loaded once at build time. No filesystem reads during page rendering — just array lookups and filters on the pre-built index. For 500 posts, filtering by tag takes microseconds instead of milliseconds per file read.
getStaticPaths — each fs.readFileSync + gray-matter parse took 5-15ms. With 1,200 files, slug generation alone took 6-18 seconds per build. Switching to a pre-built content index cut build time to 300ms for the same operation. Rule: never read frontmatter from MDX files during build-time rendering. Pre-build a content index from validated frontmatter.Custom MDX Components — Embedding React in Markdown
MDX's killer feature is embedding React components in Markdown. A blog post about charts can drop in <RevenueChart />. A tutorial can embed <InteractiveCodeEditor />. A callout can use <Alert type="warning"> — all without leaving the Markdown file.
The mechanism: when MDX compiles, it treats HTML-like tags as React components. The components must be registered via the MDXProvider (Pages Router) or the components prop on <MDXRemote> (App Router). Unregistered components become HTML — or throw errors at build time.
In the App Router, the pattern: your page component receives the MDX content as a React element from <MDXRemote>. You pass a components object mapping tag names to React components. For example, { Alert, CodeBlock, Image }. Any <Alert> in the MDX file renders as your Alert component.
The production pattern: build a small library of reusable MDX components — callout boxes, code groups with tabs, embedded videos, data tables, interactive demos. Keep the library under 10 components. Every component must be a Server Component (no 'use client') unless it genuinely needs interactivity. Client components in MDX should be the exception, not the default.
components prop on MDXRemote — keep the library under 10 components.Performance at Scale — MDX for 1,000+ Posts
MDX compilation is CPU-intensive. Each file requires: parsing the Markdown into an AST, transforming the AST through each remark plugin, compiling the AST into JSX components, and transforming through rehype plugins. With mdxRs, this takes ~10-50ms per file. For 1,000 files, that's 10-50 seconds of build time for MDX compilation alone.
Strategies for scale: (1) Incremental builds — only recompile changed MDX files. Next.js's Turbopack handles this automatically in development. For production, consider a custom build pipeline that checks file modification times. (2) Separate content from rendering — precompile MDX to serialized HTML during a prebuild step, then import the static HTML in your pages. This removes MDX compilation from the next build critical path. (3) Content caching — cache the compiled output of each MDX file between builds. Only recompile files whose timestamps changed.
The most impactful optimisation: move content that doesn't need JSX to plain Markdown (processed by a simple Markdown-to-HTML converter) and reserve MDX-only for pages that genuinely embed React components. On a 1,000-post site, 800 posts may use only standard Markdown — they don't need the MDX compilation pipeline.
.md with a simple Markdown-to-HTML converter (no MDX compilation) reduced build time from 15 minutes to 3 minutes — an 80% reduction. Rule: audit your MDX files regularly. If a file doesn't embed JSX, convert it to plain Markdown.Internationalisation and MDX — Multi-Language Content
Multilingual MDX sites present unique challenges: frontmatter must support per-language titles and descriptions, translation management must not duplicate content with fs.readdirSync issues, and the sitemap must correctly route to language-specific versions.
The frontmatter approach: use a languages object in frontmatter that maps locale to title and description. The content layer builds a per-locale index. A single MDX file contains all translations, separated by locale-specific sections, or you maintain separate MDX files per locale in language-named directories.
The recommended approach: separate files per locale in a directory structure: content/en/post.mdx, content/fr/post.mdx, content/de/post.mdx. Each file has its own frontmatter. The slug is derived from the filename (shared across locales). The content layer groups files by slug and builds a per-locale index.
Performance: compilation is per-file, so 500 posts × 5 locales = 2,500 MDX compilations. The tiered approach (plain Markdown for non-React content) becomes even more important at multilingual scale. Consider using next-intl or next-i18next for runtime translations and reserve MDX for content that truly needs React components.
lastReviewed field to frontmatter and alert on files where the source locale's lastModified is more than 30 days newer than the translation's.content/{locale}/slug.mdx structure for clean separation.Sitemap and RSS With MDX — Automating SEO
With 200+ MDX posts, maintaining a sitemap and RSS feed manually is impossible. Next.js provides generateSitemaps() for programmatic sitemap generation, and RSS can be generated at build time from the content index.
The sitemap should include: every blog post with its lastModified date from the file system, the canonical URL, and a priority based on content freshness (newer posts = higher priority). The RSS feed should include: title, description, published date, author, and an excerpt or full content.
Both the sitemap and RSS feed are generated at build time from the content index. They should be added to robots.txt for search engine discovery. The sitemap should also include image content for posts with featured images.
The production pattern: generate the sitemap and RSS feed in a prebuild script that reads the validated content index. Output them to the public/ directory so they are served statically. This avoids runtime computation and ensures they are always in sync with the deployed content.
<image:image> entries to your sitemap. This helps Google Images discover your content. Parse the frontmatter image field and add it as <image:loc> inside each post's <url> block.200 Blog Pages Missing From Production — A Single YAML Parsing Error
@next/mdx with default config skips invalid frontmatter silently — and if the build script used fs.readdirSync to list posts without validating frontmatter first, ALL posts would be excluded when the directory scan failed or when a catch-all handler returned empty data.title:"Advanced Guide" instead of title: "Advanced Guide" (colon immediately followed by quote, no space). YAML parsers treat title:" as a string starting with a quote character without separating key-value, causing parse failure. The @next/mdx compiler logged a warning but continued — no build error.
Second, the content layer was implemented as: scan all .mdx files, parse frontmatter, filter to only files with valid frontmatter. The filter was exclusive: only files with BOTH valid frontmatter AND a published: true field were included. The invalid file triggered a JavaScript runtime error in the filter function that returned undefined for the entire posts array. The template rendered an empty list. All 200 posts disappeared.
The fix: frontmatter validation before the filter, with explicit error handling that throws on invalid files. A Zod schema for frontmatter runs at build time and crashes CI if any file has invalid metadata. Additionally, the content layer now includes files with invalid frontmatter in a separate 'drafts' directory, and the build script surfaces all frontmatter errors as a consolidated report at the end of the build.validate-frontmatter.ts script that runs as a prebuild step: reads every MDX file, parses frontmatter with gray-matter, validates against a Zod schema, and fails the build with a complete list of errors. Changed the content filter from exclusive (valid frontmatter + published) to inclusive (show drafts locally, only filter on production build). Added a build-time report that lists all posts with their frontmatter fields at the end of next build output — making it obvious if posts are missing. The invalid frontmatter was fixed: title:"Advanced Guide" → title: "Advanced Guide".- Frontmatter parsing errors in MDX do not fail the build by default — they log a warning that scrolls past unnoticed in CI
- Always validate frontmatter with Zod at build time in a prebuild step — never trust that hand-written YAML is valid
- Any filter that silently excludes posts is a footgun — the content pipeline should include all files and let the template handle visibility
- A single bad frontmatter can cascade through the entire content layer — validate early, validate explicitly, and crash CI on failure
draftMode().enable() with ISR — it does NOT work with output: 'export'. Check that your middleware or API route sets the draft cookie correctly: draftMode().enable() must be called before the response is sentTransformer (function that takes (tree, file) => void). rehype plugins operate on hAST (HTML AST), not mdAST (Markdown AST). If you're passing a remark plugin to the rehype array, it will silently do nothingmdxComponents object passed to MDXProvider. For App Router, components must be in the components prop of <MDXRemote>. Server Components used in MDX must not have 'use client' — they render at build timenpx next build 2>&1 | grep -i 'mdx\|frontmatter\|yaml'find content/ -name '*.mdx' -exec sh -c 'node -e "const m=require(\"gray-matter\")(require(\"fs\").readFileSync(\"$1\",\"utf8\"))"' _ {} \; 2>&1node scripts/validate-frontmatter.js that parses every MDX file with gray-matter and reports failures with line numbers| File | Command / Code | Purpose |
|---|---|---|
| next.config.ts | const nextConfig: NextConfig = { | @next/mdx Setup |
| scripts | const frontmatterSchema = z.object({ | Frontmatter Validation |
| app | export async function GET(request: Request) { | Draft Mode |
| lib | const postSchema = z.object({ | Content Collections |
| components | function Callout({ | Custom MDX Components |
| scripts | async function buildContent(dir: string, outDir: string) { | Performance at Scale |
| lib | const LOCALES = ['en', 'fr', 'de', 'ja'] as const | Internationalisation and MDX |
| app | export async function GET() { | Sitemap and RSS With MDX |
Key takeaways
output: 'export'. Choose your output strategy based on whether editors need previewsInterview Questions on This Topic
You need to build a Next.js site with 500 MDX blog posts, editors need draft preview, and the site must be as fast as possible. Design the architecture including content pipeline, build strategy, and draft mode setup.
content/posts/ directory with YAML frontmatter (title, publishedDate, author, tags, published: boolean). Each file is self-contained — no external CMS dependency. Frontmatter is validated by a Zod schema in a prebuild script.
Build pipeline: a prebuild script runs before next build. It reads all MDX files, validates frontmatter, and generates a typed content index (JSON file) with all post metadata. Optionally precompiles MDX to serialized HTML for posts that use custom React components (tiered processing). The content index feeds generateStaticParams for ISR pages.
Rendering: ISR with revalidate: 3600 for blog pages — fast responses from cache, background refresh when stale. The content index provides metadata for SEO, sitemap, and RSS generation. Each post page uses next-mdx-remote to render the compiled MDX (or imports precompiled HTML).
Draft mode: an API route GET /api/draft that verifies a shared secret, calls draftMode().enable(), and redirects to the post slug. Blog pages check draftMode().isEnabled to include unpublished posts. Cannot use output: 'export' — deployment uses ISR with a Node.js server (standalone output) or Vercel.
Performance choices: plain Markdown for posts without JSX (80% of content), MDX only for interactive posts. Content index eliminates filesystem reads during rendering. CDN caching for ISR pages. Build time target: under 2 minutes for 500 files.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's Next.js. Mark it forged?
7 min read · try the examples if you haven't