I rendered the notes on this site with react-markdown for a while. It worked, but the setup had more moving parts than I expected: remark plugins parsed the Markdown, rehype plugins handled HTML, and a component map turned the result into site UI.
I recently replaced that pipeline with TanStack Markdown, which meant moving the parser, extension model, heading IDs, directives, and code blocks all at once.
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>That 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.
It did its job, and I'd still reach for react-markdown when I need something from unified's plugin shelf. The price was the number of layers: Markdown became mdast through remark, then hast through rehype, then React elements. Powerful, but every custom feature had to squeeze into one of those boundaries. After a while that got old.
I never imported unified directly, react-markdown runs it internally. This is what that pipeline looked like:
What I use now
TanStack Markdown ships its own parser and 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. No intermediate HTML string, and the same document model can also render as HTML or Octane output.
My component stays small on purpose. The app owns the outer <article>, typography, image base URL, and replacements. TanStack Markdown owns parsing and standard element rendering.
The new setup is shorter, and the document model sits closer to the renderer:
Replacing remark plugins
The biggest change was swapping plugin calls for focused extensions:
export const MARKDOWN_EXTENSIONS = [
autolinkLiteralExtension(),
remarkDirectiveExtension(),
htmlUrlExtension(),
]These aren't unified plugins, they use TanStack Markdown's extension API. The names just describe familiar features:
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 does the heavy lifting. It turns a directive into a component node with a name, attributes, and children, and the React map sends that node to CodeTabs, CodeEditor, Install, or Embed. The extension doesn't know what CodeTabs looks like, and it doesn't need to.
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 |
| Headings | Read heading text from the rehype node | Parser-created heading IDs are shared with the table of contents |
TanStack Markdown fits controlled blogs and docs where the syntax is known up front. Unified fits when exact Markdown compatibility or its plugin shelf matters more.
On bundle size: TanStack’s comparison measures 7.2 KB for its React path against 36.8 KB for the unified stack. Different feature sets, but it gives a feel for what the full remark and rehype ecosystem weighs inside a small docs renderer.
What got better
Those extra layers are gone from my app code. No mdast-to-hast-to-React juggling, and no bending every feature into someone else's plugin contract.
The parser runs synchronously and produces a deterministic, serializable document, which is handy for SSR, tests, TOC generation, and a content cache later.
The dependency swap tells the same story. Out went react-markdown, rehype-raw, remark-directive, remark-gfm, shiki, and unist-util-visit. In came @tanstack/markdown and @tanstack/highlight. I also deleted a 60-line directive bridge I'd written for the old stack, since the new extension model left it with nothing to do.
The same merge added a 480-line test file covering the markdown pipeline. Next time I'd write the tests first and let them pin the heading and directive behavior while everything moves.
The adapter also leaves the semantic wrapper to me:
return React.createElement(
Tag,
{ className: proseClass },
<Markdown components={components}>{content}</Markdown>,
)The renderer returns a fragment, so I pick whatever wraps it: article, section, or something else.
A few things worth knowing
Keep the syntax profile small. My content is headings, links, images, tables, task lists, code fences, and a few directives, and a focused parser covers all of it. If that ever stops being true I'll write an extension or switch back.
Extensions parse or reshape document data and stop there. No React imports in there, and no opinions about layout. The directive extension builds a component node and walks away, which keeps it testable and leaves the door open for another renderer later.
Parse headings once. The renderer and the table of contents share one heading ID factory, so the links can't drift.
Keep raw HTML off by default. Normal notes get none of it. Trusted content like a GitHub README gets it, with relative image and link URLs resolved against a known base. Content is escaped by default, which helps, but that is not a substitute for a sanitizer. I wouldn't serve untrusted content on it alone.
Highlighting lives outside Markdown too. TanStack Markdown hands code blocks to the app, TanStack Highlight does the rest, and I can swap highlighters without touching the parser.
Verdict
Smaller pipeline, closer to the content I actually have. Right call for my own notes with known syntax. The day I need unified's plugin shelf, I'll switch back.



