Logo

Next.js App Router Migration: From next-mdx-remote to gray-matter

11 min read
Next.jsReact

Table of Contents

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)
3
4// Phase 2: Client-side hydration
5<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:

  1. Server Components don't hydrate - they render once on the server
  2. The MDXRemote component requires client-side React context
  3. 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 logic
2function parse(str) {
3 // 1. Detect frontmatter delimiters (---)
4 const match = str.match(/^---\n([\s\S]*?)\n---/)
5
6 // 2. Extract and parse YAML using js-yaml
7 const frontmatter = yaml.load(match[1])
8
9 // 3. Return content after frontmatter
10 const content = str.slice(match[0].length)
11
12 return { data: frontmatter, content }
13}

The actual implementation is more sophisticated, handling:

1import matter from 'gray-matter'
2
3const file = fs.readFileSync('post.mdx', 'utf8')
4const { data, content } = matter(file)
5
6// 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:

LibraryApproachEcosystem
react-markdown (unified)AST-based transformationremark/rehype plugins
markedRegex-based compilationCustom extensions
markdown-itToken-based parsingPlugin 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 → Output
2 (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 transformation
2// heading { depth: 1 } → element { tagName: 'h1' }
3
4// hast → React
5// 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 nodes
5 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 patterns
2const tokens = marked.lexer('# Hello **world**')
3// [{ type: 'heading', depth: 1, tokens: [...] }]
4
5// 2. Parser: Convert tokens to HTML strings
6const 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 patterns
2const rules = {
3 heading: /^(#{1,6})(?:\s+(.+?))?(?:\n|$)/,
4 code: /^(`{3,})([^\n]*)\n([\s\S]*?)\n\1/,
5 bold: /\*\*([^*]+)\*\*/,
6 // ... many more
7}

Pros:

Cons:

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 stream
2const tokens = md.parse('# Hello')
3// [{ type: 'heading_open', tag: 'h1' },
4// { type: 'inline', content: 'Hello', children: [...] },
5// { type: 'heading_close', tag: 'h1' }]
6
7// 2. Render: Convert tokens to HTML
8const html = md.render('# Hello')

Plugins can hook into specific parsing rules:

1md.core.ruler.push('my_rule', (state) => {
2 // Modify token stream
3})
4
5md.inline.ruler.before('emphasis', 'my_inline', (state, silent) => {
6 // Custom inline parsing
7})

Pros:

Cons:

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<ReactMarkdown
2 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.tsx
4 ├── _document.tsx
5 ├── index.tsx
6 └── articles/[...slug].tsx
7
8After (App Router):
9src/app/
10 ├── layout.tsx
11 ├── page.tsx
12 └── 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].tsx
2export 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}
11
12export async function getStaticProps({ params }) {
13 const [category, slug] = params.slug
14 const source = await serialize(content)
15 return { props: { source, frontmatter } }
16}
17
18export default function ArticlePage({ source, frontmatter }) {
19 return <MDXRemote {...source} components={components} />
20}

After:

1// app/articles/[category]/[slug]/page.tsx
2export async function generateStaticParams() {
3 const articles = await getArticles()
4 return articles.map(({ category, fileName }) => ({
5 category,
6 slug: fileName.replace('.mdx', ''),
7 }))
8}
9
10export default async function ArticlePage({ params }) {
11 const { category, slug } = await params
12 const filePath = path.join(ARTICLE_PATH, category, `${slug}.mdx`)
13
14 if (!fs.existsSync(filePath)) {
15 notFound()
16 }
17
18 const file = fs.readFileSync(filePath, 'utf8')
19 const { data: frontmatter, content } = matter(file)
20
21 return <ArticleDetail content={content} frontmatter={frontmatter} />
22}

The Markdown Renderer

1'use client'
2
3import ReactMarkdown from 'react-markdown'
4import remarkGfm from 'remark-gfm'
5import * as Markup from './Markup'
6
7export const MarkdownRenderer = ({ content }: { content: string }) => {
8 return (
9 <ReactMarkdown
10 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 elements
18 }}
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:

1// EmotionRegistry.tsx
2'use client'
3
4import { useState } from 'react'
5import { useServerInsertedHTML } from 'next/navigation'
6import { CacheProvider } from '@emotion/react'
7import createCache from '@emotion/cache'
8
9export default function EmotionRegistry({ children }) {
10 const [cache] = useState(() => createCache({ key: 'emotion' }))
11
12 useServerInsertedHTML(() => {
13 // Extract and inject styles for SSR
14 })
15
16 return <CacheProvider value={cache}>{children}</CacheProvider>
17}

Performance Comparison

The migration had some nice performance benefits:

MetricBefore (next-mdx-remote)After (react-markdown)
Bundle size~45KB gzipped~28KB gzipped
Build timeSerialize each MDX fileJust read files
RuntimeHydration requiredPure render

The main wins:

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.

Related Articles