react-markdown
react-markdown is a React component that takes a markdown string as its child and returns real React elements. It never calls dangerouslySetInnerHTML: the markdown is parsed by remark into a syntax tree, converted to an HTML tree by rehype, and then rendered node by node into components, so raw HTML in the source is escaped rather than executed. That pipeline is the whole point. You add syntax by passing remark plugins, transform output by passing rehype plugins, and swap any generated tag (h1, a, code, table) for your own component. It follows CommonMark by default, with GitHub Flavored Markdown available through a separate plugin.
The right default for rendering markdown you do not control, because safety is structural rather than a sanitizer you have to remember to call. Accept the 34 KB and a repo that has been still since April 2025, or drop to marked plus a sanitizer when size beats the component override API.
Use it if
- You are rendering markdown that users, an API, or an LLM produced and you do not want an HTML injection surface anywhere in the path
- You need to replace generated tags with your own components: links that route through your router, images with lazy loading, code blocks with a syntax highlighter
- You want to add or remove syntax rather than accept a fixed dialect, using the remark and rehype plugin catalog (GFM, footnotes, math, emoji, heading anchors)
- You want React to diff the output like any other tree, so re-rendering changed markdown does not blow away and rebuild the DOM the way an innerHTML approach does
- Bundle size is a real constraint: roughly 34 KB gzipped and 11 direct dependencies pulling in the whole unified, remark, and micromark stack, when a comment box that needs bold, links, and code spans could use marked or markdown-it for a fraction of it
- You need CommonJS: the package is ESM only, so require() fails outright, Jest needs experimental VM modules or a transform, and older bundler and Next.js configurations need adjusting first
- You expect active development: 10.1.0 shipped in March 2025 and the last commit to the repo landed in April 2025, so this is a finished component, not a moving one
- You are streaming LLM tokens into it: each render reparses the entire string and rebuilds the tree, and partially typed syntax flickers between interpretations. Plan for memoization or a renderer built for streaming
- You want to write JSX inside your markdown files: that is MDX, and react-markdown deliberately does not support it
- You need HTML inside markdown to actually render: it is escaped by default, and the fix is rehype-raw, which the README puts at roughly another 60 KB minzipped
Setup reality
npm install react-markdown and the component works with no configuration, but three things bite in the first hour. It is ESM only and declares peer dependencies on react >=18 and @types/react >=18, so React 17 codebases and any CommonJS test setup need work before the import resolves. GitHub Flavored Markdown is not included: tables, strikethrough, task lists, and bare-URL autolinking all stay unrendered until you add remark-gfm to remarkPlugins. And no styles ship at all, so headings, tables, and code blocks land as unstyled HTML tags until you add your own CSS or something like Tailwind's typography plugin. One more trap the README documents at length: markdown written directly inside JSX gets its line endings collapsed, so you have to pass it as an expression from a variable or template literal.
Patterns
Render a markdown stringrender-markdown
import Markdown from 'react-markdown'
export function Note({source}) {
return <Markdown>{source}</Markdown>
}The markdown goes in as the child, not as a prop. There is no wrapper element around the output, so add your own div if you need a styling hook.
Turn on GitHub Flavored Markdownenable-gfm
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
const remarkPlugins = [remarkGfm]
export function Doc({source}) {
return <Markdown remarkPlugins={remarkPlugins}>{source}</Markdown>
}Tables, strikethrough, task lists, and bare-URL autolinking are all off until you add this. Hoisting the array to module scope keeps the prop identity stable across renders.
Swap generated tags for your own componentscustom-components
import Link from 'next/link'
const components = {
h1: 'h2',
a({href, children, node, ...rest}) {
return <Link href={href ?? '#'} {...rest}>{children}</Link>
},
img({node, ...props}) {
return <img loading="lazy" {...props} />
}
}
<Markdown components={components}>{source}</Markdown>Every component also receives a node prop holding the original hast element. Destructure it out before spreading, or React warns about an unknown DOM attribute.
Highlight fenced code blockssyntax-highlight
import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter'
import {dark} from 'react-syntax-highlighter/dist/esm/styles/prism'
const components = {
code({children, className, node, ...rest}) {
const match = /language-(\w+)/.exec(className || '')
return match ? (
<SyntaxHighlighter
{...rest}
PreTag="div"
language={match[1]}
style={dark}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code {...rest} className={className}>{children}</code>
)
}
}The code override fires for inline code too, which is why the language- regex check matters. Trim the trailing newline or the highlighter renders an extra blank line.
Limit which elements can be producedrestrict-elements
<Markdown
allowedElements={['p', 'em', 'strong', 'a', 'code', 'ul', 'ol', 'li']}
unwrapDisallowed
>
{userComment}
</Markdown>allowedElements and disallowedElements cannot be combined. Without unwrapDisallowed a blocked element takes its children with it, so a disallowed strong silently deletes the text inside it.
Render HTML embedded in markdownallow-raw-html
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
const rehypePlugins = [rehypeRaw, rehypeSanitize]
<Markdown rehypePlugins={rehypePlugins}>{source}</Markdown>rehype-raw alone reintroduces the XSS surface this library exists to avoid, so keep rehype-sanitize after it for anything user-supplied. Order matters: raw parses the HTML, sanitize then filters the tree.
Filter the tree after plugins runsanitize-plugin-output
import rehypeSanitize, {defaultSchema} from 'rehype-sanitize'
const schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
code: [...(defaultSchema.attributes?.code || []), ['className', /^language-./]]
}
}
<Markdown rehypePlugins={[[rehypeSanitize, schema]]}>{source}</Markdown>Plugins with options go in as a [plugin, options] tuple. The default schema strips the language- class off code elements, which quietly breaks syntax highlighting until you allow it back.
Control which URLs surviveurl-transform
import Markdown, {defaultUrlTransform} from 'react-markdown'
function urlTransform(url, key, node) {
if (key === 'src' && url.startsWith('/uploads/')) return url
return defaultUrlTransform(url)
}
<Markdown urlTransform={urlTransform}>{source}</Markdown>The default allows http, https, irc, ircs, mailto, xmpp, and protocol-relative URLs. Returning the raw url unconditionally is how you reopen javascript: links, so fall through to defaultUrlTransform.
Use async plugins on the clientasync-plugins-client
import {MarkdownHooks} from 'react-markdown'
import rehypeShiki from '@shikijs/rehype'
<MarkdownHooks
rehypePlugins={[[rehypeShiki, {theme: 'github-dark'}]]}
fallback={<pre>{source}</pre>}
>
{source}
</MarkdownHooks>MarkdownHooks renders nothing on the first pass and fills in after an effect, so pass a fallback or the content flashes empty. On the server use MarkdownAsync instead, which you can await.
Render LaTeX mathrender-math
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
<Markdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
{'The lift coefficient ($C_L$) is dimensionless.'}
</Markdown>Two plugins, in that order: remark-math parses the syntax, rehype-katex renders it. Neither imports the KaTeX stylesheet for you, so the math renders as unstyled spans until you do.
Pass literal markdown without breaking itmarkdown-in-jsx
const markdown = `
# Hi
This is a paragraph.
`
const ok = <Markdown>{markdown}</Markdown>
// broken: JSX collapses the newlines into single spaces
const broken = (
<Markdown>
# Hi
This is not a paragraph.
</Markdown>
)Indenting a template literal is the other half of this trap: four leading spaces turn a heading into an indented code block, so keep the markdown flush left.
Avoid reparsing on unrelated rendersmemoize-rendering
import {memo} from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
const remarkPlugins = [remarkGfm]
const components = {a: ExternalLink}
export const MarkdownBlock = memo(function MarkdownBlock({source}) {
return (
<Markdown remarkPlugins={remarkPlugins} components={components}>
{source}
</Markdown>
)
})Inline plugin arrays and components objects are new references on every render, which defeats memoization. Hoisting them to module scope costs nothing and matters most in chat UIs that re-render constantly.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| marked | npm | You want the smallest fast markdown-to-HTML converter and will pair it with your own sanitizer |
| markdown-it | npm | You want a pluggable HTML-string parser outside React, with its own large plugin catalog |
| @mdx-js/react | npm | Authors need to write JSX and import components inside the markdown itself |
| react-remark | npm | You want the same unified pipeline exposed as a hook so you control when parsing happens |