All writing

Writing

How This Blog Renders MDX with @next/mdx and Turbopack

By Aggelos Konstantilieris·May 9, 2026·2 min read·Updated October 28, 2026

This blog is plain MDX files on disk, rendered by Next.js 15 with the App Router. There is no CMS and no database for content. The posts are .mdx files in content/blog, with a small data layer on top.

Wiring up @next/mdx

The setup starts in next.config.mjs. It registers the MDX file extensions and wraps the config with createMDX, passing the remark and rehype plugins:

import createMDX from "@next/mdx";
 
const withMDX = createMDX({
  options: {
    remarkPlugins: ["remark-gfm", "remark-frontmatter"],
    rehypePlugins: ["rehype-slug"],
  },
});

remark-gfm adds GitHub-flavored markdown: tables, task lists and autolinks. remark-frontmatter strips the YAML block at the top of each file, so it never shows on the page. rehype-slug adds id attributes to headings, and rehype-pretty-code handles syntax highlighting.

Turbopack needs plugin names as strings

This part cost me an afternoon. In development the project runs next dev --turbopack. Turbopack's engine is written in Rust, and it cannot pass JavaScript functions across the boundary between JavaScript and Rust. An imported plugin function works under webpack but fails under Turbopack.

So every plugin is passed as a string, or a [string, options] tuple, instead of an imported function:

  • Do this: remarkPlugins: ["remark-gfm"]
  • Not this: remarkPlugins: [remarkGfm]

Next.js resolves the string to the real module itself. A string can cross the boundary, so Turbopack accepts it. The same config works unchanged when next build runs for production.

Static pages from generateStaticParams

Every post is static. The dynamic route at app/blog/[slug]/page.tsx lists every slug at build time:

  1. generateStaticParams reads the slugs from disk and returns one { slug } per published post.
  2. Next.js renders each route to static HTML.
  3. The page imports the matching .mdx file and renders it as a React Server Component.
export async function generateStaticParams() {
  return getPostSlugs().map((slug) => ({ slug }));
}

Everything is known at build time. Nothing compiles MDX at runtime and nothing is fetched in the browser. Posts are read from the filesystem during the build and ship as static HTML. I still get components, syntax highlighting and inline code while writing.

The Next.js development page describes the kind of sites I build this way.