React Markdown: Render Markdown in React Apps (2026)
September 11, 2026 · 9 min read
React Markdown: Render Markdown in React Apps
React markdown rendering takes one package. react-markdown turns a Markdown string into React elements without dangerouslySetInnerHTML, so it is safe for user content by default. This tutorial installs react-markdown 10, adds GFM tables and syntax highlighting, builds a two-pane editor in about 40 lines, and compares it with markdown-to-jsx.
What Is react-markdown and When Should You Use It?
react-markdown is a React component maintained by the remark project, and the usual answer to any reactjs markdown question. It parses Markdown with remark, converts the syntax tree to HTML nodes with rehype, and then renders those nodes as React elements. Because it never builds an HTML string, there is no innerHTML step for an attacker to exploit.
The react-markdown README lists four highlights: safe by default, swappable components, plugins, and full CommonMark compliance (full GFM with one plugin). Version 10.1.0 is current on npm as of September 2026, requires React 18 or later, and ships as ESM only. If your build still expects CommonJS, that last point is the first thing to check.
Use it when Markdown comes from users, a CMS, or a database and you want React components in the output. Skip it when you only need static HTML at build time; the Markdown to HTML guide covers the CLI and pandoc routes for that case.
Install react-markdown and Render Your First String
Install the renderer and the GFM plugin together. Plain CommonMark has no tables, strikethrough, or task lists, so you'll want both.
npm install react-markdown remark-gfm
The smallest working react-markdown render is a component with a string child:
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
const source = `# Release notes
| Version | Date |
|---|---|
| 1.2.0 | 2026-09-11 |
- [x] Tables via remark-gfm
- [ ] Syntax highlighting (next section)
`;
export default function Notes() {
return <Markdown remarkPlugins={[remarkGfm]}>{source}</Markdown>;
}
Three rules from the v9 and v10 changelog save the most debugging time. First, children must be a string; passing JSX or an array throws. Second, the className prop was removed in 10.0.0 (February 2025), so wrap the component in your own div and put the class there. Third, transformLinkUri and transformImageUri were replaced by a single urlTransform function in 9.0.0.
remark-gfm 4.0.1 adds autolink literals, footnotes, strikethrough, tables, and task lists, matching the syntax in the Markdown table guide. Without it, the table above renders as a paragraph of pipes.
How Do You Add Syntax Highlighting to react-markdown?
Fenced code arrives at your components.code override with a className of language-xxx, where xxx is whatever the author typed after the backticks. Pull the language out of that class and hand the text to a highlighter. This is the pattern from the README, using react-syntax-highlighter:
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
const components = {
code({ node, className, children, ...rest }) {
const match = /language-(\w+)/.exec(className || "");
return match ? (
<SyntaxHighlighter
{...rest}
PreTag="div"
language={match[1]}
style={oneDark}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code {...rest} className={className}>{children}</code>
);
},
};
export function Doc({ source }) {
return (
<div className="prose">
<Markdown remarkPlugins={[remarkGfm]} components={components}>
{source}
</Markdown>
</div>
);
}
The match ? ... : ... branch matters: inline code has no language- class and should stay a plain code element. Which identifiers a highlighter recognises depends on the highlighter, not on react-markdown; the code block languages post lists the common ones. The same components object lets you replace a with a Next.js Link, img with a lazy-loading image, or h2 with an anchored heading.
Build a React Markdown Editor in 40 Lines
A react markdown editor is a textarea plus the renderer, sharing one piece of state. Everything else (toolbars, autosave, split resizing) is optional.
import { useState } from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
const initial = "# Hello\n\nType **Markdown** on the left.";
export default function Editor() {
const [text, setText] = useState(initial);
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
style={{ minHeight: 400, fontFamily: "monospace" }}
/>
<div className="preview">
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
</div>
</div>
);
}
That is the whole markdown editor react needs for a comment box or a notes page.
For a toolbar, keyboard shortcuts, image upload, and a preview toggle, packaged options exist. @uiw/react-md-editor (4.1.2 on npm) gives you a full react js markdown editor as one component, and it is the package most people mean by a react-markdown editor. MDXEditor targets rich-text editing of MDX. Searches for a reactjs markdown editor usually land on one of those two. We prefer starting with the 40-line version and adding a package only when users ask for a feature it lacks.
One limitation of the hand-rolled version: the preview re-parses the entire document on every keystroke. For documents over a few thousand lines, debounce setText or render the preview from a deferred value with useDeferredValue.
Here is the same sample from the first example, rendered by our editor. Paste your own Markdown to check how a table, a task list, and a fenced block should look before you wire up the React side.
Our Markdown to HTML tool shows the raw HTML for the same input. That is handy when you're debugging a components override and want to see the element names the react-markdown renderer will hand you.
Is react-markdown Safe? HTML, Links, and Sanitising
Yes, with the defaults. Two mechanisms do the work. HTML written inside the Markdown is escaped, or dropped entirely with skipHtml, so a <script> tag in a comment renders as text. And every URL passes through defaultUrlTransform, which allows only http, https, irc, ircs, mailto, xmpp, and relative URLs, so javascript: links are neutralised.
If you trust the source and need raw HTML to render, add rehype-raw (about 60 kB minzipped per the README). If you trust the source only partly, add rehype-sanitize after it with a schema:
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
<Markdown rehypePlugins={[rehypeRaw, [rehypeSanitize, defaultSchema]]}>
{source}
</Markdown>
The README's security section is blunt: overriding urlTransform with something permissive reopens XSS, and any plugin or custom component you add can be insecure on its own. Sanitise last, after every other rehype plugin, so nothing runs after the filter.
react-markdown vs markdown-to-jsx vs marked
Three approaches cover most React projects. marked plus dangerouslySetInnerHTML is fastest to write and the one to avoid for user content.
| react-markdown 10 | markdown-to-jsx 9 | marked + innerHTML | |
|---|---|---|---|
| Output | React elements | React elements | HTML string |
| Uses innerHTML | No | No | Yes |
| GFM tables and tasks | With remark-gfm | Built in | With gfm: true |
| Raw HTML in Markdown | Escaped; rehype-raw to enable | Parsed to JSX; dangerous tags filtered by default | Rendered as-is |
| Custom elements | components prop | options.overrides | Post-process the string |
| Plugin ecosystem | remark and rehype (hundreds) | Small, built-in | marked extensions |
| Module format | ESM only | ESM | ESM and CJS |
markdown-to-jsx (9.10.2 on npm) is the lighter alternative when you don't need the plugin ecosystem. Its README describes it as GFM plus CommonMark compliant, with arbitrary HTML parsed into JSX rather than injected, and a tagfilter that escapes script, iframe, and style tags by default. The API is <Markdown options={{ overrides }}> with a string child. For a Vue or Angular project the choices differ; see the Vue Markdown editor guide.
Common React Markdown Mistakes
Passing JSX as children. <Markdown><p>{text}</p></Markdown> throws in v9 and later. Pass the string directly.
Expecting tables without remark-gfm. CommonMark has no table syntax. Add remarkPlugins={[remarkGfm]} or the pipes stay as text.
Styling with the className prop. Removed in 10.0.0. Wrap the component in a div and style that, or target elements through components.
Importing in a CommonJS build. react-markdown is ESM only. In Jest, add it to the transformIgnorePatterns exceptions or switch to ESM mode; Next.js and Vite handle ESM packages without extra config.
React Markdown FAQ
Rendering react markdown content comes down to one import, one plugin for GFM, and one components override for code blocks. Keep the defaults for anything users write, reach for rehype-raw only with trusted sources, and start your editor as a textarea before adding a package. Test your sample Markdown in the editor first, so you know what the correct output looks like before you build the React side.