Skip to main content
15cfde3ce0d479298521
·6 min read

Moving from react-markdown to TanStack Markdown

How I replaced my unified Markdown pipeline with TanStack Markdown, and what changed in the process.

react
tanstack
react-markdown
typescript
tanstack-markdown
unifiedjs
remark
rehype
3ef4f885272bc6323209
Sohan R. Emon

Developer, Learner, Tech Enthusiast

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:

tsx
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:

tsx
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:

ts
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:

  • autolinkLiteralExtension restores bare URL and email linking.
  • remarkDirectiveExtension parses the ::install and :::tabs syntax used by my notes.
  • htmlUrlExtension resolves relative src and href values 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

Areareact-markdown stackTanStack Markdown now
ParserUnified pipeline through remark and rehypeTanStack’s synchronous parser and document AST
React outputParsed through an HTML-shaped tree before React renderingReact adapter renders the document directly
ExtensionsremarkPlugins and rehypePluginsTanStack Markdown extensions
Directivesremark-directive plus a custom parser bridgeA custom parseBlock extension creates component nodes
HeadingsRead heading text from the rehype nodeParser-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:

tsx
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.

Found this useful? Share!