
How to Build an MDX-Powered Blog with the Next.js App Router?
A blog is one of the best fits for Next.js. Posts change rarely, they are read far more often than they are written, and they benefit from being prerendered as static HTML. Markdown is the natural format for writing them, and MDX takes Markdown one step further by letting you drop React components, such as callouts, tabs, embedded videos, or interactive demos, directly into a post. The catch is that there are several ways to wire MDX into the App Router, and the tutorials you find often mix Pages Router patterns, outdated packages, and synchronous params that no longer work in Next.js 15 and 16.
This article walks through building a complete MDX blog with the App Router from scratch: choosing between @next/mdx and next-mdx-remote, structuring a content folder with frontmatter, writing a typed content loader, generating static routes with generateStaticParams, rendering MDX with custom components, adding syntax highlighting and heading anchors, generating per-post metadata, and keeping the whole thing fast. The approach is the same one that powers the site you are reading right now.
Choosing an MDX Approach
There are two mainstream ways to use MDX in the App Router, and they solve slightly different problems.
| Approach | How it works | Best for |
|---|---|---|
@next/mdx | MDX files are compiled by the bundler and imported like components | Docs and a handful of pages that live inside app/ |
next-mdx-remote/rsc | MDX source is read as a string and compiled inside a Server Component | Blogs with many posts in a content folder, frontmatter, CMS sources |
@next/mdx is the official plugin. It is excellent when an .mdx file is itself a page, for example app/about/page.mdx. It does not parse frontmatter on its own, and since every post becomes a module in the bundle, building listing pages means maintaining separate metadata exports.
next-mdx-remote treats MDX as data. You read a file with Node's fs, split the frontmatter with gray-matter, and hand the body string to the MDXRemote component, which compiles and renders it on the server. Because it never touches the client bundle, it scales cleanly to hundreds of posts and works the same whether the content comes from a folder, a Git repository, or a headless CMS. For a blog, this is the approach to choose, and it is what the rest of this article uses.
If you are deciding where your content should live at all, the trade-offs between files and a CMS are covered in how to use Next.js with a headless CMS.
Setting Up the Project
Start with a fresh App Router project, or add the packages to an existing one:
# Terminal
npx create-next-app@latest my-blog --typescript --tailwind --app --src-dir
cd my-blog
npm install next-mdx-remote gray-matter remark-gfm rehype-slug rehype-autolink-headings rehype-pretty-code shiki reading-time
npm install -D @tailwindcss/typography
What each package does:
next-mdx-remotecompiles and renders MDX inside Server Components through itsnext-mdx-remote/rscentry point.gray-mattersplits the YAML frontmatter from the Markdown body.remark-gfmadds GitHub Flavored Markdown: tables, task lists, strikethrough, and autolinks.rehype-slugandrehype-autolink-headingsgive every heading anidand a clickable anchor.rehype-pretty-codewithshikiproduces syntax-highlighted code blocks at build time, so no highlighting library ships to the browser.reading-timeestimates minutes to read from the post body.@tailwindcss/typographyprovides theproseclasses that style rendered Markdown.
Project Structure
Keep content outside app/. Routes live in app/, content lives in content/, and the code that connects them lives in lib/:
src/
├── app/
│ ├── blog/
│ │ ├── page.tsx # post index
│ │ └── [slug]/
│ │ └── page.tsx # single post
│ └── layout.tsx
├── components/
│ └── mdx/
│ ├── Callout.tsx
│ └── index.tsx # component map for MDX
├── content/
│ └── posts/
│ ├── hello-world.mdx
│ └── second-post.mdx
└── lib/
└── posts.ts # read, parse, sort posts
Writing a Post with Frontmatter
Each post is an .mdx file with YAML frontmatter at the top. Keep the fields you need for listing, sorting, and metadata:
---
title: "Hello, World"
description: "The first post on the new MDX blog."
date: "2026-10-01"
tags: ["nextjs", "mdx"]
image: "/images/blog/hello-world.png"
draft: false
---
Welcome to the blog. This paragraph is plain **Markdown**.
<Callout type="tip">
This box is a React component rendered from inside the post.
</Callout>
## A Code Sample
```ts
export function greet(name: string) {
return `Hello, ${name}`;
}
```
| Feature | Supported |
| ------- | --------- |
| Tables | Yes |
The Callout tag in the body is not HTML. It maps to a React component you register later, which is the whole point of MDX.
Loading Posts from the File System
Create a single module that knows how to find, parse, validate, and sort posts. Every route imports from here, so the file system details live in one place:
// src/lib/posts.ts
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
import readingTime from "reading-time";
const POSTS_DIR = path.join(process.cwd(), "src/content/posts");
export type PostFrontmatter = {
title: string;
description: string;
date: string;
tags?: string[];
image?: string;
draft?: boolean;
};
export type Post = {
slug: string;
frontmatter: PostFrontmatter;
content: string;
readingMinutes: number;
};
function readPostFile(fileName: string): Post {
const slug = fileName.replace(/\.mdx?$/, "");
const raw = fs.readFileSync(path.join(POSTS_DIR, fileName), "utf-8");
const { data, content } = matter(raw);
if (!data.title || !data.date) {
throw new Error(`Post "${fileName}" is missing a title or date`);
}
return {
slug,
frontmatter: data as PostFrontmatter,
content,
readingMinutes: Math.ceil(readingTime(content).minutes),
};
}
export function getAllPosts(): Post[] {
return fs
.readdirSync(POSTS_DIR)
.filter((file) => /\.mdx?$/.test(file) && !file.startsWith("_"))
.map(readPostFile)
.filter(
(post) =>
process.env.NODE_ENV === "development" || !post.frontmatter.draft,
)
.filter((post) => new Date(post.frontmatter.date) <= new Date())
.sort(
(a, b) =>
new Date(b.frontmatter.date).getTime() -
new Date(a.frontmatter.date).getTime(),
);
}
export function getPostBySlug(slug: string): Post | undefined {
return getAllPosts().find((post) => post.slug === slug);
}
A few design decisions worth noting:
- Validation at read time. Throwing when a required field is missing turns a typo in frontmatter into a failed build instead of a broken page in production.
- Drafts visible in development. You can preview a draft locally with
npm run dev, but it never reaches the production build. - Future dates are excluded. Set a post's date to next Tuesday and it publishes itself on the next build after that date. This is the same rule this site's own content parser applies.
- Files starting with an underscore are skipped, which is a handy convention for templates and notes.
This module uses node:fs, so it can only be imported from Server Components, Route Handlers, and other server code. That is fine, because in the App Router pages are Server Components by default. If you want a hard guarantee, add import "server-only"; at the top of the file so an accidental import into a Client Component fails the build.
Building the Post Index Page
The index page lists posts newest first. It runs on the server at build time, so the file system reads cost nothing at request time:
// src/app/blog/page.tsx
import type { Metadata } from "next";
import Link from "next/link";
import { getAllPosts } from "@/lib/posts";
export const metadata: Metadata = {
title: "Blog",
description: "Articles on Next.js, React, and modern web development.",
};
export default function BlogIndexPage() {
const posts = getAllPosts();
return (
<main className="mx-auto max-w-3xl px-4 py-16">
<h1 className="mb-10 text-4xl font-bold">Blog</h1>
<ul className="space-y-10">
{posts.map(({ slug, frontmatter, readingMinutes }) => (
<li key={slug}>
<Link href={`/blog/${slug}`} className="group block">
<h2 className="text-2xl font-semibold group-hover:underline">
{frontmatter.title}
</h2>
<p className="mt-1 text-sm text-gray-500">
<time dateTime={frontmatter.date}>
{new Date(frontmatter.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
{" · "}
{readingMinutes} min read
</p>
<p className="mt-3 text-gray-700">{frontmatter.description}</p>
</Link>
</li>
))}
</ul>
</main>
);
}
Once you have more than a dozen or so posts, add pagination. A common pattern is a app/blog/page/[page]/page.tsx route that slices the array returned by getAllPosts() and uses generateStaticParams to prerender each page number.
Registering Custom MDX Components
Create the components you want available inside posts, then export a single map. Anything in this map can be used in MDX by name, and it also lets you override how standard Markdown elements render:
// src/components/mdx/Callout.tsx
type CalloutProps = {
type?: "note" | "tip" | "warning";
children: React.ReactNode;
};
const styles = {
note: "border-blue-500 bg-blue-50",
tip: "border-green-500 bg-green-50",
warning: "border-amber-500 bg-amber-50",
};
export function Callout({ type = "note", children }: CalloutProps) {
return (
<aside
className={`my-6 rounded-r-md border-l-4 p-4 not-prose ${styles[type]}`}
>
{children}
</aside>
);
}
// src/components/mdx/index.tsx
import type { MDXComponents } from "mdx/types";
import Image, { type ImageProps } from "next/image";
import Link from "next/link";
import { Callout } from "./Callout";
export const mdxComponents: MDXComponents = {
Callout,
a: ({ href = "", children, ...props }) => {
if (href.startsWith("/") || href.startsWith("#")) {
return (
<Link href={href} {...props}>
{children}
</Link>
);
}
return (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
);
},
Image: (props: ImageProps) => (
<Image
sizes="(min-width: 768px) 720px, 100vw"
className="rounded-lg"
{...props}
/>
),
};
The a override routes internal links through next/link, so they get client-side navigation and prefetching, while external links open in a new tab. The mdx/types import comes from the @types/mdx package, which you can install as a dev dependency if TypeScript cannot find it.
Interactive components work too. A component that uses state, such as a tab group or a copy-to-clipboard button, just needs "use client" at the top of its own file. MDX content is still rendered on the server; only that component hydrates in the browser.
Rendering a Single Post
The single post route ties everything together. It prerenders every post at build time, returns a 404 for unknown slugs, generates metadata from frontmatter, and renders the MDX body with plugins and components:
// src/app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypePrettyCode from "rehype-pretty-code";
import { mdxComponents } from "@/components/mdx";
import { getAllPosts, getPostBySlug } from "@/lib/posts";
type Props = {
params: Promise<{ slug: string }>;
};
export const dynamicParams = false;
export function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) return {};
const { title, description, image, date } = post.frontmatter;
return {
title,
description,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
type: "article",
title,
description,
publishedTime: date,
images: image ? [{ url: image, width: 1200, height: 630 }] : undefined,
},
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) notFound();
const { frontmatter, content, readingMinutes } = post;
return (
<article className="prose prose-lg mx-auto max-w-3xl px-4 py-16 dark:prose-invert">
<header className="not-prose mb-10">
<h1 className="text-4xl font-bold">{frontmatter.title}</h1>
<p className="mt-2 text-sm text-gray-500">
<time dateTime={frontmatter.date}>{frontmatter.date}</time> ·{" "}
{readingMinutes} min read
</p>
</header>
<MDXRemote
source={content}
components={mdxComponents}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: "wrap" }],
[
rehypePrettyCode,
{ theme: "github-dark-dimmed", keepBackground: true },
],
],
},
}}
/>
</article>
);
}
Important details in this file:
paramsis a Promise. Since Next.js 15,paramsandsearchParamsare asynchronous, and Next.js 16 removed the temporary synchronous fallback. You mustawait paramsin both the page andgenerateMetadata. Older tutorials that destructureparams.slugdirectly will fail.dynamicParams = falsemakes any slug not returned bygenerateStaticParamsa 404 instead of an on-demand render. For a file-based blog that is exactly what you want.- Plugins run at build time. Because the page is prerendered,
rehype-pretty-codeand Shiki run once per post duringnext build. The visitor receives finished HTML with inline color styles, and zero highlighting JavaScript. MDXRemotefromnext-mdx-remote/rscis an async Server Component. Do not import it fromnext-mdx-remote(without/rsc), which is the older client-side API built aroundserializeandgetStaticProps.
For background on why prerendering every post is the right default, see what is static site generation in Next.js.
A Note on JavaScript Expressions in MDX
next-mdx-remote version 6 blocks JavaScript expressions in MDX by default through a blockJS option that defaults to true. String props such as type="tip" work, but expressions in curly braces, such as an array passed as a prop or an inline calculation, are stripped out. This protects you if MDX ever comes from an untrusted source.
If your posts are written by you and live in your repository, you can enable expressions in the options object:
// src/app/blog/[slug]/page.tsx (options excerpt)
options={{
blockJS: false, // allow expressions in trusted, first-party content
mdxOptions: {
remarkPlugins: [remarkGfm],
},
}}
Leave the companion blockDangerousJS option at its default of true, which keeps a best-effort block on things like eval, Function, and process. If you are on an older 4.x or 5.x release, expressions are allowed by default and these options do not exist.
Reading Frontmatter with compileMDX
Instead of gray-matter, next-mdx-remote/rsc can parse frontmatter itself through compileMDX, which returns both the rendered content and the typed frontmatter:
// src/app/blog/[slug]/page.tsx (alternative)
import { compileMDX } from "next-mdx-remote/rsc";
import type { PostFrontmatter } from "@/lib/posts";
const { content, frontmatter } = await compileMDX<PostFrontmatter>({
source: rawFileContents,
components: mdxComponents,
options: { parseFrontmatter: true },
});
This is convenient for a single page. For a blog, gray-matter in the loader is still the better split, because the index page needs every post's frontmatter without compiling every post's body.
Styling Rendered Markdown
The prose classes from @tailwindcss/typography give headings, lists, tables, blockquotes, and code sensible defaults. In Tailwind CSS v4 you register the plugin in CSS rather than a config file:
/* src/app/globals.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/* rehype-pretty-code output */
[data-rehype-pretty-code-figure] pre {
@apply overflow-x-auto rounded-lg py-4 text-sm;
}
[data-rehype-pretty-code-figure] code [data-line] {
@apply px-4;
}
[data-rehype-pretty-code-figure] [data-highlighted-line] {
@apply bg-white/10;
}
Use not-prose on elements that should opt out, like the post header and the callout component above. A deeper guide to customizing the plugin is in how to use the Tailwind Typography plugin for Markdown content.
Adding Tags, Related Posts, and an RSS Feed
Because posts are just an array of objects, most blog features are a few lines of array logic.
Tag pages:
// src/app/blog/tags/[tag]/page.tsx
import Link from "next/link";
import { getAllPosts } from "@/lib/posts";
type Props = { params: Promise<{ tag: string }> };
export const dynamicParams = false;
export function generateStaticParams() {
const tags = new Set(
getAllPosts().flatMap((post) => post.frontmatter.tags ?? []),
);
return [...tags].map((tag) => ({ tag }));
}
export default async function TagPage({ params }: Props) {
const { tag } = await params;
const posts = getAllPosts().filter((post) =>
post.frontmatter.tags?.includes(tag),
);
return (
<main className="mx-auto max-w-3xl px-4 py-16">
<h1 className="mb-8 text-3xl font-bold">Posts tagged “{tag}”</h1>
<ul className="space-y-4">
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`} className="underline">
{post.frontmatter.title}
</Link>
</li>
))}
</ul>
</main>
);
}
An RSS feed as a Route Handler. It is prerendered at build time because it uses no request data:
// src/app/feed.xml/route.ts
import { getAllPosts } from "@/lib/posts";
const SITE_URL = "https://example.com";
function escapeXml(value: string) {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
export function GET() {
const items = getAllPosts()
.slice(0, 20)
.map(
({ slug, frontmatter }) => `
<item>
<title>${escapeXml(frontmatter.title)}</title>
<link>${SITE_URL}/blog/${slug}</link>
<guid>${SITE_URL}/blog/${slug}</guid>
<pubDate>${new Date(frontmatter.date).toUTCString()}</pubDate>
<description>${escapeXml(frontmatter.description)}</description>
</item>`,
)
.join("");
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>My Blog</title>
<link>${SITE_URL}</link>
<description>Latest posts</description>${items}
</channel>
</rss>`;
return new Response(xml, {
headers: { "Content-Type": "application/xml; charset=utf-8" },
});
}
For search engines, add a sitemap with the app/sitemap.ts file convention, returning one entry per post from getAllPosts(). The full approach is in how to add a sitemap to a Next.js website.
Keeping the Blog Fast
An MDX blog built this way is fast by default, but a few habits keep it that way as it grows:
- Keep heavy work on the server. Syntax highlighting, Markdown parsing, and date formatting all happen during the build. Never move them into a Client Component just to get an effect.
- Make interactive components small. A
"use client"component inside a post only ships its own code, but if it imports a charting library, that library ships with it. Load such components lazily withnext/dynamic. - Use
next/imagefor post images. Overridingimgor exposing anImagecomponent in the MDX map gives you responsive sizes and lazy loading for free. - Watch build time, not request time. With hundreds of posts, Shiki is usually the slowest step. Limit the number of languages and themes you load if builds get slow.
- Rebuild on publish. Since every post is static, publishing means a new build. On most hosts, pushing to the main branch is all it takes.
@next/mdx as an Alternative
If you prefer MDX files to be routes themselves, @next/mdx is a solid choice. You install @next/mdx, @mdx-js/loader, @mdx-js/react, and @types/mdx, wrap your config, and add a required mdx-components.tsx file at the project root:
// next.config.mjs
import createMDX from "@next/mdx";
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
};
const withMDX = createMDX({
options: {
remarkPlugins: ["remark-gfm"],
},
});
export default withMDX(nextConfig);
// mdx-components.tsx
import type { MDXComponents } from "mdx/types";
import { mdxComponents } from "@/components/mdx";
export function useMDXComponents(): MDXComponents {
return mdxComponents;
}
Notice that the plugin is passed as a string, "remark-gfm", rather than an imported function. Turbopack, the default bundler in Next.js 16, runs the MDX loader in Rust and can only receive serializable options. Plugins with function-valued options cannot be used with @next/mdx under Turbopack yet. This limitation does not apply to next-mdx-remote, because it compiles MDX at runtime in Node rather than in the bundler, which is one more reason it is the simpler choice for a blog.
Common Problems and Fixes
- "Module not found: Can't resolve 'fs'". The posts loader was imported into a Client Component. Keep it in Server Components and add
import "server-only"to catch this at build time. - "params should be awaited" or
slugis undefined. You are using the pre-Next.js 15 synchronousparams. Typeparamsas a Promise andawaitit in the page andgenerateMetadata. - Component props written with curly braces disappear.
next-mdx-remotev6 strips JavaScript expressions by default. SetblockJS: falsefor trusted content. - "Expected component X to be defined". The tag used in a post is not in the components map. Add it to
mdxComponents, and check the capitalization matches exactly. - A bare less-than sign or curly brace breaks the build. MDX treats them as JSX and expressions. Wrap them in backticks or escape them with a backslash.
- New posts do not appear in production. The build ran before the post's date, or
draftis stilltrue. Check frontmatter and redeploy.
MDX Blog FAQ
For a blog with many posts and frontmatter, next-mdx-remote is usually simpler. It reads posts as data from a content folder, compiles them in Server Components, and works the same way if you later move content to a CMS. @next/mdx is better when an MDX file is itself a page, such as documentation inside the app directory.
Not when rendered in Server Components. The MDX is compiled and rendered on the server, and the browser receives HTML. Only components you mark with use client are hydrated, and only their own code is sent to the browser.
Yes. The loader reads both extensions and next-mdx-remote compiles both. Plain Markdown files simply will not use any components, although you can still map standard elements such as links and images to custom components.
Use rehype-pretty-code with Shiki as a rehype plugin. It highlights code during the build and outputs HTML with inline styles, so no highlighting JavaScript reaches the client.
Give it a future date in frontmatter and filter out future-dated posts in the loader. The post appears on the first build after that date, so pair it with a scheduled daily rebuild on your host if you publish on a timetable.
Yes. Replace the fs calls in the loader with a fetch to your CMS, and pass the returned MDX string to MDXRemote. The rendering code stays the same.
Conclusion
An MDX blog in the App Router comes down to three parts: a content folder of .mdx files with frontmatter, a server-only loader that reads, validates, and sorts them, and two routes that turn that data into a statically generated index and post pages. next-mdx-remote/rsc renders MDX inside Server Components, so you get custom components, GitHub Flavored Markdown, heading anchors, and build-time syntax highlighting without adding JavaScript to the page.
Keep params asynchronous, set dynamicParams to false, register every component a post uses, and treat the build as your publishing step. From there, tags, pagination, RSS, sitemaps, and related posts are small additions on top of a plain array of posts.
Here are some useful references for going deeper on MDX in Next.js:
- Next.js Docs: How to use markdown and MDX in Next.js — the official guide to @next/mdx, remote MDX, and plugins.
- next-mdx-remote: GitHub repository and README — React Server Components usage, compileMDX, and the blockJS options.
- Next.js Docs: generateStaticParams — prerendering dynamic routes at build time.
- MDX: Using MDX — how components, props, and expressions work in MDX.
- Rehype Pretty Code: Documentation — themes, line highlighting, and styling for Shiki-powered code blocks.


