Rendering rich, user-generated content in React looks straightforward until you need to keep it both readable and secure. Pushing HTML strings through dangerouslySetInnerHTML can expose you to XSS attacks.
React-markdown offers a safer path by converting Markdown directly into React components, skipping that risky API entirely. You install the library, learn how to tailor every heading and link to your design system, add syntax-highlighted code blocks, and integrate the whole flow with Strapi so your editorial team manages content while you focus on features.
In brief:
dangerouslySetInnerHTML, reducing XSS risk when configured correctly.These points frame the setup, security, and performance decisions covered below.
React-markdown is safer than injecting HTML strings with dangerouslySetInnerHTML. Its README lists it as safe by default because it builds a virtual DOM from a syntax tree rather than injecting raw HTML strings.
That does not mean every Markdown rendering configuration is automatically safe. Raw HTML, unsafe URLs, third-party plugins, and user-generated content still require controls. The moment you turn on rehype-raw, override urlTransform, or add a plugin that emits markup, you take on responsibility for the output.
Security here should follow a defense-in-depth model. OWASP XSS guidance states that no single technique will solve XSS, so the right combination of defensive techniques is necessary. A Content Security Policy belongs in that stack as a second layer, not the only defense. So treat react-markdown's defaults as a strong foundation, then layer sanitization, URL validation, and CSP on top when your content sources demand it.
React Markdown is a lightweight React component that renders Markdown text into React elements while maintaining React's component structure and security model. Markdown remains the fastest way to write rich content, but once you have that .md string in hand you need a React-friendly way to show it on the page.
Built on top of remark and rehype, it converts Markdown into React components without relying on dangerouslySetInnerHTML. The pipeline runs markdown → remark (mdast) → remark plugins → remark-rehype (hast) → rehype plugins → React components. It follows CommonMark standards and offers optional support for GitHub Flavored Markdown (GFM) through a plugin. Its popularity stems from active maintenance, a wide plugin network, and compatibility with React.
While alternatives like marked and markdown-it exist, this library suits React-first environments because it emits native React elements instead of HTML strings. For a broader look at the React ecosystem, that overview covers the most popular component libraries available today.
The difference is architectural. dangerouslySetInnerHTML takes a raw HTML string and injects it straight into the DOM, so any <script> tag or onerror attribute embedded in that string executes in your users' browsers. React-markdown never touches that API in its default path. Instead, it parses Markdown into a syntax tree, then constructs React elements from each node. Because output flows through JSX, React's escaping handles text content, and raw HTML in the source is escaped or dropped rather than executed.
That distinction matters most with content you do not control. A component-based renderer gives you a tree of known elements you can filter, remap, and validate; a string injection gives you an opaque blob you have to sanitize after the fact.
React-markdown fits CMS-driven content, documentation, blog posts, comments, and changelogs. Anywhere non-technical editors write Markdown and you render it into a React frontend, this library keeps the workflow clean. It pairs especially well with a headless CMS like Strapi, where content authors work in Markdown fields and developers control presentation entirely through React components.
If your authors need to write JavaScript and JSX inside their content, react-markdown is the wrong tool. Its README says to use MDX if you want JavaScript and JSX inside markdown files. React-markdown lets you map Markdown tags to components, but it does not execute arbitrary JSX from the content itself. For interactive documentation, complex editorial blocks, or arbitrary HTML from trusted authors, you need a different approach, which we compare later.
Getting started is straightforward. Install the library using npm or yarn:
npm install react-markdown
# or
yarn add react-markdownThe current release is 10.1.0. TypeScript support ships by default, so you do not need additional type packages.
Import and use it in your React component. The Markdown content passes as the children prop:
1import ReactMarkdown from 'react-markdown';
2
3const MyComponent = () => {
4 const markdown = '# Hello, world!';
5
6 return <ReactMarkdown>{markdown}</ReactMarkdown>;
7};Only a minimal dependency footprint gets added to your project, and you avoid the raw HTML injection that security guides warn against.
React-markdown exports types that pair every node with its expected props. The Components type keeps your custom renderers predictable, so your editor auto-completes attributes:
1import ReactMarkdown from 'react-markdown';
2import type { Components } from 'react-markdown';
3
4const components: Components = {
5 h2({ node, children, ...props }) {
6 return (
7 <h2 data-level={2} {...props}>
8 {children}
9 </h2>
10 );
11 },
12};
13
14export default function Article({ markdown }: { markdown: string }) {
15 return <ReactMarkdown components={components}>{markdown}</ReactMarkdown>;
16}Every component receives a node prop (the original hast element). Destructure it out before spreading ...rest onto a DOM element to avoid React unknown prop warnings.
Rendering user-supplied content creates immediate risk. If you pipe raw HTML into the DOM, attackers can execute JavaScript in your users' browsers, a core risk covered by OWASP XSS guidance. React-markdown addresses much of this by default, but understanding exactly what it protects against, and what it does not, keeps you out of trouble. If you are pairing react-markdown with a CMS backend, the same principles apply to your CMS security posture more broadly.
Three guarantees come out of the box. First, dangerouslySetInnerHTML is never called in default rendering. Second, raw HTML embedded in Markdown is not rendered by default. The README notes that react-markdown escapes HTML or ignores it with skipHtml because it is dangerous and defeats the purpose of the library. Third, a urlTransform function runs by default and blocks dangerous protocols.
What it does not do: react-markdown does not automatically sanitize arbitrary HTML attributes, CSS, or plugin output. The "safe by default" guarantee is scoped precisely to those three behaviors.
The moment you want to render trusted HTML embedded in Markdown, you add the rehype-raw plugin. This passes each raw HTML node through parse5 to recreate a tree exactly as a browser would parse it. The react-markdown README limits this to trusted environments only. Turning on raw HTML for untrusted user content without additional sanitization reintroduces the exact XSS vectors you were avoiding.
When you need raw HTML, pair rehype-raw with rehype-sanitize. The react-markdown README is explicit: to make sure the content is completely safe, even after what plugins do, use rehype-sanitize. It lets you define your own schema of what is and is not allowed.
Order matters. Because rehype-raw creates hast nodes from raw strings, rehype-sanitize must run after it to validate the newly generated nodes:
1unified()
2 .use(remarkParse)
3 .use(remarkRehype, { allowDangerousHtml: true })
4 .use(rehypeRaw) // 1. parse raw HTML strings into hast nodes
5 .use(rehypeSanitize) // 2. sanitize the resulting hast tree
6 .use(rehypeStringify)rehype-sanitize drops unallowed content by a schema, defaulting to how github.com works. It also solves DOM clobbering by prefixing every id and name attribute with 'user-content-'. The default schema strips onmouseover, onerror, javascript: hrefs, <iframe>, and <script> while keeping safe structural elements.
DOMPurify, which OWASP recommends for HTML sanitization, operates on strings. That makes it a natural fit when you render string output from marked or markdown-it, but architecturally mismatched for mid-pipeline use in the rehype tools. For react-markdown, rehype-sanitize is the recommended tool. If you do use DOMPurify server-side in Node.js, pair it with the latest jsdom, since older jsdom versions are known to be buggy in ways that result in XSS.
Fine-grained controls provide additional layers. The allowedElements and disallowedElements props create positive or negative security models. You cannot combine the two:
1import ReactMarkdown from 'react-markdown';
2
3<ReactMarkdown
4 allowedElements={['p', 'strong', 'em', 'a', 'code']}
5>
6 {markdown}
7</ReactMarkdown>Setting unwrapDisallowed to true replaces a disallowed element with its children rather than removing it entirely. Keep in mind these props filter the element tree but do not sanitize attribute values, URL contents, or plugin output.
The current API uses the urlTransform prop (the deprecated transformImageUri and transformLinkUri were removed in v9). Its default, defaultUrlTransform, follows how GitHub works. It allows the protocols http, https, irc, ircs, mailto, and xmpp, and URLs relative to the current protocol. javascript: URIs and all other unlisted protocols are blocked by default.
The docs carry a blunt warning: use of react-markdown is secure by default, and overwriting urlTransform to something insecure will open you up to XSS vectors.
For stricter policies, you have a few choices:
defaultUrlTransform permits irc, ircs, and xmpp on top of http/https/mailto. OWASP's stricter guidance recommends http, https, and mailto only. Use the createUrlTransform factory to define a narrow set without reimplementing sanitization logic.urlTransform function receives the key (e.g., 'src') and node, so you can apply per-attribute, per-element logic. Rather than string-matching, OWASP advises using domain allowlists, parsing the URL with a standard library, and comparing the hostname against a strict allowlist with new URL().Together, these checks keep links useful while reducing protocol and image-source risk.
1const IMAGE_HOSTS = new Set(['cdn.example.com', 'localhost:1337']);
2
3function urlTransform(url, key) {
4 if (key === 'src') {
5 try {
6 const { host } = new URL(url, 'https://example.com');
7 return IMAGE_HOSTS.has(host) ? url : '';
8 } catch {
9 return '';
10 }
11 }
12 return url;
13}A strict Content-Security-Policy header adds browser-level protection that complements sanitization. OWASP is careful about its role: a strong CSP provides second-layer protection against various types of vulnerabilities, especially XSS, but should not be the only defensive mechanism against XSS.
Current best practice favors a nonce-based policy:
1Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic'; object-src 'none'; base-uri 'none';For pages that render Strapi images, add an img-src directive listing your Strapi origin and any CDN domains. Combining component-based output with sanitization, URL validation, and CSP lets you render rich content while blocking XSS across multiple layers.
React-markdown gives you control over visual and behavioral rendering through the components prop. Map every Markdown element to your own React component. Since rendering happens through JSX, you inject props, context, or design-system tokens without dangerouslySetInnerHTML.
The simplest approach wires each renderer to a CSS class:
1import ReactMarkdown from 'react-markdown';
2
3function H1(props) {
4 const { node, ...rest } = props;
5 return <h1 className="heading-xl" {...rest} />;
6}
7
8function P(props) {
9 const { node, ...rest } = props;
10 return <p className="body-md" {...rest} />;
11}
12
13export default function Article({ markdown }) {
14 return (
15 <ReactMarkdown components={{ h1: H1, p: P }}>
16 {markdown}
17 </ReactMarkdown>
18 );
19}For CMS content you don't fully control, the @tailwindcss/typography plugin is the fastest path to readable defaults. It provides prose classes you can use to add typographic defaults to vanilla HTML you don't control, like HTML rendered from Markdown or pulled from a CMS.
Wrap your rendered Markdown in a prose container:
1<article className="prose lg:prose-xl dark:prose-invert">
2 <ReactMarkdown>{markdown}</ReactMarkdown>
3</article>Install the plugin (v0.5.20) in Tailwind v4 with:
1@import "tailwindcss";
2@plugin "@tailwindcss/typography";Size modifiers run from prose-sm (14px) through prose-2xl (24px), and dark:prose-invert handles dark mode. Per-element overrides like prose-a:text-blue-600 and prose-img:rounded-xl let you tune specific tags. Use not-prose on a child to opt out. The prose wrapper and the components prop are complementary: use prose for global typographic defaults and components for element-level substitution.
Each renderer is a React function, so wire them to CSS Modules, Tailwind, or styled-components. Behavior can be customized too. Add outbound-link handling and security headers:
1function Link({ href, children, ...rest }) {
2 const external = /^https?:\/\//.test(href);
3
4 return (
5 <a
6 href={href}
7 {...rest}
8 target={external ? '_blank' : undefined}
9 rel={external ? 'noopener noreferrer' : undefined}
10 >
11 {children}
12 {external && '↗'}
13 </a>
14 );
15}Supported base tag keys include a, blockquote, br, code, em, h1 through h6, hr, img, li, ol, p, pre, strong, and ul. With remark-gfm, you also get del, input, table, and related table tags. You can build copy buttons for code blocks, tooltip-powered abbreviations, or callouts generated from blockquotes through the same map.
Heading levels carry meaning for assistive technology, so don't render arbitrary CMS H1s inside your articles. MDN heading guidance notes that a page should generally have a single <h1> element that describes the content of the page. Map your page title to <h1> and start CMS Markdown headings at <h2> so you don't create a second conflicting top-level heading.
Keep the order logical. WCAG Technique H69 warns that skipping levels may create the impression that the structure of the document has not been properly thought through or that specific headings have been chosen for their visual rendering rather than their meaning. Screen reader users navigate by heading to determine the content of the page, so a CMS emitting an <h1> inside body content breaks that mental model. Remap heading levels in your renderer:
1const components = {
2 h1: 'h2',
3 h2: 'h3',
4 h3: 'h4',
5};Once you've handled component customization, give your code blocks the same polish the rest of your UI gets. Syntax highlighting handles that, and integrating it into react-markdown takes minimal setup.
Start with react-syntax-highlighter (v16.1.1), which supports both Prism and Highlight.js grammars. Install it alongside your engine of choice:
npm install react-syntax-highlighterReplace the default code renderer through the components prop. This pattern extracts the language from the class name and delegates block rendering to the highlighter:
1import ReactMarkdown from 'react-markdown';
2import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
3import { dark } from 'react-syntax-highlighter/dist/esm/styles/prism';
4
5function Markdown({ source }) {
6 return (
7 <ReactMarkdown
8 components={{
9 code(props) {
10 const { children, className, node, ...rest } = props;
11 const match = /language-(\w+)/.exec(className || '');
12 return match ? (
13 <SyntaxHighlighter
14 {...rest}
15 PreTag="div"
16 language={match[1]}
17 style={dark}
18 >
19 {String(children).replace(/\n$/, '')}
20 </SyntaxHighlighter>
21 ) : (
22 <code {...rest} className={className}>
23 {children}
24 </code>
25 );
26 },
27 }}
28 >
29 {source}
30 </ReactMarkdown>
31 );
32}React-markdown sets the className on code elements to language-{lang} from fenced code block info strings. Inline snippets stay untouched, preserving readability in your paragraphs.
Three engines dominate. Your choice comes down to bundle budget and whether you highlight at build time or runtime.
| Metric | Prism | highlight.js | Shiki |
|---|---|---|---|
| Bundle size | 2KB core; ~300–500 bytes per language | 272KB full; ~37KB common subset gzipped | Zero JS at runtime (build-time only) |
| Languages | 297 languages | 192 languages | 200+ languages |
| Runtime | Client-side | Client-side | Build-time (zero runtime) |
| Theme system | CSS-based themes | CSS-based themes | VS Code TextMate themes |
Prism v1.30.0 is the latest stable, with v2 in development. highlight.js sits at 11.11.1 and supports Web Workers to avoid freezing the browser on very large code blocks. Shiki uses VS Code TextMate grammars and ships zero runtime JavaScript, integrating through a rehype plugin. If you highlight static blog posts at build time, Shiki removes runtime cost entirely; for dynamic client-rendered content, Prism keeps bundles smallest.
Highlighting is compute-heavy, especially when importing every language. Keep bundles lean with PrismLight or Light builds (manual language registration) or PrismAsyncLight and LightAsync (deferred loading). The async build defers loading of refractor, about 17KB gzipped, so code displays with line numbers but without highlighting while loading.
Lazy-load the highlighter component with React.lazy so it only downloads when code blocks appear. Import languages selectively rather than pulling in the full Prism build with its hundreds of grammars. For static content, consider pre-highlighting during your build pipeline and shipping plain HTML to remove runtime costs entirely.
Basic rendering works for simple documents, but remark and rehype plugins add advanced features without leaving React's component model.
If you work with GitHub issues or READMEs, you need tables, task lists, and strikethroughs. Add the remark-gfm plugin (v4.0.1):
npm install remark-gfm1import ReactMarkdown from 'react-markdown';
2import remarkGfm from 'remark-gfm';
3
4<ReactMarkdown remarkPlugins={[remarkGfm]}>
5 {markdown}
6</ReactMarkdown>With GFM turned on, - [x] Done renders as a checkbox, pipes create HTML tables, and ~~text~~ produces strikethrough. The plugin adds autolink literals and footnotes as well. Its singleTilde option (default true) controls whether ~one~ triggers strikethrough, which works on github.com but is technically prohibited by the GFM spec. When tables overflow, wrap them in a scrollable container so your design-system styles apply exactly like any <table> element.
Content files often start with YAML frontmatter for titles, dates, or SEO data. Parse that section server-side before passing the body to the renderer, since both common tools are designed for build-time or server-side use:
1import fs from 'fs';
2import matter from 'gray-matter';
3
4const src = fs.readFileSync('blog-post.md', 'utf8');
5const { data: meta, content } = matter(src);Now meta contains title, slug, or Strapi IDs for building routes and populating <Head> tags, while content remains pure Markdown. The gray-matter parser returns { data, content, orig } directly and parses YAML by default. If you prefer to keep frontmatter inside the unified pipeline, remark-frontmatter integrates it into the AST, though it doesn't parse the data itself. Strip frontmatter server-side before sending Markdown to the client. This mirrors Strapi's separation of structure and presentation.
Technical blogs need inline formulas. Add remark-math for parsing and rehype-katex for rendering:
1import remarkMath from 'remark-math';
2import rehypeKatex from 'rehype-katex';
3
4<ReactMarkdown
5 remarkPlugins={[remarkMath]}
6 rehypePlugins={[rehypeKatex]}
7>
8 {markdown}
9</ReactMarkdown>These render math at compile time, so no client-side JavaScript is needed (KaTeX does require katex.css on the page). The official docs note that math renderers are safe if you trust KaTeX or MathJax, but a vulnerability in them can open you up to a cross-site scripting (XSS) attack.
Watch the sanitization order. Running rehype-sanitize after rehype-katex strips the complex markup KaTeX injects. The correct approach extends defaultSchema to allowlist the KaTeX classes and attributes before running the math plugin, rather than skipping sanitization. Diagrams follow a similar pattern with rehype-mermaid, which renders inline SVG by default but requires Playwright outside browsers. KaTeX and Mermaid add significant kilobytes, so load them lazily when performance matters.
Picking the right tool depends on your environment and, above all, whether you trust the content author.
| Criterion | react-markdown | MDX | markdown-it / marked |
|---|---|---|---|
| React environment | Native React elements | Compiled JSX | HTML strings |
| CMS / user content | Safe by default; add rehype-sanitize | Not suitable (executes JS) | Requires DOMPurify |
| Interactive/embedded components | Components via components prop only | Full JSX/import/export in content | No component model |
| Author profile | Non-technical CMS editors | Developer-authors | Any JS developer |
When editors write Markdown and you render it in React, react-markdown is the fit. It's safe by default, emits native React elements, and blocks javascript: and non-allowlisted protocols through defaultUrlTransform. Add rehype-sanitize when you turn on raw HTML or run plugins.
MDX is an authorable format that lets you write JSX in markdown documents, supporting import/export and JavaScript expressions in braces. It compiles to JavaScript at build time. Because arbitrary JS expressions and imports are valid MDX, it is not suitable for untrusted or user-generated content. Reach for MDX when developers author docs with embedded charts, alerts, or live components.
For any JavaScript environment outside React, markdown-it (v14.3.0) and marked output HTML strings. Both require care with untrusted input. Marked's docs say it does not sanitize output HTML and recommends filtering potentially unsafe strings for XSS attacks with DOMPurify.sanitize(marked.parse(...)).
Before shipping Markdown rendering to production, work through this list. For a broader set of optimization techniques, see this performance checklist.
rehype-sanitize after rehype-raw, in that order.defaultUrlTransform on, or narrow protocols with createUrlTransform. Block javascript: and allow only http, https, and mailto for strict compliance.new URL(), not string matching.[x](javascript:alert(1)) and <img src=x onerror=alert(1)>.React.memo and memoize the components map with useMemo.React.lazy.On memoization, the React docs are honest about when it helps: useMemo pays off when the calculation is noticeably slow or the value passes to a memo-wrapped component, and React.memo helps when a component re-renders often with identical props. If you're on the React Compiler, much of this is applied automatically. For very large documents, render only the visible portion with a windowing library like react-window. For static blog posts, pre-render at build time so the browser handles zero parsing on first paint.
The techniques above come together in a real headless CMS workflow. Combining Strapi's API-driven CMS with react-markdown lets you iterate on content and presentation independently, removing friction between content authors and developers. This workflow supports a structured content lifecycle where content moves through draft, review, and publish stages while the rendering layer evolves on its own track.
Strapi 5's Content-Type Builder offers three relevant field types: Text (Long) stores a plain string, Rich Text (Markdown) stores a plain string, and Rich Text (Blocks) stores a structured JSON array. For a react-markdown workflow, a Markdown field is the natural choice, since a Strapi tutorial notes Markdown can be easier to render on the Next.js frontend and cheaper to handle on the backend.
Design your content types following content modeling best practices so your fields stay predictable for the react-markdown renderer. In the Advanced Settings tab, set validation to match your editorial guidelines: mark the field Required, set Maximum and Minimum length, and add a Default value. These constraints catch problems before content ever reaches your renderer.
Strapi 5 flattens the REST response format, so attributes are directly accessible on the data object rather than nested under data.attributes. Documents are identified by documentId, a string, not a numeric id. Use the fields parameter to return only what you need:
1const response = await fetch(
2 'http://localhost:1337/api/articles/znrlzntu9ei5onjvwfaalu2v?fields[0]=title&fields[1]=content',
3 { headers: { Authorization: 'Bearer <token>' } }
4);
5const data = await response.json();
6// data.data.content contains the Markdown string directlyContent types are private by default, so you either grant public permissions or send an authenticated request with a Bearer token. For guidance on managing those credentials, see how to store API keys securely. If you prefer GraphQL, install @strapi/plugin-graphql and query with documentId. For a deeper look at query patterns, explore Strapi's GraphQL capabilities.
1query {
2 articles(status: PUBLISHED) {
3 documentId
4 title
5 content
6 }
7}Rich Text (Markdown) comes back as a plain string, so you pass it straight to react-markdown:
1import ReactMarkdown from 'react-markdown';
2import remarkGfm from 'remark-gfm';
3
4function ArticleBody({ content }) {
5 return (
6 <ReactMarkdown remarkPlugins={[remarkGfm]}>
7 {content}
8 </ReactMarkdown>
9 );
10}Trusted editorial content is one thing; comments and forums are another. Wherever strangers can type Markdown, you have an attack surface. React-markdown ignores raw HTML by default, but if you turn on rehype-raw for any reason, pair it with rehype-sanitize and validate content both client- and server-side. For a live preview pane, feed the same sanitization pipeline so users see exactly what gets saved. Enforce character limits and link whitelists through allowedElements and a custom urlTransform.
Strapi uploads files to public/uploads/ with relative paths like /uploads/photo.jpg by default, while cloud providers such as Amazon S3 return absolute URLs. Your frontend must handle both: detect relative paths and prepend the Strapi base URL, and leave absolute URLs unchanged.
With "Responsive friendly upload" turned on, Strapi's Media Library generates large (1000px), medium (750px), and small (500px) formats, each exposing url, width, and height. Populate media fields explicitly to return this formats metadata, then build a srcset for responsive delivery and add loading="lazy" to images outside the initial viewport. For domain control, set the img-src CSP directive to your Strapi origin and any CDN domains as browser-level enforcement.
When you pipe Markdown from Strapi into react-markdown, you avoid dangerouslySetInnerHTML, get strong defaults against XSS, and control every rendered element through the components prop. The result is content that renders as native React elements while you keep responsibility for the security layers that match your content sources.
Combining Strapi's flexible, API-first architecture with this React renderer lets your content team work independently while developers focus on presentation and features. Content and code evolve on separate tracks, which speeds up releases and removes the usual friction between authors and engineers. To see the full stack in action, the Next.js and Strapi 5 beginner's guide walks through the setup end to end, or try the Launchpad demo to explore the architecture hands-on.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.