The Migration Context
After upgrading to Next.js 16 with React 19, I decided it was time to migrate my blog from the Pages Router to the App Router. What started as a routing migration quickly turned into a deeper exploration of markdown processing libraries when I discovered that next-mdx-remote doesn't play well with React Server Components.
This article covers the technical decisions behind the migration, with a focus on understanding how each library works under the hood.
Why next-mdx-remote Had to Go
next-mdx-remote was designed for the Pages Router era. It serializes MDX content on the server (in getStaticProps) and hydrates it on the client. The problem? This architecture fundamentally conflicts with React Server Components.
The Technical Issue
next-mdx-remote works in two phases:
1// Phase 1: Server-side serialization (getStaticProps)2const mdxSource = await serialize(content)34// Phase 2: Client-side hydration5<MDXRemote {...mdxSource} components={components} />
The serialization produces a compiled JavaScript function that needs to be hydrated on the client. In the App Router, Server Components can't use this pattern because:
- Server Components don't hydrate - they render once on the server
- The
MDXRemotecomponent requires client-side React context - Passing serialized MDX through the RSC wire format causes issues
I realized I was using MDX as overkill anyway. My blog posts were plain markdown with frontmatter - no embedded React components. This made it the perfect opportunity to switch to a simpler stack.
Choosing a Markdown Stack
I needed two things: a frontmatter parser and a markdown renderer. Let me break down the options I considered.
Frontmatter Parsing: gray-matter
gray-matter is the de facto standard for parsing YAML frontmatter from markdown files. Here's what makes it interesting under the hood.
How gray-matter Works
At its core, gray-matter uses a simple but clever approach:
1// Simplified internal logic2function parse(str) {3 // 1. Detect frontmatter delimiters (---)4 const match = str.match(/^---\n([\s\S]*?)\n---/)56 // 2. Extract and parse YAML using js-yaml7 const frontmatter = yaml.load(match[1])89 // 3. Return content after frontmatter10 const content = str.slice(match[0].length)1112 return { data: frontmatter, content }13}
The actual implementation is more sophisticated, handling:
- Multiple delimiters: Supports
---,~~~, or custom delimiters - Multiple formats: YAML (default), JSON, TOML, or custom parsers
- Excerpts: Can extract a content excerpt for previews
- Caching: Stores parsed results to avoid re-parsing
1import matter from 'gray-matter'23const file = fs.readFileSync('post.mdx', 'utf8')4const { data, content } = matter(file)56// data = { title: 'My Post', date: '2025-01-01', ... }7// content = 'The actual markdown content...'
Why Not Alternatives?
front-matter (npm package): Simpler but less flexible. Only supports YAML and doesn't have the excerpt feature. gray-matter's flexibility with custom parsers and delimiters made it the better choice.
Manual regex: I could parse --- blocks myself, but why reinvent the wheel? gray-matter handles edge cases like nested YAML, multiline strings, and various date formats that would be tedious to implement correctly.
Markdown Rendering: The Options
This was the more interesting decision. I evaluated three main options:
| Library | Approach | Ecosystem |
|---|---|---|
| react-markdown (unified) | AST-based transformation | remark/rehype plugins |
| marked | Regex-based compilation | Custom extensions |
| markdown-it | Token-based parsing | Plugin middleware |
Option 1: react-markdown (unified ecosystem)
react-markdown is built on the unified collective, which processes content through AST (Abstract Syntax Tree) transformations.
How unified Works Under the Hood:
The unified pipeline has three phases:
1Input String → Parse → Transform → Compile → Output2 (remark) (plugins) (rehype)
Phase 1 - Parse (remark): Converts markdown string to mdast (Markdown AST)
1// Input: "# Hello **world**"2// mdast output:3{4 type: 'root',5 children: [{6 type: 'heading',7 depth: 1,8 children: [{9 type: 'text',10 value: 'Hello '11 }, {12 type: 'strong',13 children: [{ type: 'text', value: 'world' }]14 }]15 }]16}
Phase 2 - Transform (plugins): Manipulate the AST
1// remark-gfm adds support for:2// - Tables (GridTable nodes)3// - Strikethrough (delete nodes)4// - Task lists (listItem with checked property)5// - Autolinks
Phase 3 - Compile (rehype): Convert mdast to hast (HTML AST), then to React elements
1// mdast → hast transformation2// heading { depth: 1 } → element { tagName: 'h1' }34// hast → React5// element { tagName: 'h1' } → React.createElement('h1', ...)
The Plugin Ecosystem:
The unified ecosystem's power comes from its plugin architecture. Each plugin is a function that receives and returns an AST:
1function myRemarkPlugin() {2 return (tree) => {3 visit(tree, 'text', (node) => {4 // Transform all text nodes5 node.value = node.value.toUpperCase()6 })7 }8}
Common plugins include remark-gfm for GitHub Flavored Markdown (tables, strikethrough, task lists), rehype-slug for auto-generating heading IDs, and rehype-prism-plus for syntax highlighting.
For my blog, I only use remark-gfm. Heading IDs are generated in the React component itself by slugifying the text content - this keeps the logic close to the styling. For syntax highlighting, I use prism-react-renderer instead of a rehype plugin. It's a React component that tokenizes code at render time, giving more control over styling.
Option 2: marked
marked takes a fundamentally different approach - it's a regex-based compiler that directly transforms markdown to HTML.
How marked Works Under the Hood:
marked uses a lexer-parser architecture:
1// 1. Lexer: Tokenize input using regex patterns2const tokens = marked.lexer('# Hello **world**')3// [{ type: 'heading', depth: 1, tokens: [...] }]45// 2. Parser: Convert tokens to HTML strings6const html = marked.parser(tokens)7// '<h1>Hello <strong>world</strong></h1>'
The lexer uses a series of regex patterns to identify markdown elements:
1// Simplified internal patterns2const rules = {3 heading: /^(#{1,6})(?:\s+(.+?))?(?:\n|$)/,4 code: /^(`{3,})([^\n]*)\n([\s\S]*?)\n\1/,5 bold: /\*\*([^*]+)\*\*/,6 // ... many more7}
Pros:
- Fast (no AST overhead)
- Simple mental model
- Small bundle size
Cons:
- Regex-based parsing can have edge cases
- Less flexible plugin system
- Returns HTML strings, not React elements (need
dangerouslySetInnerHTML)
Option 3: markdown-it
markdown-it uses a token-based approach similar to marked but with a more structured plugin API.
How markdown-it Works Under the Hood:
markdown-it has a two-phase architecture with a middleware-like plugin system:
1// 1. Parse: Generate token stream2const tokens = md.parse('# Hello')3// [{ type: 'heading_open', tag: 'h1' },4// { type: 'inline', content: 'Hello', children: [...] },5// { type: 'heading_close', tag: 'h1' }]67// 2. Render: Convert tokens to HTML8const html = md.render('# Hello')
Plugins can hook into specific parsing rules:
1md.core.ruler.push('my_rule', (state) => {2 // Modify token stream3})45md.inline.ruler.before('emphasis', 'my_inline', (state, silent) => {6 // Custom inline parsing7})
Pros:
- Very extensible
- CommonMark compliant
- Battle-tested (used by many CMSs)
Cons:
- Plugin API has a learning curve
- Different ecosystem from unified
- Also returns HTML strings
My Decision: react-markdown
I chose react-markdown for several reasons:
Native React Integration
Returns actual React elements, not HTML strings. This means no dangerouslySetInnerHTML, type-safe component overrides, and React DevTools shows the actual component tree.
Component Customization
I can pass custom components for each markdown element:
1<ReactMarkdown2 components={{3 h1: ({ children }) => <CustomH1>{children}</CustomH1>,4 pre: ({ children }) => <SyntaxHighlighter>{children}</SyntaxHighlighter>,5 a: ({ href, children }) => <SafeLink href={href}>{children}</SafeLink>,6 }}7>8 {content}9</ReactMarkdown>
Plugin Ecosystem
The remark/rehype ecosystem has plugins for everything: GFM support (tables, task lists), math rendering (KaTeX), syntax highlighting, auto-linking headings, and custom containers.
Future Flexibility
If I ever need MDX features again, the unified ecosystem supports it through @mdx-js/mdx.
The Migration Implementation
Project Structure Changes
1Before (Pages Router):2src/pages/3 ├── _app.tsx4 ├── _document.tsx5 ├── index.tsx6 └── articles/[...slug].tsx78After (App Router):9src/app/10 ├── layout.tsx11 ├── page.tsx12 └── articles/[category]/[slug]/page.tsx
Data Fetching Changes
The biggest paradigm shift was moving from getStaticProps/getStaticPaths to Server Components with generateStaticParams.
Before:
1// pages/articles/[...slug].tsx2export async function getStaticPaths() {3 const articles = await getArticles()4 return {5 paths: articles.map((a) => ({6 params: { slug: [a.category, a.fileName] },7 })),8 fallback: false,9 }10}1112export async function getStaticProps({ params }) {13 const [category, slug] = params.slug14 const source = await serialize(content)15 return { props: { source, frontmatter } }16}1718export default function ArticlePage({ source, frontmatter }) {19 return <MDXRemote {...source} components={components} />20}
After:
1// app/articles/[category]/[slug]/page.tsx2export async function generateStaticParams() {3 const articles = await getArticles()4 return articles.map(({ category, fileName }) => ({5 category,6 slug: fileName.replace('.mdx', ''),7 }))8}910export default async function ArticlePage({ params }) {11 const { category, slug } = await params12 const filePath = path.join(ARTICLE_PATH, category, `${slug}.mdx`)1314 if (!fs.existsSync(filePath)) {15 notFound()16 }1718 const file = fs.readFileSync(filePath, 'utf8')19 const { data: frontmatter, content } = matter(file)2021 return <ArticleDetail content={content} frontmatter={frontmatter} />22}
The Markdown Renderer
1'use client'23import ReactMarkdown from 'react-markdown'4import remarkGfm from 'remark-gfm'5import * as Markup from './Markup'67export const MarkdownRenderer = ({ content }: { content: string }) => {8 return (9 <ReactMarkdown10 remarkPlugins={[remarkGfm]}11 components={{12 h1: Markup.H1,13 h2: Markup.H2,14 h3: Markup.H3,15 pre: Markup.HighlightedCode,16 code: Markup.Code,17 // ... other elements18 }}19 >20 {content}21 </ReactMarkdown>22 )23}
Handling Emotion with App Router
One gotcha: Emotion styled components require the 'use client' directive in App Router. The createContext API used internally by Emotion doesn't work in Server Components.
I had to add 'use client' to:
- All components using
styledfrom@emotion/styled - The EmotionRegistry that handles SSR style injection
- Any component using Emotion's
useThemehook
1// EmotionRegistry.tsx2'use client'34import { useState } from 'react'5import { useServerInsertedHTML } from 'next/navigation'6import { CacheProvider } from '@emotion/react'7import createCache from '@emotion/cache'89export default function EmotionRegistry({ children }) {10 const [cache] = useState(() => createCache({ key: 'emotion' }))1112 useServerInsertedHTML(() => {13 // Extract and inject styles for SSR14 })1516 return <CacheProvider value={cache}>{children}</CacheProvider>17}
Performance Comparison
The migration had some nice performance benefits:
| Metric | Before (next-mdx-remote) | After (react-markdown) |
|---|---|---|
| Bundle size | ~45KB gzipped | ~28KB gzipped |
| Build time | Serialize each MDX file | Just read files |
| Runtime | Hydration required | Pure render |
The main wins:
- No serialization step: gray-matter just parses YAML, which is much faster than compiling MDX to JavaScript
- Smaller client bundle: react-markdown is lighter than the MDX runtime
- Server Components: Article pages are now true Server Components (except the markdown renderer)
Lessons Learned
1. Understand What You Actually Need
I was using MDX because it was the "modern" choice, but I never used its killer feature (embedding React components in markdown). Plain markdown with gray-matter + react-markdown was sufficient and simpler.
2. The unified Ecosystem Is Powerful
Once you understand the AST transformation model, the unified ecosystem becomes incredibly flexible. Need to transform all links to open in new tabs? Write a rehype plugin. Need to extract all headings for a table of contents? Walk the mdast. The abstraction pays off.
3. App Router Changes Everything
The mental model shift from "render on server, hydrate on client" to "Server Components by default, Client Components when needed" took some adjustment. But the result is cleaner: data fetching is just async/await, and you explicitly mark interactive boundaries.
4. Check Library Compatibility Early
Before starting a major migration, check if your dependencies support the target architecture. I would have saved time by checking next-mdx-remote's RSC compatibility first.
Wrapping Up
The migration from Pages Router + next-mdx-remote to App Router + gray-matter + react-markdown simplified my stack while improving performance. Understanding how each library works under the hood helped me make informed decisions rather than just following tutorials.
If you're considering a similar migration, ask yourself: do you actually need MDX? If you're just writing markdown with frontmatter, the simpler stack might be the better choice.
The code for this blog is open source if you want to see the full implementation.
