You add a blog to a Next.js App Router project. The first search result says to
use next-mdx-remote, so you do:
// app/blog/[slug]/page.tsx: the version that costs you money
import { MDXRemote } from "next-mdx-remote/rsc";
import fs from "node:fs/promises";
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const source = await fs.readFile(`content/blog/${slug}.mdx`, "utf8");
return <MDXRemote source={source} />;
}
It works. Then you look at the numbers:
- the server bundle for that route is a couple of megabytes, because the entire
MDX toolchain (
@mdx-js/mdx,unified,remark,rehype,acorn) is now part of it; - cold starts on that route are noticeably slower than the rest of the app;
- and the post is being compiled from markdown to a React tree on every request that misses the cache, for content that has not changed since you committed it.
None of that work is necessary. The posts are files in the repository. They are known at build time. The compiler belongs in the build, not in the response path.
Why the runtime approach is so common
next-mdx-remote exists for a genuine case: MDX that arrives from somewhere
you do not control at build time, a CMS field, a database row, user input. If
your source is a directory in your own repository, you are paying that cost for
nothing.
The build-time path has one wrinkle that pushes people towards the runtime one:
@next/mdx compiles .mdx files that the bundler can see, and it does not hand
you frontmatter. So "how do I list my posts with their titles and dates?" has no
obvious answer, and the runtime approach (read the file, parse the frontmatter,
compile the rest) looks like the only one that works.
The answer is to stop treating those as the same problem. Frontmatter is data
about the post; the body is a component. Read the first with gray-matter, let
the bundler compile the second.
Step 1: compile MDX in the build
// next.config.ts
import createMDX from "@next/mdx";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
pageExtensions: ["ts", "tsx", "md", "mdx"],
};
const withMDX = createMDX({
options: {
remarkPlugins: [["remark-frontmatter", { type: "yaml", marker: "-" }], "remark-gfm"],
rehypePlugins: ["rehype-slug"],
},
});
export default withMDX(nextConfig);
Two details that cost people an afternoon each:
Plugins are named as strings. Turbopack passes the plugin list to a Rust
process, and a JavaScript function cannot cross that boundary. remarkPlugins:
[remarkGfm] (the import) throws at config validation. ["remark-gfm"] (the
name) works. Options must be JSON-serialisable for the same reason.
remark-frontmatter is not optional. MDX has no concept of frontmatter. If
you do not add it, the --- block at the top of every post renders: a
horizontal rule, then a paragraph reading title: "..." description: "...". The
plugin turns that block into an AST node that produces no output.
Step 2: read frontmatter with the filesystem, not the compiler
// src/lib/blog/posts.ts
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
const POSTS_DIR = path.join(process.cwd(), "content", "blog");
export function allPosts() {
return fs
.readdirSync(POSTS_DIR)
.filter((file) => file.endsWith(".mdx"))
.map((file) => {
const slug = file.slice(0, -".mdx".length);
const { data, content } = matter(fs.readFileSync(path.join(POSTS_DIR, file), "utf8"));
return { slug, ...validate(slug, data), words: content.split(/\s+/).length };
})
.sort((a, b) => b.date.localeCompare(a.date));
}
This module is server-only: it imports node:fs. It runs during
generateStaticParams, inside generateMetadata and in server components, all
of which happen at build time. Nothing here reaches the browser.
Step 3: import the body
// src/app/blog/[slug]/page.tsx
export function generateStaticParams() {
return allPosts().map((post) => ({ slug: post.slug }));
}
export const dynamicParams = false;
export default async function Page(props: PageProps<"/blog/[slug]">) {
const { slug } = await props.params;
const { default: Body } = await import(`../../../../content/blog/${slug}.mdx`);
return <Body />;
}
The dynamic import with a template literal is the trick. Bundlers resolve it by
compiling every .mdx file in that directory at build time and generating a
lookup, so each post becomes an ordinary chunk. There is no compiler in the
output at all, only the React tree it produced.
dynamicParams = false completes the picture: a slug that was not returned by
generateStaticParams renders a 404 instead of trying to build a page on the
fly for a file that does not exist.
What you should see afterwards
Run a production build and read the route summary. The blog routes should be marked as static (prerendered as content), with a page count matching your post count. Then check the JavaScript for a post page: it should be the framework baseline plus whatever your own components need, not a hundred kilobytes more than your other pages.
If a post page still shows up as dynamic, something in its tree is reading
request data: cookies(), headers(), searchParams, or a fetch without
caching. Find it and move it, or accept that this one page is dynamic on purpose.
When you genuinely do need the runtime compiler
Keep next-mdx-remote for MDX you do not have at build time: a "long
description" field an editor types into a CMS, a template stored in Postgres,
documentation pulled from another repository at runtime. In that case, restrict
the components the MDX can reach to an explicit allowlist: MDX is code, and
compiling a string a user supplied is remote code execution with extra steps.
For a folder of files you wrote, committed and reviewed, the build already knows everything it needs. Let it do the work once.