Home JavaScript MDX in Next.js 16 — Frontmatter Parsing Error Broke 200 Blog Pages at Build Time
Advanced 7 min · July 12, 2026
MDX, Content Management, and Draft Mode in Next.js

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.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
Before you start⏱ 20 min
  • Node.js 18+
  • Next.js 14+ project with App Router
  • Basic familiarity with Markdown and YAML
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • A missing space in YAML frontmatter (title:"Post" instead of title: "Post") caused 200 MDX pages to fail silently at build time — only caught when the build failed
  • @next/mdx compiles 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() from next/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
✦ Definition~90s read
What is MDX, Content Management, and Draft Mode in Next.js?

MDX in Next.js combines the simplicity of Markdown with the power of JSX, allowing content authors to write in Markdown while embedding React components — charts, interactive demos, custom callouts — directly in their content files. The @next/mdx package (Pages Router) and next-mdx-remote (App Router) compile these mixed-format files into React components at build time or request time.

Imagine building a house where the architect left a single measurement off one blueprint page.

The frontmatter — YAML metadata at the top of each MDX file — stores structured data about the content: title, publication date, author, tags, and whether the post is published. This metadata drives page listings, SEO tags, RSS feeds, and sitemaps. Frontmatter parsing is separate from MDX compilation and should use a library like gray-matter.

Draft mode is a Next.js feature that enables previewing unpublished content. When enabled via draftMode().enable(), it sets an HTTP-only cookie that forces the page to regenerate with draft data included. The critical constraint: draft mode requires ISR (Incremental Static Regeneration) and does NOT work with output: 'export' — if your deployment uses static export, editors cannot preview unpublished content.

The remark/rehype plugin ecosystem extends MDX's capabilities — plugins for syntax highlighting, heading anchors, table of contents generation, GitHub-flavoured Markdown, and custom transformations. remark plugins transform the Markdown AST before compilation; rehype plugins transform the HTML AST after compilation. Each plugin adds build time but enables powerful content transformations that would otherwise require complex manual markup.

Plain-English First

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.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

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.

next.config.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import type { NextConfig } from 'next'
import createMDX from '@next/mdx'

const nextConfig: NextConfig = {
  pageExtensions: ['ts', 'tsx', 'mdx'],
  experimental: {
    mdxRs: true, // Rust-based MDX compiler — faster
  },
}

const withMDX = createMDX({
  options: {
    remarkPlugins: [],
    rehypePlugins: [],
  },
})

export default withMDX(nextConfig)
Try it live
MDXRs vs JS MDX Compiler
Set 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.
Production Insight
A team migrated from Pages Router MDX (compiled to page components) to App Router MDX with mdxRs. Build time dropped from 45 seconds to 8 seconds for 500 MDX files. However, one rehype plugin (remark-gfm) had different output with mdxRs, causing subtle formatting differences in tables — caught by visual regression tests. Always run a full build comparison before switching compilers. Rule: mdxRs is faster but verify plugin compatibility in a staging build first.
Key Takeaway
@next/mdx with App Router requires mdxRs flag for optimal performance — the Rust compiler is 5-10x faster than JS MDX.
Separate frontmatter parsing from MDX compilation: gray-matter for YAML, then pure MDX transform.
Test plugin compatibility when enabling mdxRs — some remark/rehype plugins produce different output.
nextjs-mdx-content-draft-mode THECODEFORGE.IO MDX Content Pipeline Layers From flat files to rendered pages in Next.js 16 Source Layer MDX Files | Frontmatter YAML | Assets Folder Parsing Layer gray-matter | remark | rehype Validation Layer Zod Schema | CI Check | Draft Filter Build Layer @next/mdx | App Router | Static Generation Delivery Layer CDN Cache | ISR | Edge Rendering THECODEFORGE.IO
thecodeforge.io
Nextjs Mdx Content Draft Mode

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.

scripts/validate-frontmatter.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { readFileSync, readdirSync } from 'fs'
import { join } from 'path'
import matter from 'gray-matter'
import { z } from 'zod'

const frontmatterSchema = z.object({
  title: z.string().min(1, 'Title is required'),
  publishedDate: z.string().regex(
    /^\d{4}-\d{2}-\d{2}$/,
    'Date must be YYYY-MM-DD'
  ),
  author: z.string().min(1),
  description: z.string().max(160).optional(),
  tags: z.array(z.string()).optional(),
  published: z.boolean().default(true),
  canonical: z.string().url().optional(),
})

type Frontmatter = z.infer<typeof frontmatterSchema>

function validateAllPosts(dir: string) {
  const errors: Array<{ file: string; error: string }> = []
  const files = readdirSync(dir, { recursive: true })
    .filter(f => (f as string).endsWith('.mdx'))

  for (const file of files) {
    const content = readFileSync(join(dir, file as string), 'utf-8')
    const { data } = matter(content)

    const result = frontmatterSchema.safeParse(data)
    if (!result.success) {
      errors.push({
        file: file as string,
        error: result.error.issues
          .map(i => `${i.path.join('.')}: ${i.message}`)
          .join('; '),
      })
    }
  }

  if (errors.length > 0) {
    console.error('\n❌ Frontmatter validation failed:')
    for (const { file, error } of errors) {
      console.error(`  ${file}: ${error}`)
    }
    process.exit(1)
  }

  console.log(`✅ All ${files.length} files passed validation`)
}

validateAllPosts('content/posts')
Try it live
Don't Validate in the App Layer
Validating frontmatter during 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.
Production Insight
After the 200-posts incident, the team added Zod validation as a prebuild step. In the first week, it caught 7 files with invalid dates, 3 with missing titles, and 12 with incorrect tag formatting. All were fixed in one pass. Over the next 3 months, it caught 2-3 validation errors per week — all resolved before they could reach production. Rule: frontmatter validation in CI pays for itself in the first day. Never ship MDX content without schema validation.
Key Takeaway
Validate frontmatter as a prebuild step with Zod — crash CI immediately, not silently at build time.
The prebuild script aggregates all errors into a single report so developers fix everything in one pass.
Build a validated content index JSON file during prebuild — the app reads this index, never raw MDX files.

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.

app/api/draft/route.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'

// CMS calls this endpoint to enable preview
// GET /api/draft?secret=MY_SECRET&slug=my-post
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const secret = searchParams.get('secret')
  const slug = searchParams.get('slug')

  // Verify secret — should match env var
  if (secret !== process.env.DRAFT_SECRET) {
    return new Response('Invalid token', { status: 401 })
  }

  if (!slug) {
    return new Response('Missing slug', { status: 400 })
  }

  // Enable draft mode — sets HTTP-only cookie
  draftMode().enable()

  // Redirect to the preview page
  redirect(`/blog/${slug}`)
}

// --- app/blog/[slug]/page.tsx ---
// Page component that checks draft mode
import { draftMode } from 'next/headers'

export default async function BlogPost({ params }: {
  params: { slug: string }
}) {
  const { isEnabled } = draftMode()

  // When draft mode is enabled, skip the published filter
  // This shows unpublished posts to the previewing editor
  const post = await getPost(params.slug, {
    includeDrafts: isEnabled,
  })

  return <Article post={post} />
}
Try it live
Draft Mode + Static Export = Impossible
If your next.config.ts has 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.
Production Insight
A content team migrated from a traditional CMS to Next.js + MDX. They chose static export for CDN performance. Six months later, editors demanded draft preview — impossible with static export. The team had to rebuild deployment to use ISR with 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.
Key Takeaway
Draft mode works ONLY with on-demand ISR — it does not work with output: 'export' (static export).
The preview flow: CMS → API route (verify secret, draftMode().enable(), redirect) → page (check draftMode().isEnabled, include unpublished data).
If static export is mandatory, provide an alternative: separate preview environment or client-side draft fetching.
MDX Validation: Dev vs CI Trade-offs between speed and safety in Next.js 16 Dev Mode Only CI Validation Error Detection Silent failures Immediate feedback Build Speed Fast iteration Slower due to checks Production Safety Risk of broken pages Guaranteed valid output Implementation No extra code Zod schema + CI step Scalability Fails at 200+ posts Handles 1,000+ posts THECODEFORGE.IO
thecodeforge.io
Nextjs Mdx Content Draft Mode

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.

next.config.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import type { NextConfig } from 'next'
import createMDX from '@next/mdx'

// remark plugins (operate on Markdown AST)
import remarkGfm from 'remark-gfm'
import remarkFrontmatter from 'remark-frontmatter'
import remarkPrism from 'remark-prism'

// rehype plugins (operate on HTML AST)
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypePrism from 'rehype-prism-plus'

const nextConfig: NextConfig = {
  pageExtensions: ['ts', 'tsx', 'mdx'],
  experimental: { mdxRs: true },
}

const withMDX = createMDX({
  options: {
    remarkPlugins: [
      remarkFrontmatter,
      remarkGfm,
      remarkPrism,
    ],
    rehypePlugins: [
      rehypeSlug,          // Add IDs to headings
      [
        rehypeAutolinkHeadings,
        { behavior: 'wrap' }, // Wrap heading text in <a>
      ],
      rehypePrism,         // Syntax highlighting
    ],
  },
})

export default withMDX(nextConfig)
Try it live
Plugin Order Dependency
rehypeSlug must come before rehypeAutolinkHeadings — the first creates heading IDs, the second uses them for anchor links. Similarly, remarkGfm must run before remarkPrism because GFM transforms tables and task lists that Prism should not touch. Check each plugin's README for ordering requirements.
Production Insight
A team added 7 remark plugins for various features (emoji shortcodes, footnotes, containers, admonitions). Build time for 300 MDX files went from 30 seconds to 4 minutes. Removing 3 underused plugins (emoji shortcodes, custom containers) dropped build time to 90 seconds — a 62% reduction. The removed features were used in fewer than 5 posts total. Rule: each remark/rehype plugin adds build time. Audit your plugin list quarterly — remove any that don't serve a clear purpose for your content.
Key Takeaway
remark plugins transform Markdown AST (syntax highlighting, GFM); rehype plugins transform HTML AST (slug IDs, TOC).
Most production sites need only 2-3 plugins — each adds 10-50ms per file to build time.
Plugin order matters: rehypeSlug before rehypeAutolinkHeadings, remarkGfm before syntax highlighters.

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.

lib/content.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import { readFileSync, readdirSync } from 'fs'
import { join } from 'path'
import matter from 'gray-matter'
import { z } from 'zod'

const postSchema = z.object({
  slug: z.string(),
  title: z.string(),
  publishedDate: z.string(),
  author: z.string(),
  description: z.string().optional(),
  tags: z.array(z.string()).default([]),
  readingTime: z.number(),
  excerpt: z.string(),
})

export type Post = z.infer<typeof postSchema>

interface ContentIndex {
  posts: Post[]
  tags: string[]
  bySlug: Map<string, Post>
  byTag: Map<string, Post[]>
}

function buildIndex(contentDir: string): ContentIndex {
  const files = readdirSync(contentDir, { recursive: true })
    .filter(f => (f as string).endsWith('.mdx')) as string[]

  const posts: Post[] = files.map((file) => {
    const content = readFileSync(join(contentDir, file), 'utf-8')
    const { data, content: body } = matter(content)
    const slug = file.replace('.mdx', '').replace(/\\/g, '/')
    const words = body.split(/\s+/).length
    const readingTime = Math.ceil(words / 200)

    return postSchema.parse({
      slug,
      ...data,
      readingTime,
      excerpt: body.slice(0, 200).replace(/[#*`]/g, '').trim(),
    })
  })

  const bySlug = new Map(posts.map(p => [p.slug, p]))
  const byTag = new Map<string, Post[]>()

  for (const post of posts) {
    for (const tag of post.tags) {
      const existing = byTag.get(tag) ?? []
      existing.push(post)
      byTag.set(tag, existing)
    }
  }

  return {
    posts: posts.sort(
      (a, b) => b.publishedDate.localeCompare(a.publishedDate)
    ),
    tags: [...byTag.keys()].sort(),
    bySlug,
    byTag,
  }
}

export const contentIndex = buildIndex('content/posts')
Try it live
The Index is Your Single Source of Truth
The content index replaces filesystem reads during rendering. All filtered, sorted, and joined data comes from one JSON structure. This means: the rendering phase never throws from a bad MDX file (validation already happened), slug lookups are O(1) Map operations, and tag/category filtering is pre-computed.
Production Insight
An MDX site with 1,200 posts originally read frontmatter from every file during 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.
Key Takeaway
Build a typed content index at prebuild time — one JSON file replaces hundreds of filesystem reads during rendering.
The index provides O(1) slug lookups, pre-computed tag filtering, and sorted post lists.
Never parse MDX frontmatter during build-time page generation — the content index is the single source of truth.

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/mdx-components.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// Server Components for MDX rendering
// These render at build time — zero JS shipped

import type { MDXComponents } from 'mdx/types'
import Link from 'next/link'

// Custom Callout component — renders as styled div
// Usage in MDX: <Callout type="warning">Content</Callout>
function Callout({
  type = 'info',
  children,
}: {
  type?: 'info' | 'warning' | 'tip' | 'danger'
  children: React.ReactNode
}) {
  const colors = {
    info: 'border-blue-500 bg-blue-50',
    warning: 'border-yellow-500 bg-yellow-50',
    tip: 'border-green-500 bg-green-50',
    danger: 'border-red-500 bg-red-50',
  }

  return (
    <div className={`border-l-4 p-4 my-6 ${colors[type]}`}>
      {children}
    </div>
  )
}

// Code Group — tabs for multi-language code blocks
// Usage in MDX:
// <CodeGroup>
// ```ts title="server.ts"
// ...
// ```
// ```py title="client.py"
// ...
// ```
// </CodeGroup>
function CodeGroup({ children }: {
  children: React.ReactNode
}) {
  return (
    <div className="code-group border rounded-lg overflow-hidden">
      {children}
    </div>
  )
}

export function useMDXComponents(): MDXComponents {
  return {
    Callout,
    CodeGroup,
    a: (props) => <Link {...props as any} />,
    // Add custom components here
  }
}
Try it live
Client Components in MDX
If your MDX needs a client component (interactive chart, code playground), create it in a separate file with 'use client' and import it in the MDX file. You cannot use hooks inside MDX directly — the MDX content is a Server Component. Import client components as named imports.
Production Insight
A documentation site had 15 custom MDX components, 6 of which were 'use client' components for interactivity. The client components (interactive demos, playground) accounted for 80% of the JS shipped by documentation pages. Moving the interactive demos to lazy-loaded iframes and keeping the remaining MDX components as Server Components cut JS payload by 65%. Rule: most MDX components should be Server Components. Only make them client-side if they genuinely need browser APIs or React hooks.
Key Takeaway
MDX components are registered via the components prop on MDXRemote — keep the library under 10 components.
MDX components should be Server Components by default — only use 'use client' for genuine interactivity.
Build a small, reusable component library (callouts, code tabs, embedded media) that writers can use without touching code.

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.

scripts/build-content.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Prebuild script: compile MDX to serialized HTML
// Removes MDX compilation from the next build critical path

import { readFileSync, readdirSync, writeFileSync } from 'fs'
import { join } from 'path'
import { compile } from '@mdx-js/mdx'
import matter from 'gray-matter'

async function buildContent(dir: string, outDir: string) {
  const files = readdirSync(dir, { recursive: true })
    .filter(f => (f as string).endsWith('.mdx')) as string[]

  for (const file of files) {
    const raw = readFileSync(join(dir, file), 'utf-8')
    const { content, data } = matter(raw)

    // Compile MDX to JS string
    const compiled = await compile(content, {
      outputFormat: 'function-body',
      remarkPlugins: [],
      rehypePlugins: [],
    })

    // Write compiled output + frontmatter
    const outPath = join(outDir, file.replace('.mdx', '.json'))
    writeFileSync(outPath, JSON.stringify({
      frontmatter: data,
      compiled: String(compiled),
    }))
  }
}

buildContent('content/posts', '.content-cache')
Try it live
Tiered Content Processing
Not all content needs MDX. Tier 1: Plain Markdown (fastest, no JSX). Tier 2: MDX without custom components (mdxRs compiles, no React overhead). Tier 3: MDX with custom components (full pipeline, longest build). Default to Tier 1. Upgrade to Tier 2/3 only when specific posts need embedded React components. This reduces build time by 80% for content-heavy sites.
Production Insight
A knowledge base with 2,500 MDX files had build times exceeding 15 minutes. Analysis showed that only 120 files (4.8%) used custom React components. The remaining 2,380 files used only standard Markdown. Migrating the 2,380 files to plain .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.
Key Takeaway
MDX compilation scales linearly with file count — 1,000 files at 10-50ms each = 10-50 seconds of build time.
Use tiered content processing: plain Markdown for most posts, MDX only for posts that embed React components.
Precompile MDX in a prebuild step to remove compilation from the next build critical path.

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.

lib/i18n-content.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// Multi-locale content index
// Directory structure: content/{locale}/{slug}.mdx

import { readFileSync, readdirSync } from 'fs'
import { join } from 'path'
import matter from 'gray-matter'

const LOCALES = ['en', 'fr', 'de', 'ja'] as const
type Locale = typeof LOCALES[number]

interface LocalizedPost {
  slug: string
  locale: Locale
  title: string
  description: string
  publishedDate: string
}

function buildLocalizedIndex(baseDir: string) {
  const posts: LocalizedPost[] = []

  for (const locale of LOCALES) {
    const dir = join(baseDir, locale)
    if (!existsSync(dir)) continue

    const files = readdirSync(dir)
      .filter(f => f.endsWith('.mdx'))

    for (const file of files) {
      const content = readFileSync(join(dir, file), 'utf-8')
      const { data } = matter(content)
      const slug = file.replace('.mdx', '')

      posts.push({
        slug,
        locale,
        title: data.title,
        description: data.description ?? '',
        publishedDate: data.publishedDate,
      })
    }
  }

  return {
    posts,
    // Get all locales for a slug
    getLocalizations: (slug: string) =>
      posts.filter(p => p.slug === slug),
    // Get a specific locale + slug
    getLocalized: (slug: string, locale: Locale) =>
      posts.find(p => p.slug === slug && p.locale === locale),
  }
}
Try it live
Translation Drift
With separate files per locale, translations can drift — the English post may be updated but the French translation falls behind. Add a lastReviewed field to frontmatter and alert on files where the source locale's lastModified is more than 30 days newer than the translation's.
Production Insight
A multilingual site with 8 locales and 300 posts per locale (2,400 total MDX files) faced 25-minute build times. The fix: convert 85% of posts to plain Markdown (no JSX), reducing MDX compilation from 2,400 files to 360. Build time dropped to 6 minutes. The remaining 360 MDX files (English-only, with interactive demos) were the only ones needing the full MDX pipeline. Rule: at multilingual scale, tiered content processing isn't optional — it's survival. Default to plain Markdown for every locale except those that genuinely need JSX.
Key Takeaway
Multi-locale MDX multiplies compilation time — 500 posts × 5 locales = 2,500 compilations.
Use separate MDX files per locale in content/{locale}/slug.mdx structure for clean separation.
Tiered content (plain Markdown default, MDX only when needed) is critical at multilingual scale.

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.

app/sitemap.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { contentIndex } from '@/lib/content'

export async function GET() {
  const baseUrl = 'https://example.com'

  const posts = contentIndex.posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: post.publishedDate,
    changeFrequency: 'weekly' as const,
    priority: 0.8,
  }))

  const staticPages = [
    { url: baseUrl, lastModified: new Date(), changeFrequency: 'monthly' as const, priority: 1.0 },
    { url: `${baseUrl}/blog`, lastModified: new Date(), changeFrequency: 'weekly' as const, priority: 0.9 },
    { url: `${baseUrl}/about`, lastModified: new Date(), changeFrequency: 'monthly' as const, priority: 0.5 },
  ]

  const allPages = [...staticPages, ...posts]

  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  ${allPages.map((page) => `
    <url>
      <loc>${page.url}</loc>
      <lastmod>${page.lastModified}</lastmod>
      <changefreq>${page.changeFrequency}</changefreq>
      <priority>${page.priority}</priority>
    </url>`
  ).join('')}
</urlset>`

  return new Response(sitemap, {
    headers: {
      'Content-Type': 'application/xml',
    },
  })
}
Try it live
Image Sitemaps
For posts with featured images, add <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.
Production Insight
A site with 1,500 MDX posts generated an RSS feed that included full post content — each RSS entry was 5-10KB. The feed XML was 15MB and took 8 seconds to generate at build time. Switching to excerpts only (first 200 characters) reduced the feed to 500KB and generation to 400ms. Rule: RSS feeds with full content scale poorly. Use excerpts for the feed and link back to the full article.
Key Takeaway
Generate sitemaps and RSS from the validated content index at build time — never parse MDX files during sitemap generation.
Use excerpts (not full content) in RSS feeds for scalable, fast-feed generation.
Include image sitemaps for featured images to improve Google Images discoverability.
● Production incidentPOST-MORTEMseverity: high

200 Blog Pages Missing From Production — A Single YAML Parsing Error

Symptom
Production deploy completed successfully — no exit code errors. But all 200 blog posts returned 404. The homepage (statically generated) worked. CI logs showed 'Warning: Skipping file with invalid frontmatter: /posts/advanced-guide.mdx' but the warning was buried among 4,000 lines of build output. No one saw it.
Assumption
The team assumed that invalid frontmatter in a single MDX file would either: (a) cause a build error that fails CI, or (b) skip only the problematic file. They did not realise that @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.
Root cause
Two compounding issues. First, one MDX file had invalid frontmatter: 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.
Fix
Added Zod validation for frontmatter in a 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".
Key lesson
  • 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
Production debug guideDiagnose MDX compilation, frontmatter, and draft mode issues4 entries
Symptom · 01
Build succeeds but MDX pages are missing in production
Fix
Check build logs for 'Skipping file with invalid frontmatter' warnings. Run the frontmatter validation script separately against all MDX files. Verify that your content layer's filter function doesn't throw on unexpected data — wrap it in try/catch and log the specific file that causes the error
Symptom · 02
Draft mode returns empty or 404 pages
Fix
Verify you are using on-demand ISR (not static export). Draft mode requires 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 sent
Symptom · 03
remark/rehype plugin not transforming output
Fix
Check the plugin's return type — remark plugins must return a Transformer (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 nothing
Symptom · 04
Embedded React component in MDX throws 'not defined' at build time
Fix
Verify the component is exported from the mdxComponents 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 time
★ MDX Quick Debug ReferenceFast commands for diagnosing Next.js MDX and draft mode issues
Failed build with MDX compilation error
Immediate action
Find the specific file with the syntax error
Commands
npx 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>&1
Fix now
Run a dedicated frontmatter validator: node scripts/validate-frontmatter.js that parses every MDX file with gray-matter and reports failures with line numbers
Draft mode not showing unpublished posts+
Immediate action
Check if you're using static export
Commands
grep -rn "output:" next.config.ts next.config.js --include='*.{ts,js}'
curl -sI http://localhost:3000/api/draft?slug=test-post | grep 'set-cookie'
Fix now
If using output: 'export', draft mode does not work. Switch to on-demand ISR with output: 'standalone'. Ensure draftMode().enable() is called in an API route with a valid secret
remark plugin not transforming Markdown+
Immediate action
Check plugin registration order and return type
Commands
grep -rn 'remarkPlugins\|rehypePlugins' next.config.ts --include='*.ts'
node -e "const p = require('./plugins/custom-remark.js'); console.log(typeof p, typeof p().then)"
Fix now
remark plugins must export a function that returns a (tree, file) => void transformer. For async plugins, the transformer returns a Promise. Verify the plugin is in the remarkPlugins array (not rehypePlugins)
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
next.config.tsconst nextConfig: NextConfig = {@next/mdx Setup
scriptsvalidate-frontmatter.tsconst frontmatterSchema = z.object({Frontmatter Validation
appapidraftroute.tsexport async function GET(request: Request) {Draft Mode
libcontent.tsconst postSchema = z.object({Content Collections
componentsmdx-components.tsxfunction Callout({Custom MDX Components
scriptsbuild-content.tsasync function buildContent(dir: string, outDir: string) {Performance at Scale
libi18n-content.tsconst LOCALES = ['en', 'fr', 'de', 'ja'] as constInternationalisation and MDX
appsitemap.tsexport async function GET() {Sitemap and RSS With MDX

Key takeaways

1
Frontmatter validation with Zod as a prebuild step catches invalid YAML before it breaks production
crash CI, not the build
2
Draft mode works only with on-demand ISR
NOT with output: 'export'. Choose your output strategy based on whether editors need previews
3
remark plugins operate on Markdown AST, rehype plugins on HTML AST
most sites need only 2-3 plugins total
4
Build a validated content index at prebuild time
the app reads the index, not raw MDX files, eliminating filesystem I/O during rendering
5
Tiered content processing
default to plain Markdown, use MDX only for posts that embed React components — saves 80% build time
6
Custom MDX components should be Server Components by default
only use 'use client' for genuine browser interactivity
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
You need to build a Next.js site with 500 MDX blog posts, editors need d...
Q02SENIOR
A single YAML frontmatter error in one of 200 MDX files caused the entir...
Q03SENIOR
Explain the differences between remark and rehype plugins. When would yo...
Q04SENIOR
Your team wants editors to preview unpublished blog posts before schedul...
Q05SENIOR
How would you handle code syntax highlighting in MDX while minimising Ja...
Q01 of 05SENIOR

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.

ANSWER
The architecture has four layers: content storage, build pipeline, rendering, and preview. Content storage: MDX files in a 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.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What is the difference between @next/mdx and next-mdx-remote?
02
Can draft mode work with statically exported Next.js sites?
03
How do I handle syntax highlighting in MDX?
04
What happens if an MDX file imports a component that doesn't exist?
05
Should I version control compiled MDX or compile at build time?
06
How do I handle images in MDX content?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
🔥

That's Next.js. Mark it forged?

7 min read · try the examples if you haven't

Previous
Custom Cache Handlers: Redis, Memcached, and Distributed Caching
47 / 56 · Next.js
Next
next.config.ts Deep Dive: All Configuration Options Explained