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
- react-markdown — the default choice. Renders Markdown as React components (no
dangerouslySetInnerHTML). Plugin ecosystem via remark/rehype. - MDX — Markdown that canimport and use React components inline. The right pick for component-rich docs.
- 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
.mdxfiles; 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:
- react-markdown is safe by default. HTML inside Markdown is escaped. Keep it that way.
- MDX is not safe for user-submitted content. JSX in user input = arbitrary React component execution.
- 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
.mdfiles from disk ingetStaticProps/generateStaticParamsand 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.
Related
- Markdown to HTML — the non-React rendering story
- GitHub Flavored Markdown — what GFM adds over CommonMark
- Markdown in Python — Python equivalent of this guide
- Markdown syntax cheat sheet — the syntax your renderer needs to handle
Related articles
Confluence and Markdown: how to import, convert, and sync
Confluence doesn't accept Markdown directly in its editor. Three working ways to get Markdown into Confluence — paste-conversion, the macro, and the bulk import.
GitHub Flavored Markdown: what's different from CommonMark
GitHub Flavored Markdown (GFM) extends CommonMark with tables, task lists, autolinks, strikethrough, and a few other features. Every difference, in one page.
The AI-generated Markdown cleanup checklist (12 items)
Twelve specific things to clean up in AI-generated Markdown before you ship the document. What each one looks like, why it matters, and how to fix it.
Markdown viewer: how to view .md files online and offline
The best Markdown viewers for browsers, mobile, and desktop. From quick previews of a single .md file to full multi-document workspaces.