Every blog platform wants to be your landlord. They give you a database you can't export, an editor you can't customize, and a design system that looks identical to the thirty thousand other blogs on the same platform. Then they raise the price.
MDX with Next.js App Router flips that entire dynamic: your content lives as files in git, renders through React components you control, and deploys as static HTML to whatever host you choose. No vendor lock-in, no migration anxiety, no monthly invoice for the privilege of writing markdown. You own the content, the rendering pipeline, and the styling.
This is the setup I use for this blog. I'm going to walk you through building it from scratch — TypeScript, App Router, server components, the works.
The stack (three packages, that's it)
Before we start scaffolding, here's what we're installing and why each one earns its spot:
- next-mdx-remote — compiles and renders MDX inside React Server Components without bundling content at build time
- gray-matter — extracts YAML frontmatter from your
.mdxfiles into typed JavaScript objects - reading-time — estimates how long a post takes to read, so you can display "5 min read" without guessing
That's the entire content layer. No CMS SDK, no GraphQL client, no ORM. If you're coming from a terminal-first setup, this will feel familiar — everything is files and functions.
pnpm create next-app@latest mdx-blog --typescript --tailwind --app --src-dir=false
cd mdx-blog
pnpm add next-mdx-remote gray-matter reading-timeProject structure
The App Router convention makes the mapping between URLs and files dead obvious. Content goes in content/blog/, utilities go in lib/, and the two route files handle listing and rendering.
Each .mdx file in content/blog/ becomes a blog post. The filename is the slug. No routing config, no database entries, no CMS sync — drop a file in and it exists.
Want to delete a post? Delete the file. Want to rename a URL? Rename the file. The filesystem is the single source of truth, and git is your audit trail.
Notice there's no components/ or styles/ folder in this tree — we'll add custom components later. Start lean, add complexity when you have a reason to.
Frontmatter contract
Before writing any loader code, define what every blog post must include. This is a contract between your content and your rendering layer — if a field is missing, TypeScript catches it at build time instead of your readers catching a broken page in production.
interface BlogFrontmatter {
title: string;
publishedAt: string;
description: string;
image: string;
author: string;
tags: string[];
}
interface BlogPost {
slug: string;
frontmatter: BlogFrontmatter;
content: string;
readingTime: string;
}The content field holds the raw MDX string — we don't serialize it ahead of time because next-mdx-remote/rsc handles compilation inside the server component. That distinction matters: the RSC version compiles on the server during rendering, not during a separate build step.
Why type frontmatter?
I've shipped posts with missing description fields before. The page rendered fine — until social previews showed "undefined" as the meta description across every platform. Type-safe frontmatter prevents that entire class of bug.
Content layer
All the filesystem logic lives in one file. Two functions, no abstractions, no class hierarchy. The file system is the database and gray-matter is the query engine.
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(), "content", "blog");
/** Return metadata + raw MDX for every published post, sorted newest-first. */
export function getAllPosts(): BlogPost[] {
const files = fs.readdirSync(POSTS_DIR).filter((f) => f.endsWith(".mdx"));
return files
.map((filename) => {
const slug = filename.replace(/\.mdx$/, "");
const raw = fs.readFileSync(path.join(POSTS_DIR, filename), "utf-8");
const { data, content } = matter(raw);
return {
slug,
frontmatter: data as BlogFrontmatter,
content,
readingTime: readingTime(content).text,
};
})
.sort(
(a, b) =>
new Date(b.frontmatter.publishedAt).getTime() -
new Date(a.frontmatter.publishedAt).getTime()
);
}
/** Return a single post by slug, or null if it doesn't exist. */
export function getPostBySlug(slug: string): BlogPost | null {
const filePath = path.join(POSTS_DIR, `${slug}.mdx`);
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(raw);
return {
slug,
frontmatter: data as BlogFrontmatter,
content,
readingTime: readingTime(content).text,
};
}Two things worth noting here. First, getPostBySlug returns null instead of throwing — the caller decides whether a missing post is a 404 or an error. Second, we're reading files synchronously because this runs server-side during static generation; there's no event loop to block (and fs.readFileSync is actually faster than its async counterpart for small files).
Blog listing page
The listing page is an async server component. No "use client", no useEffect, no loading states. It calls getAllPosts() at render time, and because Next.js statically generates App Router pages by default, this runs once at build time and outputs pure HTML.
import Link from "next/link";
import { getAllPosts } from "@/lib/content";
export const metadata = {
title: "Blog",
description: "Articles on web development, tooling, and building in public.",
};
export default function BlogPage() {
const posts = getAllPosts();
return (
<main className="mx-auto max-w-2xl px-6 py-16">
<h1 className="mb-8 text-3xl font-bold tracking-tight">Blog</h1>
<div className="flex flex-col gap-6">
{posts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
className="group block rounded-lg border p-6 transition-colors
hover:border-neutral-400 dark:hover:border-neutral-600"
>
<div className="flex items-baseline justify-between gap-4">
<h2 className="font-semibold group-hover:underline">
{post.frontmatter.title}
</h2>
<time
dateTime={post.frontmatter.publishedAt}
className="shrink-0 text-sm text-neutral-500"
>
{new Date(post.frontmatter.publishedAt).toLocaleDateString(
"en",
{ year: "numeric", month: "short", day: "numeric" }
)}
</time>
</div>
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
{post.frontmatter.description}
</p>
<span className="mt-3 block text-xs text-neutral-400">
{post.readingTime}
</span>
</Link>
))}
</div>
</main>
);
}No data-fetching function to export, no serialization boundary to worry about, no hydration mismatch to debug. The component calls a function, gets data, renders JSX. This is what the App Router was designed for.
If you've used getStaticProps in the Pages Router, this is the equivalent — except there's no props object, no serialization boundary, and no separate data-fetching layer. The function runs where the component runs: on the server. It's a genuinely better model once you stop looking for the hooks you're used to.
Article page
The dynamic route needs three things: generateStaticParams to tell Next.js which slugs exist at build time, generateMetadata for SEO, and the page component itself that renders the MDX.
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import { getAllPosts, getPostBySlug } from "@/lib/content";
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }: PageProps) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) return {};
return {
title: post.frontmatter.title,
description: post.frontmatter.description,
openGraph: {
title: post.frontmatter.title,
description: post.frontmatter.description,
images: [post.frontmatter.image],
},
};
}
export default async function ArticlePage({ params }: PageProps) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) notFound();
return (
<main className="mx-auto max-w-2xl px-6 py-16">
<header className="mb-10">
<h1 className="text-3xl font-bold tracking-tight">
{post.frontmatter.title}
</h1>
<div className="mt-3 flex items-center gap-3 text-sm text-neutral-500">
<time dateTime={post.frontmatter.publishedAt}>
{new Date(post.frontmatter.publishedAt).toLocaleDateString("en", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
<span>·</span>
<span>{post.readingTime}</span>
</div>
</header>
<article className="prose dark:prose-invert max-w-none"> // [!code highlight]
<MDXRemote source={post.content} />
</article>
</main>
);
}The import is next-mdx-remote/rsc — not the default export. This tripped me up the first time. The default next-mdx-remote export is designed for the Pages Router: it serializes content in getStaticProps and hydrates it on the client.
The /rsc export skips all of that — it compiles MDX on the server as part of the React render tree, which means zero client-side JavaScript for your content. The entire article ships as static HTML.
Also note the params type: Promise<{ slug: string }>. Next.js 15+ made params asynchronous in layouts and pages, so you need to await it. If you forget, TypeScript will catch it — but the error message is confusing if you don't know what changed.
If
getPostBySlugreturnsnull, callingnotFound()triggers Next.js's built-in 404 page. No try-catch, no error boundaries, no conditional renders.
Custom components
This is the real payoff of MDX over plain markdown. You can pass React components into the renderer and use them directly in your .mdx files. A callout box, a responsive image with blur placeholder, a styled link — anything you can build in React, you can embed in your writing.
The components map
Create a file that maps component names to their implementations. This is what MDXRemote uses to resolve JSX tags in your content.
import Image from "next/image";
import type { MDXComponents } from "mdx/types";
import { Callout } from "@/components/callout";
export const components: MDXComponents = {
Callout,
Image: (props: React.ComponentProps<typeof Image>) => (
<Image
className="rounded-lg"
sizes="(max-width: 768px) 100vw, 672px"
{...props}
/>
),
a: ({ href, children, ...props }) => (
<a
href={href}
target={href?.startsWith("http") ? "_blank" : undefined}
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
{...props}
>
{children}
</a>
),
};A Callout component
This is the component I use most. Tip boxes, warnings, info blocks — one component with a type prop.
import type { ReactNode } from "react";
interface CalloutProps {
type?: "info" | "warning" | "tip";
title?: string;
children: ReactNode;
}
const styles = {
info: "border-blue-500 bg-blue-50 dark:bg-blue-950/30",
warning: "border-amber-500 bg-amber-50 dark:bg-amber-950/30",
tip: "border-purple-500 bg-purple-50 dark:bg-purple-950/30",
};
export function Callout({ type = "info", title, children }: CalloutProps) {
return (
<div className={`my-6 rounded-lg border-l-4 p-4 ${styles[type]}`}>
{title && <p className="mb-1 font-semibold">{title}</p>}
<div className="text-sm leading-relaxed">{children}</div>
</div>
);
}Wiring it up
Pass the components map to MDXRemote in your article page:
import { components } from "@/lib/mdx-components";
// inside the page component:
<article className="prose dark:prose-invert max-w-none">
<MDXRemote source={post.content} components={components} /> // [!code highlight]
</article>Now your .mdx files can use these components without any imports:
## Setting up the project
<Callout type="tip" title="Before you start">
Make sure you have Node.js 18+ and pnpm installed.
</Callout>
Here's what the dashboard looks like:
<Image src="/blog/dashboard.png" alt="Dashboard screenshot" width={1200} height={630} />That's the entire value proposition of MDX in one example. Your content is markdown. Your interactive elements are React. They coexist in the same file, version-controlled in the same repo.
You can add as many components as you want — charts, embedded demos, interactive quizzes — without any changes to your rendering pipeline. The components map scales linearly: add a component, add a key.
Styling the prose
You've done the hard part — content loads, MDX compiles, components resolve. But if you preview the page right now, you'll notice the rendered HTML looks terrible. Headings have no margins, paragraphs run together, lists have no bullets. That's because Tailwind's preflight strips all default browser styles. The @tailwindcss/typography plugin adds them back with a single class.
Install the plugin
pnpm add @tailwindcss/typographyImport it in your CSS
Add the plugin import to your global CSS file (Tailwind v4 uses CSS-based configuration):
@import "tailwindcss";
@plugin "@tailwindcss/typography"; /* */Apply the prose class
Wrap your MDX output in a prose container. Add dark:prose-invert so it respects dark mode.
<article className="prose dark:prose-invert max-w-none"> /* [!code highlight] */
<MDXRemote source={post.content} components={components} />
</article>That single prose class applies typographic defaults to every HTML element inside it — headings get proper sizing and spacing, paragraphs get readable line heights, lists get bullets, code blocks get backgrounds, links get underlines. It transforms raw HTML into something that actually looks like a blog post.
The max-w-none override lets the container width be controlled by the parent instead of typography's default 65ch.
The difference between a blog that looks amateur and one that looks professional is almost entirely typography.
@tailwindcss/typographygets you 90% of the way there with zero custom CSS.
So here's what you've built: a blog where every post is a .mdx file in your repo, parsed by gray-matter, compiled by next-mdx-remote, rendered as a React Server Component, and deployed as static HTML. No database. No CMS. No vendor to migrate away from when they inevitably change their pricing or shut down their API.
The content is in git, which means you get version history, branch-based drafts, and pull request reviews for your writing. The rendering is React, which means you can embed any component you can build. The output is static HTML, which means it loads fast everywhere and costs almost nothing to host.
From here, the natural next steps are syntax highlighting (look at rehype-shiki — it's what I use), an RSS feed for subscribers, and maybe full-text search if your post count warrants it.
If you're still getting your dev environment sorted, my terminal setup guide covers the tooling side. And if you're earlier in the journey — still figuring out whether to even learn to code — I wrote about what I wish I'd known before starting.
But the foundation is solid. Every piece of this system is a plain function or a React component — no framework magic, no generated code, no build step you don't understand. When something breaks (and it will), you'll know exactly where to look. That's the whole point.
You own your content. Ship it.


