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.
Terminal
Project 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.
React TSX
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.
TypeScript
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
descriptionfields 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.
TypeScript
getPostBySlug returns null instead of throwing — the caller decides whether a missing post is a 404 or an error. 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.
React TSX
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. It's a 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.
React TSX
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.
React TSX
A Callout component
This is the component I use most. Tip boxes, warnings, info blocks — one component with a type prop.
React TSX
Wiring it up
Pass the components map to MDXRemote in your article page:
React TSX
Now your .mdx files can use these components without any imports:
mdx
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. 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.
Terminal
Add the plugin import to your global CSS file (Tailwind v4 uses CSS-based configuration):
CSS
Wrap your MDX output in a prose container. Add dark:prose-invert so it respects dark mode.
React TSX
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.
Content in git means version history, branch-based drafts, and PR reviews for your writing. Rendering in React means you can embed any component you can build. Static HTML output 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.
Frequently asked questions
Do I need a database to build a blog with Next.js and MDX?
No. Each post is a .mdx file in your repo, parsed at build time with gray-matter — the filesystem is the database. No Postgres, no CMS, and every post deploys as static HTML.
What's the difference between Markdown and MDX?
MDX is Markdown that can import and render React components inline. You keep Markdown's simple prose syntax but can drop in interactive components like callouts, steps, or charts where plain Markdown falls short.
How do I add frontmatter metadata to MDX posts?
Put a YAML block at the top of the file between fence markers (title, date, tags), then parse it with gray-matter. Validate it with a Zod schema so a malformed post fails the build instead of shipping broken.
Is a Next.js MDX blog good for SEO?
Yes. Posts are statically generated as fast, crawlable HTML, and you can emit per-post metadata, canonical URLs, and BlogPosting JSON-LD from the same frontmatter — structured content for search engines and AI assistants.
