Next.js
Building a Blog with MDX in Next.js App Router (Production Setup)
End-to-end setup for MDX content in the App Router: reading files, frontmatter, and components in content.

Running an MDX blog in the Next.js App Router means choosing where content lives (files or CMS), how you compile MDX, and how you inject custom components. This setup works in production.
Where content lives
For a file-based blog, put MDX (or Markdown) in a folder like content/blog/. Each file has frontmatter (title, date, excerpt, etc.) and body. At build time you read the directory, parse frontmatter, and either compile MDX to React or use a library like next-mdx-remote to compile on demand. For CMS-driven content, you fetch the MDX string (or AST) and compile it the same way; the rest of the pipeline is similar.
Reading and listing posts
Read the content directory (e.g. with fs.readdirSync or fs.promises.readdir) and filter by .mdx. Parse frontmatter with gray-matter or the same parser your MDX compiler uses. Sort by date and expose a function like getAllPosts() that returns slug, title, date, excerpt for listing pages, and getPostBySlug(slug) that returns full frontmatter and content for the post page.
// lib/blog.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
const postsDir = path.join(process.cwd(), "content/blog");
export function getAllPosts() {
const files = fs.readdirSync(postsDir);
const posts = files
.filter((f) => f.endsWith(".mdx"))
.map((filename) => {
const slug = filename.replace(/\.mdx$/, "");
const fullPath = path.join(postsDir, filename);
const { data } = matter(fs.readFileSync(fullPath, "utf8"));
return { slug, ...data };
});
return posts.sort((a, b) => new Date(b.date).valueOf() - new Date(a.date).valueOf());
}Compiling MDX in the App Router
With next-mdx-remote (or similar), use the RSC serialize in a server component. Pass the source string and optional component map so you can render custom components inside MDX. Use compileMDX or the library’s async API so you get a React element you can render.
// app/blog/[slug]/page.tsx
import { serialize } from "next-mdx-remote/serialize";
import { MDXRemote } from "next-mdx-remote/rsc";
import { getPostBySlug, getAllPosts } from "@/lib/blog";
import { notFound } from "next/navigation";
export async function generateStaticParams() {
return getAllPosts().map((p) => ({ slug: p.slug }));
}
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) notFound();
const mdx = await serialize(post.content, {
scope: {},
});
return (
<article>
<h1>{post.title}</h1>
<MDXRemote source={post.content} />
</article>
);
}Adjust to your package’s API (e.g. some use compileMDX and return a component). The idea: get the raw string, compile with frontmatter stripped, then render with optional components.
Custom components and production
Pass a components map so MDX can use your design system (e.g. custom CodeBlock, Callout). In production, ensure you only compile trusted content (your repo or CMS); avoid compiling user-supplied MDX without sanitization. Set up metadata (and canonical) from frontmatter in generateMetadata so each post has correct title and description. With that, you have a production-ready MDX blog in the App Router.
Summary
Store MDX in files or fetch from a CMS; parse frontmatter and list posts by slug. Compile MDX in server components with next-mdx-remote or equivalent, pass a component map for custom blocks, and wire metadata and canonicals from frontmatter. That’s a solid MDX blog setup for the App Router.