# MDX in the App Router without shipping a compiler

> next-mdx-remote compiles every post on every request. Compile at build time with @next/mdx instead, and keep frontmatter with gray-matter.

You add a blog to a Next.js App Router project. The first search result says to
use `next-mdx-remote`, so you do:

```tsx
// 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

```ts
// 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

```ts
// 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

```tsx
// 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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
