next-mdx-remote-client Guide for Next.js 16 (2026)
If you followed our original MDX blog setup guide, you already have a working blog powered by next-mdx-remote. There's just one problem: HashiCorp archived that repo on April 9, 2026. The package still runs fine today — nothing breaks overnight — but it's now frozen. No new features, no security patches, no fixes if a future Next.js or React release changes something it depends on.
That's not a reason to panic, but it is a reason to plan your next move. The community's answer is next-mdx-remote-client — an actively maintained fork that keeps the same mental model (compile MDX on the server, render it as React) while fixing several rough edges the original never got around to, including a proper built-in error boundary and support for export statements inside MDX on the App Router.
This guide walks through the same MDX blog setup as before, rebuilt from scratch on next-mdx-remote-client — content folder, typed data layer, custom components, syntax highlighting, and dynamic SEO metadata — so you can migrate confidently or start fresh if you're building a new Next.js 16 blog today.
Note: This guide targets the App Router using
next-mdx-remote-client's React Server Component API (the/rscsubpath). If you're on the Pages Router, the package has a separate, isolated API for that too — see the FAQ at the end.
Why next-mdx-remote-client Instead of next-mdx-remote?
Both packages solve the same problem: MDX content stored outside your app folder — a content/blogs directory, a database, a CMS — rendered through one dynamic [slug] route, instead of every post being its own route file under @next/mdx.
While the original next-mdx-remote was the long-time standard for this pattern, its archiving means developers need a modern alternative. next-mdx-remote-client has emerged as the community's go-to, actively maintained drop-in replacement built specifically for the App Router. A few concrete differences that actually matter day to day:
| Feature | next-mdx-remote | next-mdx-remote-client |
|---|---|---|
| Actively maintained | ❌ Archived April 2026 | ✅ |
| Built-in error handling in App Router | ❌ You wrap it yourself | ✅ onError prop |
export statements from MDX in App Router | ❌ | ✅ |
| Get frontmatter + scope without a separate parse step | ❌ | ✅ (evaluate function) |
| Utility to read frontmatter without compiling | ❌ | ✅ getFrontmatter |
| MDX version | 3 | 3 |
What "built-in error handling" actually buys you: with next-mdx-remote, a broken MDX file (a stray { in an expression, an unclosed JSX tag) throws during render, and it's on you to catch it with an ErrorBoundary. next-mdx-remote-client's MDXRemote component takes an onError prop directly, so a bad post shows a friendly fallback instead of crashing the whole page — which matters a lot the first time a typo in frontmatter takes down your blog in production.
💡 Tip:
next-mdx-remote-clientrequires React 19.1+ and is tested against Next.js 15 and 16. If your project is still on React 18, install the@^1tag instead — see Step 1.
A Quick Word on Security
Before writing any code: MDX compiles to executable JavaScript. That's what makes <Component /> tags work inside your Markdown, but it also means rendering MDX from a source you don't fully control (user submissions, an open CMS) can enable XSS or even remote code execution — this is true of every MDX renderer, not something specific to this package.
For a personal or team blog where you're the only one writing .mdx files, this isn't a practical concern — you already trust your own content. If you ever accept MDX from outside contributors, strip dangerous expressions at the remark stage first (the maintainer's own remark-mdx-remove-expressions plugin is built for exactly this) rather than trying to sanitize after the fact.
Prerequisites
This guide uses:
- Next.js 16 with the App Router
- React 19.2+
- TypeScript in strict mode
- Node.js 20.9+
We'll build:
- 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)- Built-in error handling via
onError - Dynamic SEO metadata and JSON-LD structured data per post
Step 1: Install Dependencies
npm install next-mdx-remote-client gray-matter remark-gfm reading-time sugar-high
Since when this matters is the whole point of this migration, be explicit about which major version you need:
# React 19 projects (the default for new Next.js 16 apps)
npm install next-mdx-remote-client@^2
# React 18 projects
npm install next-mdx-remote-client@^1
What each package does:
| Package | Purpose |
|---|---|
next-mdx-remote-client | Compiles and renders MDX strings as React, with built-in error handling |
gray-matter | Parses frontmatter for the post-listing data layer |
remark-gfm | Adds GitHub-flavored Markdown — tables, strikethrough, task lists |
reading-time | Calculates estimated reading time from post content |
sugar-high | Lightweight, zero-client-JS syntax highlighter for code blocks |
⚠️ Common Mistake: Importing from
next-mdx-remote-clientdirectly. The package splits its App Router and Pages Router code into separate, isolated subpaths — for the App Router you always import fromnext-mdx-remote-client/rsc, never the bare package name. We'll use that subpath throughout this guide.
Step 2: Content Folder and Frontmatter (Unchanged)
If you're migrating from the original guide, this part doesn't change at all — next-mdx-remote-client reads the same MDX files.
content/blogs/hello-world.mdx
---
title: "Hello World: My First MDX Post"
description: "A short example post to test out the next-mdx-remote-client setup."
publishedAt: "2026-08-20"
tags: ["MDX", "Next.js"]
featured: false
---
## Hello World
This is my **first** MDX post rendered with `next-mdx-remote-client`.
- It supports Markdown
- It supports *React components*
- It even supports tables (thanks to remark-gfm)
| Feature | Supported |
| ------------------ | --------- |
| Tables | ✅ |
| Code blocks | ✅ |
| Custom components | ✅ |
Keep frontmatter field names consistent across every post (title, description, publishedAt, tags). A typo like date instead of publishedAt on one file will silently break sorting for that post, since your data layer expects an exact key.
Step 3: Build the Data Layer
This file reads every .mdx file from disk, parses frontmatter with gray-matter, and returns typed post objects for your listing page. This part still uses gray-matter on purpose — it's what powers the blog index (getAllPosts), which needs frontmatter without compiling MDX for every post just to render a list.
// 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[];
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,
updatedAt: data.updatedAt,
tags: data.tags || [],
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);
}
💡 Tip — an alternative worth knowing:
next-mdx-remote-clientships its owngetFrontmatterutility that reads frontmatter without compiling the MDX, using the samevfile-matterextractor internally:import { getFrontmatter } from "next-mdx-remote-client/utils"; const { frontmatter, strippedSource } = getFrontmatter<Post>(fileContent);It's isolated in its own subpath and costs almost nothing to import, so it's a reasonable way to drop the
gray-matterdependency entirely if you're starting fresh. This guide keepsgray-mattersince it's already a common dependency in most Next.js content setups and behaves identically either way.
💡 Tip:
lib/posts.tsuses Node'sfsmodule, so it only runs on the server — inside Server Components,generateStaticParams, orgenerateMetadata. Never import it into a"use client"component.
Step 4: Create Custom MDX Components
The component map itself is unchanged from a next-mdx-remote setup — this is standard @mdx-js/mdx behavior, and next-mdx-remote-client uses the same convention.
// mdx-components.tsx
import React, { ComponentPropsWithoutRef } from "react";
import Link from "next/link";
import { highlight } from "sugar-high";
import type { MDXComponents } from "next-mdx-remote-client/rsc";
type HeadingProps = ComponentPropsWithoutRef<"h1">;
type ParagraphProps = ComponentPropsWithoutRef<"p">;
type ListProps = ComponentPropsWithoutRef<"ul">;
type AnchorProps = ComponentPropsWithoutRef<"a">;
type BlockquoteProps = ComponentPropsWithoutRef<"blockquote">;
export const mdxComponents: MDXComponents = {
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} />
),
a: ({ href, children, ...props }: AnchorProps) => {
const className = "text-blue-500 hover:text-blue-700";
if (href?.startsWith("/")) {
return (
<Link href={href} className={className} {...props}>
{children}
</Link>
);
}
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} />
),
};
What's different here from a next-mdx-remote setup: the MDXComponents type now comes from next-mdx-remote-client/rsc instead of mdx/types — the package re-exports it so you don't need a separate import. Everything else, including the special wrapper key if you want to wrap MDX content in a container element, works exactly the same way.
Step 5: Build the Dynamic Blog Route
This is where the actual migration happens. Swap the import source and add the onError prop — the rest of the route looks familiar if you've built this with next-mdx-remote before.
// app/blog/[slug]/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { MDXRemote, type MDXRemoteOptions } from "next-mdx-remote-client/rsc";
import remarkGfm from "remark-gfm";
import { getAllPosts, getPostBySlug } from "@/lib/posts";
import { mdxComponents } from "@/mdx-components";
import ErrorComponent from "@/components/error-component";
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,
};
}
const mdxOptions: MDXRemoteOptions = {
mdxOptions: {
remarkPlugins: [remarkGfm],
},
};
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>
<Suspense fallback={<p>Loading post…</p>}>
<MDXRemote
source={post.content}
components={mdxComponents}
options={mdxOptions}
onError={ErrorComponent}
/>
</Suspense>
</article>
);
}
Create Error component
// components/error-component.tsx
'use client'
export default function ErrorComponent({ error }: { error: unknown | Error }) {
return (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
<pre>
<code>
{typeof error === "string"
? error
: error instanceof Error
? error.message
: String(error)}
</code>
</pre>
</div>
);
}
What's actually new compared to next-mdx-remote:
import { MDXRemote } from "next-mdx-remote-client/rsc"— the App Router entry point. Importing from the bare package name here would pull in the Pages Router code path instead, which won't work.<Suspense>is required, not optional.MDXRemoteis anasyncServer Component under the hood — it returns aPromise<React.JSX.Element>— so it needs a Suspense boundary the same way any other async Server Component does.onError={ErrorComponent}is the headline feature from earlier. If this specific post's MDX fails to compile — a syntax error like an unclosed tag, or a bad frontmatter parse —MDXRemotecatches it internally and rendersErrorComponentin its place, instead of taking down the whole page.options.mdxOptions.remarkPluginsstill works exactly the waynext-mdx-remotedid —remark-gfmplugs in the same way for tables and GFM syntax.
⚠️ Common Mistake: Forgetting the
<Suspense>wrapper. SinceMDXRemoteis async, rendering it directly without a Suspense boundary will throw a build error telling you an async component needs one — this is a hard requirement of the package, not a style suggestion.
💡 Tip: Notice
onErrorcatches compile/render errors caught by the package's internal handling, but the README is explicit that evaluating the compiled module itself doesn't throw except syntax errors — some render-time errors from a broken custom component can still escape and need a real React<ErrorBoundary>around your route if you want to be fully defensive. For a typical blog where you control every MDX file,onErroralone covers the realistic failure cases (a bad frontmatter reference, a malformed table, an unclosed tag).
If you want to deep dive into Error Handling in Next.js, I've full guide covering this topic here.
Step 6: Want Frontmatter and Scope Inside JSX? Use evaluate Instead
The MDXRemote component above is enough if you only need frontmatter and custom data inside the MDX body (e.g. # {frontmatter.title} written directly in the .mdx file). If you need that data outside the MDX, in your page's own JSX — for a byline, a reading-time badge, a table of contents component — reach for the lower-level evaluate function instead.
Update lib/posts.ts
// lib/posts.ts
export const getSource = async (filename: string): Promise<string | undefined> => {
const sourcePath = path.join(POSTS_PATH, filename);
if (!fs.existsSync(sourcePath)) return;
return await fs.promises.readFile(sourcePath, "utf8");
};
// app/blog/[slug]/page.tsx (alternative using evaluate)
import { Suspense } from "react";
import { evaluate, type EvaluateOptions } from "next-mdx-remote-client/rsc";
import remarkGfm from "remark-gfm";
import { getSource } from "@/lib/posts";
import { mdxComponents } from "@/mdx-components";
import ErrorComponent from "@/components/error-component";
interface BlogPostPageProps {
params: Promise<{ slug: string }>;
}
type Frontmatter = {
title: string;
description: string;
};
const mdxOptions: MDXRemoteOptions = {
parseFrontmatter: true,
mdxOptions: {
remarkPlugins: [remarkGfm],
},
};
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const { slug } = await params;
const source = await getSource(`${slug}.mdx`)!;
if (!source) return <ErrorComponent error="The source could not found!" />;
const { content, frontmatter, error } = await evaluate<Frontmatter>({
source,
options: mdxOptions,
components: mdxComponents,
});
if (error) return <ErrorComponent error={error} />;
return (
<article className="prose dark:prose-invert mx-auto">
<h1>{frontmatter.title}</h1>
<Suspense fallback={<p>Loading post…</p>}>
{content}
</Suspense>
</article>
);
}
What's happening here: evaluate returns { content, mod, frontmatter, scope, error } all at once — content is the compiled MDX JSX (still needs its own <Suspense>), frontmatter is typed and available immediately for use in your surrounding JSX, and error is a plain object you check yourself instead of a component prop. This is the tool to reach for whenever you need frontmatter or a table-of-contents object before the MDX renders, not just inside it — the MDXRemote component genuinely can't do this on the App Router, since it doesn't hand you the frontmatter as a return value at all.
💡 Tip: Passing
parseFrontmatter: truehere meansevaluateextracts frontmatter itself — you could skipgray-matterentirely for a page that only needs a single post. This guide'slib/posts.tsstill usesgray-matterseparately because the blog index page needs frontmatter for every post at once, without compiling every post's MDX just to list titles.
Step 7: Add JSON-LD Structured Data (Optional, but Great for SEO)
Unchanged from the original setup — this is a Next.js <Script> tag, not something the MDX package touches.
// app/blog/[slug]/page.tsx (addition)
import Script from "next/script";
<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" },
}),
}}
/>;
Step 8: Style Blog Content with Tailwind Typography
Also unchanged — MDX content renders as plain, unstyled HTML by default regardless of which remote-MDX package compiled it.
npm install @tailwindcss/typography
/* app/globals.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
--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 {
code:not(pre code) {
background-color: var(--color-gray-100);
}
code:not(pre code) span {
font-weight: 600;
color: black !important;
}
table {
display: block;
max-width: fit-content;
overflow-x: auto;
white-space: nowrap;
text-align: left;
}
}
Migrating an Existing next-mdx-remote Setup: Checklist
If you already have the original guide's setup running in production, here's the exact diff:
-
npm uninstall next-mdx-remoteandnpm install next-mdx-remote-client@^2(or@^1on React 18) - Remove
transpilePackages: ['next-mdx-remote']fromnext.config.mjsif present — it's not needed fornext-mdx-remote-client - Change
import { MDXRemote } from "next-mdx-remote/rsc"toimport { MDXRemote } from "next-mdx-remote-client/rsc" - Change
options={{ mdxOptions: { remarkPlugins: [remarkGfm] } }}to a typedMDXRemoteOptionsobject (optional, but gets you autocomplete) - Add an
onErrorcomponent and pass it as a prop — you're no longer required to hand-roll this yourself - Confirm your route already wraps
<MDXRemote />in<Suspense>—next-mdx-remote's RSC component was also async, so this requirement doesn't actually change -
mdx-components.tsx,lib/posts.ts, your.mdxcontent files, and your Tailwind Typography setup all need zero changes
For most blogs, this is a genuinely small migration — the package was designed as a drop-in-shaped replacement, not a rewrite.
Frequently Asked Questions
Is next-mdx-remote-client a fork or a completely separate project?
It's a from-scratch package built independently in 2024, in the spirit of @mdx-js/mdx, rather than a literal code fork — but it's positioned and documented as a direct alternative to next-mdx-remote, with a published migration guide for exactly this switch.
Do I need to change my .mdx content files at all?
No. next-mdx-remote-client compiles the same MDX syntax, reads the same frontmatter format, and works with the same custom component map. The migration is entirely in your route and import statements, not your content.
Can I still use next-mdx-remote/utils-style frontmatter parsing without gray-matter?
Yes — use getFrontmatter from next-mdx-remote-client/utils, shown in Step 3. It's isolated in its own subpath specifically so it costs almost nothing to import, and the maintainer notes it works fine even if you're still on plain next-mdx-remote elsewhere in your app.
What's the difference between MDXRemote and evaluate?
MDXRemote is the simpler option when you only need frontmatter and scope data inside the MDX body itself. evaluate is lower-level and returns frontmatter, scope, and exported mod data directly to your page's JSX — reach for it when a byline, reading-time badge, or table-of-contents component outside the MDX needs that data. See Step 6.
Does this work with the Pages Router too?
Yes, but through a completely separate, isolated API — serialize (server-side, in getStaticProps), plus hydrate and MDXClient (client-side) — imported from next-mdx-remote-client/serialize and next-mdx-remote-client/csr rather than /rsc. The App Router and Pages Router code paths don't share internals.
Is next-mdx-remote actually broken right now?
No — HashiCorp archived the repository, meaning it's read-only with no further commits, but the last published version (6.0.0) still installs and runs normally today. Nothing forces an immediate migration. The reason to move is that any future Next.js, React, or @mdx-js/mdx change that breaks compatibility will never get a fix from the original package.
Wrapping Up
The move from next-mdx-remote to next-mdx-remote-client is a narrow, mechanical change for most blogs: swap the package, swap one import path, add an onError handler, and everything else — your content folder, your data layer, your custom components, your Tailwind Typography setup — keeps working exactly as it did. What you get in exchange is a package that's still receiving updates, plus a real error boundary for MDX rendering that the original never shipped.
If you're starting a brand-new Next.js 16 blog today rather than migrating one, just start here directly — there's no reason to build on the archived package first.
Useful Resources
- next-mdx-remote-client GitHub Repository
- next-mdx-remote-client Migration Guide
- remark-gfm Documentation
- Tailwind Typography Plugin Docs
Continue Learning
- Complete MDX Blog Setup with next-mdx-remote and Next.js 16 (original guide)
- Next.js 16 Routing Explained: A Complete Beginner's Guide
- Server vs Client Components in Next.js 16
- The Complete Technical SEO Checklist for Next.js Developers
Free Developer Tools
- Meta Tag Generator for Next.js & HTML — generate SEO meta tags, Open Graph tags, Twitter Cards, and JSON-LD instantly for each post.
- JSON-LD Generator for Next.js & HTML — generate BlogPosting structured data checked live against Google's Rich Results requirements.
📦 Source Code: View on GitHub
More Guides
View AllMDX Blog Setup with next-mdx-remote in Next.js 16
Learn how to set up next-mdx-remote in Next.js 16 App Router to render MDX blog posts from the file system, with remark-gfm, custom components, and syntax highlighting.
Server vs Client Components in Next.js 16 (2026 Guide)
Learn when to use Server Components vs Client Components in Next.js 16 App Router, with real code examples, common mistakes, and a clear decision framework.
Session vs JWT: Which Should You Use in Next.js 16?
Session vs JWT in Next.js 16 compared: how each works, real code with jose, Server Actions, and proxy.ts, and how to pick the right one for your app.
llms.txt Explained: What It Is, Who Uses It, and How to Add It to Next.js
llms.txt explained for Next.js developers — what it actually is, who really reads it, and a copy-paste guide to adding one to your App Router site in 2026.