Complete MDX Blog Setup with next-mdx-remote and Next.js 16 (2026 Beginner Guide)
If you've ever tried writing blog posts in plain Markdown inside a Next.js app, you've probably hit a wall pretty fast. Plain Markdown can't render React components, can't handle custom styled code blocks, and definitely can't drop in an interactive widget in the middle of a paragraph.
That's exactly the gap MDX fills — and next-mdx-remote is the easiest way to load MDX content that lives outside your app folder (like a content/blogs directory) and render it as real React.
In this guide, you'll build a complete, production-ready blog setup using next-mdx-remote in Next.js 16 — the same setup powering this very blog. By the end, you'll have:
- MDX files stored in a
content/blogsfolder - A reusable
getAllPosts()/getPostBySlug()data layer - Custom-styled MDX components (headings, code blocks, links, tables)
- Syntax highlighting with
sugar-high remark-gfmfor GitHub-flavored Markdown (tables, strikethrough, task lists)- Dynamic SEO metadata and JSON-LD structured data per post
Why MDXRemote Instead of @next/mdx?
Next.js offers two different ways to work with MDX, and beginners often mix them up.
@next/mdx treats .mdx files as actual pages/routes. You'd create a file like app/blog/my-post/page.mdx and Next.js compiles it into a route at build time. This works, but it means every blog post is a route file living inside app/ — awkward if you want a content/ folder, dynamic [slug] routes, or to pull posts from a CMS/database later.
next-mdx-remote lets you store MDX content anywhere — a content/blogs folder, a database, a CMS — and render it through a single dynamic route like app/blog/[slug]/page.tsx. You read the raw MDX string yourself (usually with the filesystem), then hand it to the <MDXRemote /> component to compile and render.
💡 Tip: If you want a handful of static pages (like a docs page),
@next/mdxis simpler. If you're building a blog with many posts and dynamic routing,next-mdx-remoteis the better fit — it's what we'll use here.
What We're Building
content/
blogs/
hello-world.mdx
another-post.mdx
lib/
posts.ts → reads MDX files, parses frontmatter, sorts posts
app/
blog/
[slug]/
page.tsx → dynamic route rendering MDXRemote
opengraph-image.tsx → auto-generated OG image per post
mdx-components.tsx → custom styled components used inside MDX
Prerequisites: Next.js 16 (App Router), TypeScript in strict mode, and Tailwind CSS for styling. If your project doesn't already have these, scaffold one with npx create-next-app@latest.
Step 1: Install Dependencies
Run the following command to install everything this setup needs:
npm install next-mdx-remote gray-matter remark-gfm reading-time sugar-high
Here's what each package actually does:
| Package | Purpose |
|---|---|
next-mdx-remote | Compiles and renders MDX strings as React on the server |
gray-matter | Parses frontmatter (the --- block at the top of your .mdx files) |
remark-gfm | Adds GitHub-flavored Markdown — tables, strikethrough, task lists |
reading-time | Calculates estimated reading time from post content |
sugar-high | Lightweight syntax highlighter for code blocks (no client JS needed) |
⚠️ Common Mistake: Don't confuse
next-mdx-remotewithnext-mdx-remote/rsc. The base package is for the Pages Router / client components. For the App Router with Server Components (which is what we want in Next.js 16), you import fromnext-mdx-remote/rscinstead. We'll use that import throughout this guide.
Step 2: Configure next.config.mjs (Optional)
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
transpilePackages: ['next-mdx-remote'],
}
export default nextConfig
What's happening here:
pageExtensionstells Next.js that.mdand.mdxfiles can also be treated as pages, alongside the usual.ts/.tsx.transpilePackages: ['next-mdx-remote']makes sure Next.js properly transpiles the package rather than trying to treat it as pre-built.
Step 3: Create the Content Folder
This is where your actual blog posts will live as plain .mdx files.
content/blogs/hello-world.mdx
---
title: "Hello World: My First MDX Post"
description: "A short example post to test out the MDXRemote setup in Next.js 16."
publishedAt: "2026-07-01"
tags: ["MDX", "Next.js"]
featured: false
---
## Hello World
This is my **first** MDX post rendered with `next-mdx-remote`.
- It supports Markdown
- It supports *React components*
- It even supports tables (thanks to remark-gfm)
| Feature | Supported |
| ------------------ | --------- |
| Tables | ✅ |
| Code blocks | ✅ |
| Custom components | ✅ |
const greeting: string = "Hello from MDX!";
console.log(greeting);
Understanding the frontmatter block:
The section between the --- lines at the top is called frontmatter. It's plain YAML metadata that describes the post — title, description, publish date, tags — without being part of the rendered content itself. gray-matter (installed earlier) is the library that separates this metadata from the actual MDX body.
⚠️ Common Mistake: Keep your frontmatter field names consistent across every post (
title,description,publishedAt,tags,featured). If one file hasdateand another haspublishedAt, your sorting and metadata logic will silently break for that post.
Step 4: Build the Data Layer (lib/posts.ts)
This file is the heart of the whole setup — it reads every .mdx file from disk, parses the frontmatter, and returns clean, typed post objects your pages can use.
// lib/posts.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import readingTime from "reading-time";
const POSTS_PATH = path.join(process.cwd(), "content/blogs");
export type Post = {
slug: string;
title: string;
description: string;
publishedAt: string;
updatedAt?: string;
tags: string[];
coverImage?: string;
readingTime: string;
content: string;
};
export function getAllPosts(): Post[] {
const files = fs.readdirSync(POSTS_PATH);
const posts = files.map((file) => {
const filePath = path.join(POSTS_PATH, file);
const fileContent = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(fileContent);
const slug = file.replace(/\.mdx$/, "");
return {
slug,
title: data.title,
description: data.description,
publishedAt: data.publishedAt,
tags: data.tags || [],
coverImage: data.coverImage || "",
readingTime: readingTime(content).text,
content,
};
});
return posts.sort(
(a, b) =>
new Date(b.publishedAt).getTime() -
new Date(a.publishedAt).getTime()
);
}
export function getPostBySlug(slug: string) {
return getAllPosts().find((post) => post.slug === slug);
}
export function extractFirstImage(content: string) {
const regex = /!\[.*?\]\((.*?)\)/;
const match = content.match(regex);
return match?.[1] || null;
}
Breaking this down line by line:
fs.readdirSync(POSTS_PATH)reads the filenames of every.mdxfile insidecontent/blogs.matter(fileContent)splits each file intodata(the frontmatter object) andcontent(the raw MDX body below the frontmatter).slugis derived directly from the filename —hello-world.mdxbecomes the slughello-world, which later becomes the URL/blog/hello-world.readingTime(content).textautomatically calculates something like"3 min read"based on word count — no manual math needed.- Posts are sorted newest-first by comparing
publishedAtdates. extractFirstImage()is a small helper that scans the Markdown content for the firstimage — handy for auto-generating Open Graph preview images later.
💡 Tip: Because this uses
fs(Node's filesystem module), it can only run on the server — inside Server Components,generateStaticParams, orgenerateMetadata. Never importlib/posts.tsinto a"use client"component.
Step 5: Create Custom MDX Components
By default, MDX renders plain, unstyled HTML tags (<h1>, <p>, <code>, etc). To make your blog actually look good, you override these tags with your own styled versions.
// mdx-components.tsx
import React, { ComponentPropsWithoutRef } from 'react';
import Link from 'next/link';
import { highlight } from 'sugar-high';
type HeadingProps = ComponentPropsWithoutRef<'h1'>;
type ParagraphProps = ComponentPropsWithoutRef<'p'>;
type ListProps = ComponentPropsWithoutRef<'ul'>;
type ListItemProps = ComponentPropsWithoutRef<'li'>;
type AnchorProps = ComponentPropsWithoutRef<'a'>;
type BlockquoteProps = ComponentPropsWithoutRef<'blockquote'>;
const components = {
h1: (props: HeadingProps) => (
<h1 className="scroll-m-20 text-4xl font-bold tracking-tight lg:text-5xl" {...props} />
),
h2: (props: HeadingProps) => (
<h2 className="scroll-m-20 mt-16 pb-2 text-3xl font-semibold tracking-tight" {...props} />
),
h3: (props: HeadingProps) => (
<h3 className="scroll-m-20 mt-12 text-2xl font-semibold tracking-tight" {...props} />
),
p: (props: ParagraphProps) => (
<p className="text-gray-800 dark:text-zinc-300" {...props} />
),
ul: (props: ListProps) => (
<ul className="text-gray-800 dark:text-zinc-300 list-disc pl-5 space-y-1" {...props} />
),
li: (props: ListItemProps) => <li className="pl-1" {...props} />,
a: ({ href, children, ...props }: AnchorProps) => {
const className = 'text-blue-500 hover:text-blue-700';
// Internal links use Next.js's Link for client-side navigation
if (href?.startsWith('/')) {
return <Link href={href} className={className} {...props}>{children}</Link>;
}
// External links open in a new tab
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={className} {...props}>
{children}
</a>
);
},
code: ({ children, ...props }: ComponentPropsWithoutRef<'code'>) => {
const codeHTML = highlight(String(children));
return <code dangerouslySetInnerHTML={{ __html: codeHTML }} {...props} />;
},
blockquote: (props: BlockquoteProps) => (
<blockquote className="ml-[0.075em] border-l-3 border-green-400 pl-4 text-gray-700" {...props} />
),
em: (props: ComponentPropsWithoutRef<'em'>) => (
<em className="font-medium" {...props} />
),
};
declare global {
type MDXProvidedComponents = typeof components;
}
export { components };
export function useMDXComponents(): MDXProvidedComponents {
return components;
}
What's happening here:
- Every key in the
componentsobject (h1,p,code, etc.) maps directly to that HTML tag name inside your MDX files. Write# My Headingin MDX, and it renders through your customh1component automatically. - The
codecomponent is the interesting one — instead of plain unstyled text, it runs the code throughsugar-high'shighlight()function, which returns syntax-highlighted HTML. This gives you colored code blocks with zero client-side JavaScript, sincesugar-highruns entirely at render time on the server. - The
acomponent checks whether a link is internal (starts with/) or external. Internal links use Next.js's<Link>for fast client-side navigation; external links gettarget="_blank"so readers don't lose your site.
⚠️ Common Mistake: Forgetting to style the
codecomponent is the #1 reason MDX blogs look broken — code blocks renders with no formatting. Always wire up a syntax highlighter here.
Step 6: Build the Dynamic Blog Route
This is where everything comes together — reading the post, compiling the MDX, and rendering it.
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import { getAllPosts, getPostBySlug } from "@/lib/posts";
import { components } from "@/mdx-components";
interface BlogPostPageProps {
params: Promise<{ slug: string }>;
}
// Pre-renders every post at build time (Static Site Generation)
export async function generateStaticParams() {
const posts = getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }: BlogPostPageProps) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) return {};
return {
title: post.title,
description: post.description,
};
}
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) {
notFound();
}
return (
<article className="prose dark:prose-invert mx-auto">
<h1>{post.title}</h1>
<MDXRemote
source={post.content}
components={components}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
},
}}
/>
</article>
);
}
Understanding each piece:
generateStaticParams()tells Next.js every possibleslugvalue ahead of time, so it can statically generate every blog post page at build time instead of rendering it on every request. This is what makes MDX blogs built this way extremely fast.generateMetadata()dynamically sets the<title>and meta description per post — critical for SEO, since every post needs its own unique tags rather than a single site-wide default.notFound()triggers Next.js's built-in 404 page if someone visits a slug that doesn't exist incontent/blogs.<MDXRemote source={...} />is doing the real work: it takes the raw MDX string (post.content), compiles it into React on the server, and renders it — swapping in your customcomponentsalong the way.remarkPlugins: [remarkGfm]enables GitHub-flavored Markdown syntax inside this specific render call — tables,~~strikethrough~~, and- [ ] task listsall now work in your MDX files.
💡 Tip:
MDXRemotefromnext-mdx-remote/rscis an async Server Component. It streams and renders entirely on the server — no MDX compiler code ever ships to the browser, which keeps your client bundle small.
Step 7: Add JSON-LD Structured Data (Optional, but Great for SEO)
To help Google understand each post is a blog article (and potentially show rich snippets), add structured data using Next.js's <Script> component.
// app/blog/[slug]/page.tsx (addition)
import Script from "next/script";
<>
<article>
rest of the code
</article>
<Script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.description,
datePublished: post.publishedAt,
author: { "@type": "Person", name: "Your Name" },
}),
}}
/>
</>
This injects a <script type="application/ld+json"> tag containing structured metadata search engines can parse directly — no visible UI change, just better SEO.
Step 8: Update global.css
Blog content renders as plain HTML elements with no default styling, so headings, paragraphs, and lists will look unstyled out of the box. The Tailwind Typography plugin fixes this in one line.
Install it:
npm install @tailwindcss/typography
Add the plugin to your global CSS:
/* app/globals.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root{
/* your other styles */
/* add code blocks styling */
--sh-class: #7aa2f7;
--sh-sign: #89ddff;
--sh-string: #9ece6a;
--sh-keyword: #bb9af7;
--sh-comment: #acacac;
--sh-jsxliterals: #7aa2f7;
--sh-property: #73daca;
--sh-entity: #e0af68;
--sh-identifier: #fff;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
pre::-webkit-scrollbar {
display: none;
}
code:not(pre code) {
background-color: var(--color-gray-100);
}
code:not(pre code) span {
font-weight: 600;
color: black !important;
}
hr {
color: var(--color-gray-200);
}
/* Remove Safari input shadow on mobile */
input[type='text'],
input[type='email'] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
table {
display: block;
max-width: fit-content;
overflow-x: auto;
white-space: nowrap;
text-align: left;
}
}
Testing Your Setup
Start your dev server:
npm run dev
Then visit:
http://localhost:3000/blog/hello-world
You should see your MDX post fully rendered with styled headings, a working table, and a syntax-highlighted code block.
⚠️ Common Mistake: If you get a "module not found: fs" error, you've likely imported
lib/posts.tsinside a Client Component ("use client"). Thefsmodule only works on the server — remove the client directive or move the data fetching to a Server Component / route handler.
Bonus: Dynamic Open Graph Images
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const runtime = 'edge'; // Use Edge Runtime for faster generation
export const alt = 'Blog Post Preview';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function Image({ params }: { params: Promise<{ slug: string; }> }) {
const title = (await params).slug.replace(/-/g, ' ');
return new ImageResponse(
(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
backgroundColor: "#fff",
padding: "60px",
}}
>
<div
style={{
fontSize: 44,
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
gap: '15px'
}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="green" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" /><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" /><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" /></svg>
<span>Website Name</span>
</div>
<div
style={{
fontSize: 64,
fontWeight: 700,
lineHeight: 1.1,
maxWidth: 900,
textTransform: 'capitalize'
}}
>
{title}
</div>
<div
style={{
fontSize: 28,
opacity: 0.7,
}}
>
example.com
</div>
</div>
),
{ ...size }
);
}
Frequently Asked Questions
Can I use next-mdx-remote with the Pages Router?
Yes, but you'd import from next-mdx-remote (not the /rsc subpath) and use getStaticProps/getStaticPaths instead of generateStaticParams. The /rsc version shown in this guide is built specifically for the App Router's Server Components.
Do I need @next/mdx if I'm already using MDXRemote?
Not strictly — MDXRemote compiles MDX at runtime regardless of your next.config.mjs setup. But registering .mdx as a page extension avoids editor/tooling quirks and keeps the door open if you ever want static .mdx route files too.
How do I add images inside my MDX posts?
Just use standard Markdown image syntax: . If you want Next.js's optimized <Image> component instead, add an img key to your components object in mdx-components.tsx that renders next/image under the hood.
Why is my code block not syntax highlighted?
Double-check that your code component in mdx-components.tsx is running the content through sugar-high's highlight() function. If you copy-pasted code but forgot this override, code blocks fall back to plain unstyled text.
Can I fetch MDX content from a CMS instead of the filesystem?
Yes — that's actually one of the biggest advantages of next-mdx-remote over @next/mdx. Since MDXRemote just needs a raw MDX string, you can fetch that string from Contentful, Sanity, a database, or anywhere else instead of fs.readFileSync. The rest of the setup stays identical.
Wrapping Up
You now have a complete, production-ready MDX blog setup running on Next.js 16 — MDX files stored cleanly in content/blogs, a typed data layer for reading and sorting posts, custom-styled components, GitHub-flavored Markdown support, and per-post SEO metadata.
From here, natural next steps include adding tag-based filtering, a search feature, or auto-generated Open Graph images per post using next/og.
Helpful Resources
- Next.js MDX Documentation
- next-mdx-remote Docs
- remark-gfm Documentation
- gray-matter Documentation
- sugar-high Documentation
- Tailwind Typography Plugin Docs
Related Guides
If you'd like to keep building out your Next.js stack, these guides pair well with this one:
- SaaS Starter Architecture with Next.js 2026
- Build a Todo App with Next.js 16 and Supabase (2026 Guide)
- How to Add Clerk Authentication in Next.js 16 (2026 Guide)
- How to Setup Resend Email in Next.js 16 (2026 Complete Beginner Guide)
Try These Free Developer Tools
While you're here, a couple of free tools that pair naturally with a fresh MDX blog setup like this one:
- Meta Tag Generator for Next.js & HTML — generate SEO meta tags, Open Graph tags, Twitter Cards, canonical URLs, and JSON-LD structured data instantly. Handy for wiring up per-post metadata like the
generateMetadata()function above. - Supabase RLS Policy Generator — if your next project pairs this blog setup with a Supabase backend, generate secure, performance-optimized Row Level Security policies in seconds.
- Zod Schema Generator — generate Zod schemas instantly from JSON or TypeScript. Includes smart inference, live validation, React Hook Form, Next.js API Routes, and Server Actions.
📦 Source Code: View on GitHub