In short
Adding an MDX blog to a Next.js 16 site on Turbopack fails twice: the build rejects remark and rehype plugins passed as imported functions, and a root-level mdx-components.js silently loses every Tailwind class in production.
Declare plugins as string names with JSON-serialisable options, keep mdx-components.js inside src/ so Tailwind's content glob reaches it, and read frontmatter with gray-matter on a channel separate from the compiled body.
I moved this site to Next.js 16 earlier this year, then went to add a blog to it. A good chunk of that work went into two problems that produce no useful error message. Both are specific to Turbopack being the default bundler in 16.x, and neither is obvious from the MDX guide.
Here is what broke and what actually fixed it.
The problem#
The standard MDX setup looks like this in almost every tutorial written before 2025:
import remarkGfm from 'remark-gfm';
import rehypeSlug from 'rehype-slug';
const withMDX = createMDX({
options: { remarkPlugins: [remarkGfm], rehypePlugins: [rehypeSlug] },
});On Turbopack that build fails with a message about the MDX loader not having serializable options. Nothing in it points at the plugin array.
Why it happens#
Turbopack is written in Rust. It serialises its loader configuration before handing it across the JavaScript boundary, and an imported plugin is a function — there is no way to serialise one. Support for configuring plugins at all landed in Next 15.1, and it works by accepting the plugin's package name as a string and resolving it on the Rust side.
So the constraint is not "plugins don't work". It is that the plugin must be named rather than imported, and every option you pass alongside it has to survive JSON.stringify.
The fix#
const withMDX = createMDX({
extension: /\.mdx?$/,
options: {
remarkPlugins: ['remark-frontmatter', 'remark-gfm'],
rehypePlugins: [
'rehype-slug',
[
'rehype-autolink-headings',
{
behavior: 'append',
properties: { className: ['heading-anchor'] },
// A hast node literal, not a function - this is the part that
// usually trips people up, because the docs show a function here.
content: {
type: 'element',
tagName: 'span',
properties: {},
children: [{ type: 'text', value: '#' }],
},
},
],
['rehype-pretty-code', { theme: 'github-dark-default', keepBackground: false }],
],
},
});Two things worth calling out.
remark-frontmatter is not optional if your posts use YAML frontmatter. Without it, MDX reads the opening --- as a thematic break and renders your title: and description: keys as visible body text at the top of the post.
The content option for rehype-autolink-headings is normally written as a function returning a hast node. That is exactly the kind of value Turbopack cannot serialise, so pass the node literal directly instead.
The second problem: Tailwind purges your MDX styles#
This one is worse, because the build passes.
Next.js accepts mdx-components.js at the repository root or inside src/. Most examples put it at the root. My tailwind.config.js, like most src/-based projects, scans:
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}']A root-level mdx-components.js sits outside that glob. Tailwind never sees the file, so every class in the component map is purged from the production stylesheet. Development uses a different build path and looks perfect. Production ships an unstyled blog.
The fix is one segment of path: put the file at src/mdx-components.js.
Worth noting alongside it — the Next 16 signature changed, and useMDXComponents() now takes no arguments:
export function useMDXComponents() {
return components;
}How the content layer is wired#
One structural decision has paid off more than either fix: the body and the metadata are read through two independent channels.
The post body is compiled by @next/mdx through a dynamic import in the route. The frontmatter is read separately, straight off disk with gray-matter:
const readPost = (file) => {
const slug = file.replace(/\.mdx?$/, '');
const raw = fs.readFileSync(path.join(BLOG_DIR, file), 'utf8');
const { data, content } = matter(raw);
return { slug, ...data, ...readingStats(content) };
};The reason is blast radius. sitemap.xml and llms.txt are built from that second channel, so they have no dependency on the MDX toolchain at all. An MDX compilation problem can break a single page without taking the machine-readable files down with it.
It removes a class of bug outright, too: the filename is the slug. There is no slug: frontmatter field, because a frontmatter slug that disagrees with the filename breaks the dynamic import in a way that is genuinely unpleasant to trace.
What this demonstrates#
Both failures share a shape worth recognising. The first is loud and points nowhere useful. The second is completely silent and only appears in production. Neither is a bug in Next.js or in Tailwind — they are two tools doing exactly what they are designed to do, at a boundary where that behaviour is surprising.
When a build tool's error message doesn't name the thing that is actually wrong, the fastest way through is usually to stop reading the error and start asking what boundary the value had to cross.
Reading list#
- nextjs
- mdx
- turbopack
- tailwind
- seo
Common questions
Why does Turbopack reject remark and rehype plugins in next.config.mjs?
Turbopack is written in Rust and serialises its loader configuration before passing it across the JavaScript boundary. A plugin imported as a function cannot be serialised, so the build fails with a message about the MDX loader not having serializable options. Declare the plugin by its string name instead, and keep every option JSON-serialisable.
Where should mdx-components.js live in a Next.js project using Tailwind?
Inside src/, not the repository root, whenever tailwind.config.js scans ./src/**/*. Next.js accepts both locations, but a root-level file falls outside Tailwind's content glob, so every class named in the component map is purged from the production stylesheet. The blog then looks correct in development and unstyled in production.
Should blog frontmatter be read by the MDX pipeline or separately?
Separately. Compiling the body through MDX while reading frontmatter with gray-matter off disk keeps sitemap.xml and llms.txt independent of the MDX toolchain, so a compilation problem can break one page without taking the machine-readable files down with it.
Share this
Instagram has no web share link, so this gives you both pieces: copy the caption, save the card, then post it.
Got this problem too?
Bespoke React and Next.js platforms, SaaS interfaces, client portals and interactive tools built for speed.