← Blog

Markdown in React: rendering, sanitization, and the libraries that matter

How to render Markdown in a React app — react-markdown, MDX, and the security trade-offs. With code snippets you can paste into a project today.

If you're building a React app that needs to render Markdown — for a blog, a docs site, AI-generated content, user-submitted comments — the React ecosystem has three viable libraries and one common mistake (forgetting to sanitize). This guide covers the libraries, the patterns, and the trade-offs.

The three libraries

  1. react-markdown — the default choice. Renders Markdown as React components (nodangerouslySetInnerHTML). Plugin ecosystem via remark/rehype.
  2. MDX — Markdown that canimport and use React components inline. The right pick for component-rich docs.
  3. markdown-it + dangerouslySetInnerHTML + DOMPurify — when you have HTML and want maximum control.

Pick by what you're rendering.

react-markdown (the easy default)

npm install react-markdown
import ReactMarkdown from 'react-markdown';

export function Post({ markdown }) {
  return <ReactMarkdown>{markdown}</ReactMarkdown>;
}

That's it. The Markdown gets parsed and rendered as React elements —<h1>,<p>,<ul>,<a>, etc. NodangerouslySetInnerHTML, no manual sanitization needed (HTML inside the Markdown is escaped by default).

To enableGitHub Flavored Markdown (tables, task lists, strikethrough), add the plugin:

npm install remark-gfm
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

<ReactMarkdown remarkPlugins={[remarkGfm]}>{markdown}</ReactMarkdown>;

Customizing what each element renders as

<ReactMarkdown
  components={{
    a: ({ href, children }) => (
      <a href={href} target="_blank" rel="noopener noreferrer">
        {children}
      </a>
    ),
    img: ({ src, alt }) => <Image src={src} alt={alt} />,
    code: ({ inline, className, children }) =>
      inline ? (
        <code className="px-1 rounded bg-muted">{children}</code>
      ) : (
        <SyntaxHighlighter language={className?.replace('language-', '')}>
          {String(children)}
        </SyntaxHighlighter>
      ),
  }}
>
  {markdown}
</ReactMarkdown>

This pattern — passing acomponents map — is how most production sites style the output. Headings get anchor links, code blocks get syntax highlighting, images get Next.js's<Image> for optimization.

MDX (Markdown + JSX in one file)

MDX lets you write Markdown with React components embedded inline:

import { Chart } from './Chart';

# Q3 results

The revenue trend shows steady growth.

<Chart data={[1, 2, 3, 4]} />

Back to regular **Markdown** prose.

The MDX compiler turns this into a React component you import like any other. Used by Docusaurus, Astro, Next.js (@next/mdx), Storybook for docs.

When to use MDX:

  • You have docs that need interactive demos, charts, or custom layouts inline
  • Static content lives in.mdx files; dynamic UI is sprinkled in as JSX

Whennot to use MDX:

  • You're rendering user-submitted Markdown — MDX lets users execute arbitrary JSX, which is a security disaster waiting to happen. Stick with react-markdown.

markdown-it + DOMPurify (when you need HTML)

For the cases where you want HTML output (not React elements) — server-side rendering to an email template, generating static HTML files, etc:

npm install markdown-it dompurify
import MarkdownIt from 'markdown-it';
import DOMPurify from 'isomorphic-dompurify';

const md = new MarkdownIt({ html: false }); // disallow raw HTML
const dirty = md.render(markdownString);
const clean = DOMPurify.sanitize(dirty);

The{ html: false } option tells markdown-it to escape any HTML in the Markdown source. DOMPurify is the belt-and-suspenders second layer — it strips anything the renderer let through.

SeeMarkdown to HTML for the non-React side of this story.

The mistake everyone makes once

You render user-submitted Markdown without sanitization. The Markdown contains:

<script>
  fetch('/api/me')
    .then((r) => r.json())
    .then((d) => sendToAttacker(d));
</script>

If your renderer allows raw HTML and you dangerously-set the output, you've shipped XSS. Cookies, session tokens, in-page form data — all exfiltratable.

Three rules:

  1. react-markdown is safe by default. HTML inside Markdown is escaped. Keep it that way.
  2. MDX is not safe for user-submitted content. JSX in user input = arbitrary React component execution.
  3. markdown-it / marked / remark all allow raw HTML by default. Disable it ({ html: false })and sanitize the output.

Syntax highlighting

The clean pattern: don't bundle a highlighter into your main chunk. Lazy-load it:

const SyntaxHighlighter = lazy(() => import('react-syntax-highlighter/dist/esm/light'));

Or use Shiki (server-side highlighting), which produces pre-highlighted HTML at build time and ships zero highlighter code to the client. Best for static blogs.

Loading Markdown content

Common patterns:

  • Build-time (blog, docs): read.md files from disk ingetStaticProps /generateStaticParams and pass the markdown string as a prop. Static-rendered at build, no markdown library at runtime if you pre-render to HTML.
  • Request-time (CMS): fetch from the CMS, parse server-side, render with react-markdown.
  • User input (comments): submit as Markdown, server-side sanitize-then-render-and-store, display the stored HTML.

The Markdown Tidy blog system uses build-time loading. See theREADME.md guide for the docs-blog-from-files pattern.

Common questions

"Why is react-markdown so big?" — the remark/rehype unified ecosystem is comprehensive but ships a lot of code. Tree-shaking helps. If bundle size is a concern, considermarked + sanitization for ~5 KB total.

"My code blocks aren't highlighted." — react-markdown renders<code className="language-python"> but doesn't ship a highlighter. Addreact-syntax-highlighter orshiki via thecomponents prop.

"How do I render tables / strikethrough / task lists?" — addremarkPlugins={[remarkGfm]}. GFM features aren't in CommonMark, so they're opt-in.

Try Markdown Tidy free

Paste markdown, get a polished document — no signup required.