For years, developers have used react-markdown to render Markdown documentation in their applications.
That approach works, but configuring standard layout features like tables, heading slugs, and direct clickable links often requires dragging in a complex chain of independent AST parsing packages and compiler extensions.
With TanStack Markdown, you can render Markdown without the overhead of the unified plugin ecosystem.
I put both packages to the test by building the same documentation using them.
Before beginning, make sure your environment has the following:
Under the hood, both libraries transform Markdown strings into React component nodes, but they follow completely different architectural designs.
The react-markdown library handles text processing through an Abstract Syntax Tree (AST). It relies on remark to parse Markdown into an AST and rehype to compile that AST into HTML nodes. Because react-markdown runs this full AST parser on the client, it is highly customizable, allowing you to intercept or modify any node in the compiler pipeline. However, this flexibility comes with a trade-off: standard features like tables or heading slugs are not available out of the box, requiring you to install and configure separate middleware plugins, a process we’ll dive into later.
In contrast, TanStack Markdown is built for speed and simplicity. Rather than relying on a pluggable middleware ecosystem, it parses Markdown directly into a lightweight token stream. It also provides native component properties for common documentation needs, including GitHub Flavored Markdown (GFM), heading IDs, and anchor links.
Apart from the Markdown rendering packages, I used the same TypeScript types, styling, and Markdown content.
This is the types file for the project:
export type DocPage = {
slug: string
title: string
section: string
summary: string
content: string
}
I also have the docs.ts file, which contains the Markdown I will render.
Here is the content of the file:
import type { DocPage } from '../types'
export const docPages: DocPage[] = [
{
slug: 'getting-started',
title: 'Getting started',
section: 'Essentials',
summary: 'The quiet way to make your first day feel less overwhelming.',
content: `# Getting started
Good documentation should help people make progress before it asks them to learn the whole system. This guide gives you a calm place to begin.
## Start with the shape of the work
Before choosing a tool or opening a new tab, write down three things:
- The outcome you are trying to reach
- The smallest useful next step
- The question blocking you right now`,
},
{
slug: 'working-together',
title: 'Working together',
section: 'Essentials',
summary: 'A practical rhythm for sharing context and making decisions.',
content: `# Working together
Strong teams do not remove disagreement. They make disagreement useful by making assumptions visible.
## Share the why
When handing off a task, include the reason behind the decision, not just the requested change.`,
},
]
export const docSections = ['Essentials']
With our data model and Markdown content in place, we can build the Markdown rendering components.
I’ll first show how I built it with react-markdown, then follow with the TanStack Markdown version.
To set this up, let’s get into setting up react-markdown and its plugins.
First, use this command to install react-markdown:
npm install react-markdown
Since react-markdown works with plugins, we must install remark-gfm to handle tables, lists, and task-list markers:
npm install remark-gfm
To support anchor tags and page navigation inside our technical documents, we also need rehype-slug to inject custom ID attributes into headers, and rehype-autolink-headings to automatically make those headers clickable link anchors:
npm install rehype-slug rehype-autolink-headings
Now let’s configure these tools in our component.
Add the following to MarkdownContent.tsx file:
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
type MarkdownContentProps = {
content: string
}
export function MarkdownContent({ content }: MarkdownContentProps) {
return (
<article className="markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, rehypeAutolinkHeadings]}
>
{content}
</ReactMarkdown>
</article>
)
}
In the code above, we imported ReactMarkdown from the react-markdown library, along with other plugins like remarkGfm, rehypeSlug, and rehypeAutolinkHeadings.
Next, we passed the Markdown content string directly inside the ReactMarkdown component.
We specified the remarkPlugins and rehypePlugins parameters to wire up GFM formatting, automatic header slugs, and link anchors. This allows us to parse and style standard Markdown structure dynamically.
For this, install the TanStack Markdown package:
npm install @tanstack/markdown
Then create a MarkdownContent.tsx file with the following code:
import { Markdown } from '@tanstack/markdown/react'
type MarkdownContentProps = {
content: string
}
export function MarkdownContent({ content }: MarkdownContentProps) {
return (
<article className="markdown-content">
<Markdown headingIds headingAnchors>{content}</Markdown>
</article>
)
}
In the code above, we imported Markdown from the TanStack Markdown library. Next, we passed our Markdown content string inside our custom wrapper component.
We also passed the headingIds and headingAnchors props directly to the Markdown component to automatically handle adding heading IDs and anchor tags.
These components are identical across both projects. They handle layout and navigation.
For the shared components, I created the components folder with the following files:
DocsHeader.tsxDocsSidebar.tsxDocsArticle.tsxFor the document header, there is DocsHeader.tsx
Here is the following code:
type DocsHeaderProps = {
isNavOpen: boolean
onMenuToggle: () => void
onHome: () => void
}
export function DocsHeader({ isNavOpen, onMenuToggle, onHome }: DocsHeaderProps) {
return (
<header className="border-b border-[#353b36] bg-[#1a1d1a]/95 px-5 py-4 backdrop-blur md:px-10">
<div className="mx-auto flex max-w-7xl items-center justify-between">
<button className="flex items-center gap-3 text-left" type="button" onClick={onHome}>
<span className="grid size-9 place-items-center rounded-full bg-[#d95d39] text-sm font-bold text-white">N</span>
<span>
<span className="block font-serif text-lg font-bold tracking-tight">Northstar</span>
<span className="block text-[10px] font-semibold uppercase tracking-[0.2em] text-[#b0c0b4]">Field notes</span>
</span>
</button>
<div className="hidden items-center gap-6 text-sm text-[#b0c0b4] md:flex">
<span>Documentation</span>
<span className="rounded-full border border-[#353b36] px-3 py-1.5 text-xs">v1.0</span>
</div>
<button className="rounded-lg border border-[#5a6a5e] px-3 py-2 text-sm font-semibold md:hidden" type="button" onClick={onMenuToggle} aria-expanded={isNavOpen} aria-controls="docs-navigation">
{isNavOpen ? 'Close' : 'Menu'}
</button>
</div>
</header>
)
}
The DocsHeader component manages the primary layout header.
It also includes a responsive menu button that calls onMenuToggle to slide open the navigation sidebar on smaller mobile screens.
This is the code for DocsSidebar.tsx:
import type { DocPage } from '../types'
type DocsSidebarProps = {
pages: DocPage[]
sections: string[]
activeSlug: string
isNavOpen: boolean
onSelect: (slug: string) => void
}
export function DocsSidebar({ pages, sections, activeSlug, isNavOpen, onSelect }: DocsSidebarProps) {
return (
<aside id="docs-navigation" className={`${isNavOpen ? 'block' : 'hidden'} border-b border-[#353b36] px-5 py-7 md:block md:border-b-0 md:border-r md:px-8 md:py-12`}>
<p className="mb-5 text-[10px] font-bold uppercase tracking-[0.2em] text-[#d95d39]">Contents</p>
<nav aria-label="Documentation pages" className="space-y-7">
{sections.map((section) => (
<div key={section}>
<p className="mb-2 text-xs font-semibold text-[#b0c0b4]">{section}</p>
<div className="space-y-1">
{pages.filter((page) => page.section === section).map((page) => (
<button key={page.slug} type="button" onClick={() => onSelect(page.slug)} className={`block w-full rounded-md px-3 py-2 text-left text-sm transition ${activeSlug === page.slug ? 'bg-[#2d352e] font-semibold text-[#e8ece6]' : 'text-[#b0c0b4] hover:bg-[#252b26] hover:text-[#e8ece6]'}`}>
{page.title}
</button>
))}
</div>
</div>
))}
</nav>
</aside>
)
}
Here, we mapped over the documentation sections to filter the pages list to match the current section.
Next, we returned a list of page buttons, triggering the onSelect callback when clicked and styling the button dynamically when its slug matches the active page.
There is also DocsArticle.tsx file:
import { MarkdownContent } from './MarkdownContent'
import type { DocPage } from '../types'
type DocsArticleProps = {
page: DocPage
pageIndex: number
pages: DocPage[]
onSelect: (slug: string) => void
}
export function DocsArticle({ page, pageIndex, pages, onSelect }: DocsArticleProps) {
const previousPage = pages[pageIndex - 1]
const nextPage = pages[pageIndex + 1]
return (
<main className="min-w-0 px-5 py-12 sm:px-10 md:px-16 md:py-16 lg:px-24">
<div className="mx-auto max-w-3xl">
<p className="mb-5 text-xs font-bold uppercase tracking-[0.18em] text-[#d95d39]">{page.section} / Guide {String(pageIndex + 1).padStart(2, '0')}</p>
<div className="mb-12 border-b border-[#353b36] pb-10">
<h1 className="font-serif text-5xl font-bold leading-[1.05] tracking-tight text-[#e8ece6] sm:text-6xl">{page.title}</h1>
<p className="mt-5 max-w-xl text-lg leading-8 text-[#b0c0b4]">{page.summary}</p>
</div>
<MarkdownContent content={page.content} />
<nav className="mt-16 grid grid-cols-2 gap-4 border-t border-[#353b36] pt-6" aria-label="Page navigation">
{previousPage ? <button type="button" className="text-left" onClick={() => onSelect(previousPage.slug)}><span className="block text-xs text-[#b0c0b4]">Previous</span><span className="font-semibold">{previousPage.title}</span></button> : <span />}
{nextPage ? <button type="button" className="text-right" onClick={() => onSelect(nextPage.slug)}><span className="block text-xs text-[#b0c0b4]">Next</span><span className="font-semibold">{nextPage.title} →</span></button> : <span />}
</nav>
</div>
</main>
)
}
In the code above, we imported our custom MarkdownContent component to handle rendering the markdown body. Next, we checked our global pages array based on pageIndex to determine the previous and next pages. We then rendered a simple navigation bar at the bottom, letting developers slide sequentially through our documentation guides.
I finally wired up the app by changing the content App.tsx file to this:
import { useState } from 'react'
import { docPages, docSections } from './content/docs'
import { DocsArticle } from './components/DocsArticle'
import { DocsHeader } from './components/DocsHeader'
import { DocsSidebar } from './components/DocsSidebar'
function App() {
const [activeSlug, setActiveSlug] = useState(docPages[0].slug)
const [isNavOpen, setIsNavOpen] = useState(false)
const activePage = docPages.find((page) => page.slug === activeSlug) ?? docPages[0]
const activeIndex = docPages.findIndex((page) => page.slug === activePage.slug)
function selectPage(slug: string) {
setActiveSlug(slug)
setIsNavOpen(false)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<div className="min-h-screen bg-[#1a1d1a] text-[#e8ece6]">
<DocsHeader isNavOpen={isNavOpen} onMenuToggle={() => setIsNavOpen(!isNavOpen)} onHome={() => selectPage(docPages[0].slug)} />
<div className="mx-auto grid max-w-7xl md:grid-cols-[240px_minmax(0,1fr)_180px]">
<DocsSidebar pages={docPages} sections={docSections} activeSlug={activePage.slug} isNavOpen={isNavOpen} onSelect={selectPage} />
<DocsArticle page={activePage} pageIndex={activeIndex} pages={docPages} onSelect={selectPage} />
<aside className="hidden border-l border-[#353b36] px-6 py-16 lg:block">
<p className="mb-4 text-[10px] font-bold uppercase tracking-[0.18em] text-[#d95d39]">On this page</p>
<p className="text-sm leading-6 text-[#b0c0b4]">A small set of notes for making thoughtful work easier to begin and easier to share.</p>
</aside>
</div>
</div>
)
}
export default App
I initialized two states inside the App component: isNavOpen to toggle the responsive navigation drawer, and activeSlug to track the current active guide. When a user changes pages, it triggers the selectPage function to update the state, close the mobile drawer, and automatically scroll the browser page smoothly back to the top.
This is the result in the browser:

Evaluating both libraries involves weighing zero-dependency simplicity against plugin-based flexibility.
While both handle rendering standard Markdown elegantly, they use different execution models and architectures.
| Feature | react-markdown | @tanstack/markdown |
|---|---|---|
| Dependencies | 4 packages (core + plugins) | 1 package |
| GFM support | Opt-in via remark-gfm |
Built-in |
| Heading IDs | rehype-slug plugin |
headingIds prop |
| Anchor links | rehype-autolink-headings plugin |
headingAnchors prop |
| Component overrides | Full control per element | Limited to built-in props |
In practice, neither library is a perfect fit for every single scenario. Understanding their core limitations helps avoid integration issues down the road.
React Markdown limitations:
TanStack Markdown limitations:
Choosing between these two libraries comes down to a clear tradeoff.
TanStack Markdown eliminates dependency orchestration. Its native properties deliver GFM, heading IDs, and anchor links out of the box with a minimal client-side bundle size.
In contrast, react-markdown offers highly custom control.
Install LogRocket via npm or script tag. LogRocket.init() must be called client-side, not
server-side
$ npm i --save logrocket
// Code:
import LogRocket from 'logrocket';
LogRocket.init('app/id');
// Add to your HTML:
<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>

Can a Rust toolkit speed up Node.js? We tested Nub’s script running, package management, and benchmarks to see how it compares to Bun and Deno.

Compare React Native Track Player and Expo Audio across background playback, lock-screen controls, queue management, licensing, and developer experience.

Can AI refactor legacy CSS without breaking your layout? We benchmarked 5 top AI assistants across cascade order, specificity ties, and stacking traps.

Learn how to offload long-running Gemini AI requests to Trigger.dev background jobs in Next.js using Server Actions and real-time React hooks.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now