mrkeyoor.com_
Thu 06 Aug 02:41 UTC
npmWeb Frontendupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5Nothing has changed since 10.1.0 in March 2025, so the current surface is completely settled, but majors do delete props rather than deprecate them: 10.0.0 removed the className prop and told you to wrap the component in your own div
Docs5/5One long README that documents every option with its TypeScript type, plus worked examples for plugins, syntax highlighting, and math, an architecture diagram of the remark-to-rehype pipeline, and appendices on HTML in markdown and JSX line endings; there is also a live demo
Maintenance3/5No commits since April 2025 and no release since March 2025. The 1 open issue (5 open issues and PRs) reflects aggressive triage into discussions rather than activity. The remark and rehype packages underneath still move, but this wrapper is parked
Ecosystem5/530M weekly downloads, 15.8k stars, and the entire remark and rehype plugin catalog works here unchanged, which is a much larger surface than a single-library plugin scene

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
Skip it if

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

PackageRegistryPick it when
markednpmYou want the smallest fast markdown-to-HTML converter and will pair it with your own sanitizer
markdown-itnpmYou want a pluggable HTML-string parser outside React, with its own large plugin catalog
@mdx-js/reactnpmAuthors need to write JSX and import components inside the markdown itself
react-remarknpmYou want the same unified pipeline exposed as a hook so you control when parsing happens