On this page

Chapter 3 of 8. Prerequisite: Chapter 2, the next.config.ts for static export. You should already have output: 'export', trailingSlash: true, and images.unoptimized: true in place. This chapter adds the MDX layer on top of that config, and it has to survive the export contract you just set.

Most MDX tutorials render one file, then a folder, and stop. They never mention static export because they assume Vercel is doing the serving. This is the chapter that shows you how to deploy a Next.js MDX blog to cPanel with no Vercel anywhere in the pipeline: everything the MDX layer needs must happen at next build time, because there is no server at request time to do it.

Every value below is quoted from captainrandom.co.uk itself. The site you are reading this on is the example project, and it is in production on cPanel.

What MDX actually is (and why it survives export)

MDX is a format that combines markdown with JSX. You write markdown, and where you want a React component you drop it inline. The compiler turns the whole file into JavaScript, and it compiles plain markdown to JavaScript too.

That last fact is the one that matters for a static host. MDX compiles at build time. There is no runtime markdown parser sitting on the server. When you run next build with output: 'export', each MDX file is compiled to a static HTML page and written into out/, and that folder can be deployed to any web server that serves static assets. cPanel serving flat HTML is precisely that server.

So the rule for this whole chapter is simple. If a step needs a Node process at the moment a visitor asks for the page, it is forbidden. If it needs a Node process at the moment you run next build, it is fine.

Install the packages

Captain Random pins these versions. Install the same set:

npm install @next/mdx@^15.3.0 @mdx-js/loader@^3.1.0 @mdx-js/react@^3.1.0 @types/mdx@^2.0.13
npm install remark-gfm@^4.0.1 rehype-highlight@^7.0.2 rehype-slug@^6.0.0
npm install gray-matter@^4.0.3

@next/mdx is the Next.js integration. It drives two underlying MDX packages, @mdx-js/loader and @mdx-js/react, while @types/mdx gives you the TypeScript types. The three remark-* / rehype-* packages are the plugins that turn raw markdown into the finished HTML. gray-matter is the frontmatter parser, and it earns its place for a specific reason that the last section explains.

Wire the plugins into next.config.ts

This is the part no cPanel tutorial covers and every content site needs. You wrap your existing static-export config with a createMDX call. Here is the real next.config.ts from this site:

import type { NextConfig } from 'next'
import createMDX from '@next/mdx'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import rehypeSlug from 'rehype-slug'

const nextConfig: NextConfig = {
  output: 'export',
  pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
  images: {
    unoptimized: true,
  },
  trailingSlash: true,
}

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

export default withMDX(nextConfig)

Three things here are load-bearing.

First, pageExtensions includes md and mdx. Without this, Next.js treats .mdx files as inert and will not render them as routes or resolve them as imports. Adding the two extensions is what makes an MDX file behave like a page.

Second, the config file is .ts, not .js. The remark and rehype ecosystem is ESM-only. Per the Next.js MDX guide, that forces you to use next.config.mjs or next.config.ts so the import statements at the top resolve. A CommonJS next.config.js with require() will fail the moment you add a plugin.

Third, the plugins split into two arrays, and order matters inside each. MDX runs two distinct plugin passes. Remark plugins operate on the markdown syntax tree, and rehype plugins operate on the HTML syntax tree that markdown becomes. remark-gfm gives you GitHub-flavoured markdown (tables, strikethrough, task lists, autolinks). Then, on the HTML side, rehype-highlight adds syntax-highlighting classes to code blocks, and rehype-slug adds id attributes to every heading.

rehype-highlight runs before rehype-slug here on purpose. Highlighting rewrites the internals of <pre><code> blocks, while slugging only touches heading elements. They do not fight, but the ordering is deliberate and the site ships it in this sequence.

The required mdx-components.tsx (and where it lives)

Under the App Router, @next/mdx will not work without a file called mdx-components.tsx at the root of your project. Not inside app/. Not inside src/. It goes in the repository root, next to next.config.ts. The docs are blunt about this: it "will not work without it."

The file exports one function, useMDXComponents, that maps HTML element names to your own React components. This is where a plain markdown heading becomes a styled Captain Random heading. Here is the shape the site uses:

import type { MDXComponents } from 'mdx/types'

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    h1: ({ children }) => (
      <h1 className="display" style={{ fontSize: 'clamp(40px,6vw,80px)', marginBottom: '24px' }}>
        {children}
      </h1>
    ),
    p: ({ children }) => (
      <p style={{ lineHeight: 1.7, marginBottom: '18px', maxWidth: '68ch' }}>
        {children}
      </p>
    ),
    pre: ({ children }) => (
      <pre className="term" style={{ marginBottom: '24px', marginTop: '24px' }}>
        <div className="term-bar">
          <div className="tl"><span /><span /><span /></div>
          <span className="title">code</span>
        </div>
        <div className="term-body">{children}</div>
      </pre>
    ),
    ...components,
  }
}

The full map on the real site also styles h2, h3, and code, but the three above show the important patterns. Two are worth calling out.

The pre override is doing real work. Every fenced code block in an MDX file lands inside that <pre>, and this override wraps it in a fake macOS terminal window: the three traffic-light dots (.tl), a title bar, and a body. The .term, .term-bar, .tl, and .term-body classes live in the global stylesheet. That is why every code block you have seen in this course sits inside a little terminal frame. It is one override, applied globally, at build time.

The ...components spread at the end matters too. It merges anything the caller passes in on top of your defaults, so a single MDX file can override one element without losing the rest of the map.

Read the frontmatter with gray-matter at build time

MDX renders the body of a post. It does nothing with the frontmatter block at the top: the title, date, excerpt, and tags you need to build an index page, sort posts, and generate meta tags. For that you read the files yourself. This is where the static-export contract turns into a hard constraint that no amount of styling preference can wave away.

fs is allowed only at build time under static export. There is no server at request time, so any fs.readFileSync has to run inside next build and be baked into the output. The Next.js docs say the same thing plainly: reading metadata off disk "can only be used server-side," which under output: 'export' means at build. This site's loader lives in src/lib/mdx.ts and does precisely that:

import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import type { Post } from '@/types'

const POSTS_DIR = path.join(process.cwd(), 'src/content/posts')

export function getPostBySlug(slug: string): Post {
  const fullPath = path.join(POSTS_DIR, `${slug}.mdx`)
  const fileContents = fs.readFileSync(fullPath, 'utf8')
  const { data, content } = matter(fileContents)

  return {
    slug,
    title: data.title,
    date: normaliseDate(data.date),
    excerpt: data.excerpt ?? '',
    tags: data.tags ?? [],
    featured: data.featured ?? false,
    hidden: data.hidden ?? false,
    readingTime: estimateReadingTime(content),
    content,
  }
}

matter(fileContents) splits the file into data (the parsed frontmatter object) and content (the raw MDX body). Two gotchas surface once you run this in production.

  • Unquoted YAML dates come back as JavaScript Date objects. Write date: 2026-05-17 with no quotes and gray-matter hands you a Date instead of a string. The loader normalises it to an ISO date string with rawDate.toISOString().slice(0, 10) so nothing downstream has to guess the type.
  • Reading time is derived here, not stored. estimateReadingTime is Math.max(1, Math.round(words / 200)) + ' min read', a flat 200 words per minute floored at one minute. It runs at build, so it never costs a visitor anything.

The full loader does more besides. It globs the posts directory (fs.readdirSync, filtering for .mdx), sorts newest-first, filters out hidden: true posts, and resolves a convention-based hero image by checking fs.existsSync on public/images/writing/<slug>/hero-1.png. Every one of those calls is an fs call, and every one runs at build. That is the whole discipline: file I/O in the loader, never in a component that ships to the browser.

This pipeline grows three layers of complexity as it scales: routing, authoring, and citation. That growth is the subject of the lived-experience article An MDX content pipeline grows three kinds of complexity. The tag-slug bug it opens with is the kind of thing that hides until you have a few posts and a static export that only knows the URLs you generated at build.

Verify it before you deploy

Run the build locally and confirm the MDX actually rendered into static HTML:

npm run build

Acceptance thresholds, in order:

  1. The build completes and writes an out/ directory. If it errors on an import in next.config, your config is .js instead of .ts or .mjs.
  2. out/ contains an HTML file for each post route, so check that you get real HTML and not a JavaScript stub. Open one in a browser directly from disk.
  3. Every heading in that HTML has an id attribute (rehype-slug worked) and code blocks carry hljs highlight classes (rehype-highlight worked).
  4. Each code block is wrapped in the .term frame (your mdx-components.tsx pre override worked).

If all four hold, the MDX layer survives export and the out/ folder is ready to drop into public_html/. The next chapter tackles the trap that catches nearly everyone shipping a content site this way: why next/image breaks under static export, and the one-line config that fixes it.

// EXERCISE

Extend the loader with tag pages built at build time

Using the gray-matter loader pattern on your own MDX site, add a tag system the chapter never builds: a getAllTags and getPostsByTag in the loader, plus a dynamic /tags/[tag] route whose generateStaticParams enumerates every tag so the export knows every URL. All file reading stays inside next build.

Expected behaviour
  • The loader reads tags from frontmatter with fs at build time only, and no client component imports fs
  • generateStaticParams enumerates every tag, so out/ contains a tags/<tag>/index.html directory per tag after the build
  • Unquoted YAML dates in your frontmatter arrive in components as ISO strings, never as raw Date objects
  • Opening an exported tag page straight from disk shows the correct post list as real HTML rather than a JavaScript stub

PROVE IT Add a brand-new tag to one post, rebuild, and show the new out/tags/<tag>/index.html appearing with that post listed, with no code change beyond the frontmatter edit.

// CHECKPOINT — MDX PIPELINE
multiple choice · auto-checked

Where must the mdx-components.tsx file live for @next/mdx to work under the App Router?

exact answer · auto-checked

In this site's rehypePlugins array, which plugin runs first?

open · self-checked

@next/mdx already compiles your MDX files, so why does the pipeline need gray-matter at all?

Show answer

@next/mdx renders the body of a post and does not support frontmatter by default, since frontmatter is a convention layered on top of markdown. Index pages, sorting, and meta tags all need the title, date, excerpt, and tags, so the loader reads the files itself and gray-matter splits each one into parsed frontmatter data and the raw body. That read happens with fs at build time, the only moment file I/O is allowed under static export.

↺ re-read: “Read the frontmatter with `gray-matter` at build time

Lived experience

Sources

  • How to use markdown and MDX in Next.js (Guides: MDX)
    Next.js (Vercel) — official documentation
    Canonical @next/mdx wiring: the required packages, the mandatory root mdx-components file, ESM-only next.config for remark/rehype plugins, and the frontmatter caveat that justifies gray-matter.
    nextjs.org
  • How to create a static export of your Next.js application (Guides: Static Exports)
    Next.js (Vercel) — official documentation
    The static-export contract this pipeline must live inside: output:'export', the out/ folder deployable to any static host, and the fact that fs-based data loading only runs at build time.
    nextjs.org
  • Extending MDX
    MDX (mdxjs.com — official MDX project documentation)
    The remark/rehype plugin model: separate mdast and hast plugin arrays, [plugin, options] tuples, and multiple same-type plugins in one compile — backs the plugin ordering in next.config.
    mdxjs.com
  • What is MDX?
    MDX (mdxjs.com — official MDX project documentation)
    Authoritative definition of MDX as markdown-plus-JSX compiled to JavaScript — backs the opening explanation of why a static site can use MDX in a build-time pipeline.
    mdxjs.com
Back to guide overview