I used react-markdown to render the notes on this site. It worked well, but the setup had more parts than I first realised.
The old stack was react-markdown on top of the unified ecosystem. I used remark plugins to parse Markdown, rehype plugins to process HTML, and a custom component map to turn the result into the interface used by the site.
I recently replaced that pipeline with TanStack Markdown. This was not a one-line package change. The parser, extension model, heading handling, directives, and code blocks all needed to move together.
What I had before
The old renderer looked roughly like this:
import Markdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import remarkDirective from 'remark-directive'
import remarkGfm from 'remark-gfm'
const remarkPlugins = [
remarkGfm,
remarkDirective,
remarkDirectiveParser,
]
<Markdown
remarkPlugins={remarkPlugins}
rehypePlugins={imgBase ? [rehypeRaw] : []}
components={components}
>
{content}
</Markdown>This gave me GitHub-style Markdown, directives such as :::tabs, raw HTML for trusted remote content, and custom React components for headings, links, images, and code blocks.
There was nothing wrong with this approach. react-markdown is still a good choice when I need the unified ecosystem and its large collection of remark and rehype plugins.
The cost was the number of layers I had to connect. Markdown became mdast through remark, then hast through rehype, and finally React elements through react-markdown. That is powerful, but every custom feature had to fit one of those boundaries.
A small clarification about unified
I was not importing unified directly in the React component. react-markdown uses the unified pipeline under the hood. The old setup looked like this:
The current setup is shorter and keeps the document model closer to the renderer:
So the honest description of the old setup is: react-markdown with remark and rehype plugins, built on unified.
What I use now
TanStack Markdown has its own parser and a React renderer:
import { Markdown } from '@tanstack/markdown/react'
<Markdown
components={components}
extensions={extensions}
headingIds={headingIds}
frontmatter={false}
>
{content}
</Markdown>The React adapter renders the parsed document directly. It does not need an intermediate HTML string, and the same document model can also be rendered as HTML or Octane output.
The current component is deliberately small. The application still owns the outer <article>, typography, image base URL, and component replacements. TanStack Markdown owns the Markdown parsing and the standard element rendering.
Replacing remark plugins
The biggest change was replacing plugin calls with focused TanStack Markdown extensions:
export const MARKDOWN_EXTENSIONS = [
autolinkLiteralExtension(),
remarkDirectiveExtension(),
htmlUrlExtension(),
]These are not unified plugins. They use TanStack Markdown’s extension API instead.
The names are familiar because the features are familiar:
autolinkLiteralExtensionrestores bare URL and email linking.remarkDirectiveExtensionparses the::installand:::tabssyntax used by my notes.htmlUrlExtensionresolves relativesrcandhrefvalues when trusted content comes from GitHub.
The directive extension is the most important one. It turns a directive into a component node with a name, attributes, and children. The React component map then sends that node to CodeTabs, CodeEditor, Install, or Embed.
That keeps parsing and rendering separate. The parser decides what the document means. React decides how that meaning looks.
The difference in this project
| Area | react-markdown stack | TanStack Markdown now |
|---|---|---|
| Parser | Unified pipeline through remark and rehype | TanStack’s synchronous parser and document AST |
| React output | Parsed through an HTML-shaped tree before React rendering | React adapter renders the document directly |
| Extensions | remarkPlugins and rehypePlugins | TanStack Markdown extensions |
| Directives | remark-directive plus a custom parser bridge | A custom parseBlock extension creates component nodes |
| Components | react-markdown component map | TanStack Markdown component map with React props |
| Headings | Read heading text from the rehype node | Parser-created heading IDs are shared with the table of contents |
| Raw HTML | Added with rehype-raw when trusted content needed it | Disabled by default; enabled only for trusted content |
| Code blocks | Custom pre component receives the rendered block | Custom pre component still owns the block and TanStack metadata |
| Syntax highlighting | Separate Shiki work inside the code block | Still separate, using the TanStack Highlight renderer |
| Package shape | Several packages and plugin contracts | Focused parser, React adapter, and local extensions |
The comparison is not about one library being universally better. TanStack Markdown is designed for controlled blogs and documentation where the syntax profile is known. Unified is the better fit when exact Markdown compatibility or a large plugin ecosystem is the main requirement.
There is a bundle difference as well. TanStack’s comparison measures the React path at 7.2 KB and the unified stack at 36.8 KB. These are not equal feature sets, so I would not treat the numbers as a promise for this site. They do show the cost of bringing the full remark and rehype ecosystem into a small documentation renderer.
What improved for me
The code has fewer moving parts. There is no separate mdast-to-hast-to-React mental model in the application code, and I do not need to adapt every feature to a third-party plugin contract.
The parser is synchronous and produces a deterministic, serializable document. That matters for SSR, tests, table-of-contents generation, and any future content cache.
The React adapter also leaves the semantic wrapper under my control:
return React.createElement(
Tag,
{ className: proseClass },
<Markdown components={components}>{content}</Markdown>,
)That small detail is useful. The Markdown renderer returns a fragment, so the application can decide whether the content belongs in an article, section, or another semantic element.
A few lessons from the migration
Keep the syntax profile small
I do not need every Markdown edge case. My content uses headings, links, images, tables, task lists, code fences, and a few custom directives. A focused parser is reasonable when those are the real requirements.
If the content later depends on a unified plugin that has no direct equivalent, I have two choices: rewrite that feature as a TanStack extension, or move back to unified. The syntax requirement should decide this, not the library name.
Use extensions for meaning and components for presentation
An extension should parse or transform document data. A component should render it.
For example, the directive extension creates a component node. It does not import React components or decide their layout. That keeps it testable and makes the same document useful to another renderer later.
Parse headings once
The renderer and the table of contents must use the same heading rules. I now create a heading ID factory and pass it to the Markdown renderer. The table of contents uses the same parsed heading information, so links do not drift when a document contains custom blocks.
Treat raw HTML as a boundary
Raw HTML is disabled for normal notes. I enable it only for trusted content such as a GitHub README, where I also resolve relative image and link URLs against a known base.
TanStack Markdown escapes ordinary code and keeps raw HTML opt-in. That is a useful default, but it is not a replacement for a sanitizer when the content is untrusted.
Keep highlighting outside Markdown
Markdown parsing and code highlighting solve different problems. TanStack Markdown hands code blocks to the application, and TanStack Highlight handles tokenisation, line decorations, themes, and rendering.
That separation lets me change the highlighter without changing the Markdown parser. It also keeps the Markdown bundle focused.
The tradeoff
The unified ecosystem is broader and more familiar. If I need remark-gfm, rehype-slug, rehype-raw, or another existing plugin exactly as it is, unified is usually the practical choice.
TanStack Markdown gives me a smaller and more controlled pipeline. It is a good fit for this site because the notes are mine, the syntax is known, the renderers are React and SSR, and the custom UI is more important than complete Markdown compatibility.
The migration did not remove Markdown complexity. It moved the complexity closer to the content model I actually use.



