Native Markdown parsing and rendering for React Native, powered by md4c (C++) through Nitro Modules (JSI).
- Native C++ parser -- synchronous parsing via JSI with minimal JS thread overhead
- Full rendering pipeline -- parser + renderer + streaming session in one package
- GFM support -- tables, strikethrough, task lists, autolinks
- Math rendering -- inline and block LaTeX via
react-native-mathjax-svg(optional) - Opt-in HTML AST nodes -- preserve raw HTML as
html_inline/html_blockfor custom renderers - Large document support -- top-level block virtualization and cached renderer styles
- Code highlighting -- built-in JS/TS, Python, and Bash tokenizer with custom highlighter support
- Headless API -- parse markdown and extract text without any UI
- Streaming -- incremental rendering for chat/LLM token streams
- Customizable -- themes, per-node style overrides, custom renderers, AST transforms, plugin pipeline
| Dependency | Version |
|---|---|
| React Native | >=0.75.0 |
| react-native-nitro-modules | >=0.35.4 |
| react-native-mathjax-svg (optional) | >=0.9.0 |
| react-native-svg (optional, for math) | >=13.0.0 |
With npm:
npm install react-native-nitro-markdown react-native-nitro-modules
cd ios && pod installWith Bun:
bun add react-native-nitro-markdown react-native-nitro-modules
cd ios && pod installFor math rendering:
npm install react-native-mathjax-svg react-native-svgExpo (development build):
npx expo install react-native-nitro-markdown react-native-nitro-modules
npx expo prebuildimport { Markdown } from "react-native-nitro-markdown";
export function Example() {
return (
<Markdown options={{ gfm: true }}>
{"# Hello\nThis is **native** markdown."}
</Markdown>
);
}ParserOptions controls native parser extensions. Defaults are conservative for HTML and feature-complete for Markdown extensions.
| Option | Default | Enables | Notes |
|---|---|---|---|
gfm |
true |
Tables, strikethrough, task lists, permissive autolinks | Set false for stricter CommonMark-style parsing |
math |
true |
Inline and display LaTeX spans | Rendering falls back to plain styled text unless optional math deps are installed |
html |
false |
html_inline and html_block AST nodes |
Raw HTML is not rendered by default; provide custom renderers |
<Markdown options={{ gfm: true, math: true, html: false }}>
{content}
</Markdown>The main component. Parses a markdown string and renders it.
import { Markdown } from "react-native-nitro-markdown";| Prop | Type | Default | Description |
|---|---|---|---|
children |
string |
required | Markdown input string |
options |
ParserOptions |
-- | Parser flags (gfm, math, html) |
plugins |
MarkdownPlugin[] |
-- | Plugin hooks (beforeParse, afterParse) |
sourceAst |
MarkdownNode |
-- | Pre-parsed AST; skips native parse when provided |
astTransform |
AstTransform |
-- | Post-parse AST rewrite before render |
renderers |
CustomRenderers |
{} |
Per-node custom renderer overrides |
theme |
PartialMarkdownTheme |
defaultMarkdownTheme |
Theme token overrides |
styles |
NodeStyleOverrides |
-- | Per-node style overrides |
stylingStrategy |
"opinionated" | "minimal" |
"opinionated" |
Base styling preset |
style |
StyleProp<ViewStyle> |
-- | Container style |
onLinkPress |
LinkPressHandler |
-- | Intercept link presses; return false to block default open |
onParsingInProgress |
() => void |
-- | Called when parse inputs change |
onParseComplete |
(result) => void |
-- | Called with { raw, ast, text } after parse |
onError |
(error, phase, pluginName?) => void |
-- | Error handler for parse/plugin failures |
highlightCode |
boolean | CodeHighlighter |
-- | Enable syntax highlighting for code blocks |
tableOptions |
{ minColumnWidth?; measurementStabilizeMs? } |
-- | Table layout tuning |
virtualize |
boolean | "auto" |
false |
Top-level block virtualization |
virtualizationMinBlocks |
number |
40 |
Block threshold for "auto" virtualization |
virtualization |
MarkdownVirtualizationOptions |
-- | FlatList tuning (windowSize, batching, etc.) |
Pipeline order: beforeParse plugins (by priority desc) -> parse/sourceAst -> afterParse plugins (by priority desc) -> astTransform -> render.
Renders markdown from a streaming session. Extends MarkdownProps (minus children).
import { MarkdownStream } from "react-native-nitro-markdown";| Prop | Type | Default | Description |
|---|---|---|---|
session |
MarkdownSession |
required | Session supplying streamed text |
updateIntervalMs |
number |
50 |
Flush interval for "interval" strategy |
updateStrategy |
"interval" | "raf" |
"interval" |
Update cadence |
useTransitionUpdates |
boolean |
false |
Wrap updates in startTransition |
incrementalParsing |
boolean |
true |
Append-optimized incremental AST updates |
A native text buffer with change listeners, used for streaming.
import { createMarkdownSession } from "react-native-nitro-markdown";
const session = createMarkdownSession();
session.append("# Hello\n");
session.append("Streaming content...");| Method | Signature | Description |
|---|---|---|
append |
(chunk: string) => number |
Append text, returns new UTF-16 length |
clear |
() => void |
Clear buffer, emit reset event |
reset |
(text: string) => void |
Replace full buffer content |
replace |
(from, to, text) => number |
Partial buffer mutation |
getAllText |
() => string |
Get full session text |
getLength |
() => number |
Get UTF-16 length without copy |
getTextRange |
(from, to) => string |
Get substring range |
addListener |
(listener) => () => void |
Subscribe to mutation events; returns unsubscribe |
highlightPosition |
number |
Mutable cursor for stream highlight |
Creates and owns a MarkdownSession for a component lifecycle.
import { useMarkdownSession } from "react-native-nitro-markdown";
const { getSession, isStreaming, setIsStreaming, stop, clear, setHighlight } =
useMarkdownSession();Extends useMarkdownSession with timeline sync for timed playback.
import { useStream } from "react-native-nitro-markdown";
const stream = useStream({ 0: 0, 1: 500, 2: 1000 });
stream.sync(currentTimeMs);
<MarkdownStream session={stream.getSession()} />;Parse markdown without any UI. Available from both entry points.
import {
parseMarkdown,
parseMarkdownWithOptions,
extractPlainText,
getTextContent,
getFlattenedText,
stripSourceOffsets,
} from "react-native-nitro-markdown/headless";| Function | Description |
|---|---|
parseMarkdown(text, options?) |
Parse to AST (options: { gfm?, math?, html? }) |
parseMarkdownWithOptions(text, options) |
Parse with explicit options |
extractPlainText(text) |
Parse and return plain text from native parser |
extractPlainTextWithOptions(text, options) |
Same with parser flags |
getTextContent(node) |
Concatenate text recursively (no normalization) |
getFlattenedText(node) |
Normalized plain text with block separators |
stripSourceOffsets(node) |
Remove beg/end fields from AST |
Override rendering for specific node types:
import {
Markdown,
type HeadingRendererProps,
type CodeBlockRendererProps,
} from "react-native-nitro-markdown";
<Markdown
renderers={{
heading: ({ level, children }: HeadingRendererProps) => (
<MyHeading level={level}>{children}</MyHeading>
),
code_block: ({ language, content }: CodeBlockRendererProps) => (
<MyCode language={language} content={content} />
),
}}
>
{content}
</Markdown>;Renderers receive EnhancedRendererProps with node, children, and Renderer (for recursive rendering). Node-specific props are mapped automatically:
| Node type | Extra props |
|---|---|
heading |
level |
link |
href, title |
image |
url, alt, title |
code_block |
content, language |
code_inline |
content |
list |
ordered, start |
task_list_item |
checked |
html_inline, html_block |
Read raw HTML from node.content |
Return undefined to fall back to the built-in renderer, or null to render nothing.
HTML parsing is opt-in and produces raw AST nodes. The built-in renderer intentionally renders nothing for html_inline and html_block; applications decide what is safe to display.
import { Text, View } from "react-native";
import { Markdown } from "react-native-nitro-markdown";
<Markdown
options={{ html: true }}
renderers={{
html_inline: ({ node }) => {
if (node.content === "<br>") return <Text>{"\n"}</Text>;
return null;
},
html_block: ({ node }) => {
if (!node.content?.includes('data-kind="release-note"')) return null;
return (
<View>
<Text>Release note</Text>
</View>
);
},
}}
>
{content}
</Markdown>;Do not pass untrusted HTML directly into a WebView from a renderer. Sanitize or map known tags/attributes to native components.
import {
Markdown,
defaultMarkdownTheme,
minimalMarkdownTheme,
mergeThemes,
} from "react-native-nitro-markdown";
const theme = mergeThemes(defaultMarkdownTheme, {
colors: { text: "#0f172a", link: "#1d4ed8" },
fontSizes: { m: 16 },
});
<Markdown theme={theme}>{content}</Markdown>;MarkdownTheme includes:
- colors --
text,heading,link,code,codeBackground,blockquote,border,surface, table colors, and optionalcodeTokenColorsfor syntax highlighting - spacing --
xs,s,m,l,xl - fontSizes --
xsthroughxl, plush1-h6 - fontFamilies --
regular,heading,mono - borderRadius --
s,m,l - headingWeight -- optional font weight override
- showCodeLanguage -- show/hide language label on code blocks
Use stylingStrategy="minimal" for a bare baseline, or NodeStyleOverrides for per-node style overrides (text nodes accept TextStyle, container nodes accept ViewStyle).
import { useEffect } from "react";
import {
MarkdownStream,
useMarkdownSession,
} from "react-native-nitro-markdown";
export function StreamingExample() {
const session = useMarkdownSession();
useEffect(() => {
const s = session.getSession();
s.append("# Streaming\n");
s.append("This text arrives in chunks.");
return () => session.clear();
}, [session]);
return (
<MarkdownStream
session={session.getSession()}
options={{ gfm: true }}
updateStrategy="raf"
/>
);
}import { useCallback } from "react";
import { Markdown, type AstTransform } from "react-native-nitro-markdown";
const transform = useCallback<AstTransform>((ast) => {
const visit = (node: Parameters<AstTransform>[0]): typeof node => ({
...node,
content:
node.type === "text"
? (node.content ?? "").replace(/:wink:/g, "π")
: node.content,
children: node.children?.map(visit),
});
return visit(ast);
}, []);
<Markdown astTransform={transform}>{"Hello :wink:"}</Markdown>;import { Markdown, type MarkdownPlugin } from "react-native-nitro-markdown";
const plugins: MarkdownPlugin[] = [
{
name: "rewrite-before-parse",
priority: 10,
beforeParse: (input) => input.replace(/:rocket:/g, "ROCKET_TOKEN"),
},
{
name: "rewrite-after-parse",
afterParse: (ast) => {
const visit = (node: typeof ast): typeof ast => ({
...node,
content:
node.type === "text"
? (node.content ?? "").replace(/ROCKET_TOKEN/g, "π")
: node.content,
children: node.children?.map(visit),
});
return visit(ast);
},
},
];
<Markdown plugins={plugins}>{"Launch :rocket:"}</Markdown>;import { Markdown, parseMarkdownWithOptions } from "react-native-nitro-markdown";
const ast = parseMarkdownWithOptions(content, {
gfm: true,
math: true,
html: false,
});
<Markdown sourceAst={ast}>{"ignored when sourceAst is provided"}</Markdown>;<Markdown
virtualize="auto"
virtualizationMinBlocks={30}
virtualization={{
initialNumToRender: 10,
maxToRenderPerBatch: 10,
windowSize: 8,
}}
>
{content}
</Markdown>Keep Markdown as the primary vertical scroller when virtualization is enabled -- avoid nesting inside another ScrollView.
import type { CodeHighlighter } from "react-native-nitro-markdown";
// Built-in highlighter (JS/TS, Python, Bash)
<Markdown highlightCode>{"```typescript\nconst x: number = 42;\n```"}</Markdown>
// Custom highlighter
const myHighlighter: CodeHighlighter = (language, code) => {
return [{ text: code, type: "default" }];
};
<Markdown highlightCode={myHighlighter}>{content}</Markdown><Markdown
onLinkPress={(href) => {
if (href.startsWith("/")) {
router.push(href);
return false;
}
}}
>
{content}
</Markdown>Default link behavior: trims href, calls onLinkPress, validates scheme (http:, https:, mailto:, tel:, sms:), then opens via Linking.openURL. Relative URLs and anchors are ignored unless handled in onLinkPress.
document, heading, paragraph, text, bold, italic, strikethrough, link, image, code_inline, code_block, blockquote, horizontal_rule, line_break, soft_break, table, table_head, table_body, table_row, table_cell, list, list_item, task_list_item, math_inline, math_block, html_block, html_inline
html_inline and html_block are parsed only when options.html is true; they are not rendered by default. Use a custom renderer to handle them, and read raw HTML from node.content.
Components, hooks, sessions, themes, built-in renderers, syntax highlighting, and all headless APIs.
Parser and text utilities only -- no React dependencies. Use this for server-side processing, search indexing, or custom renderers.
- Use
updateStrategy="raf"for streaming UI updates. - Batch
session.append()calls at 50-100ms intervals rather than per-character. - Enable
virtualize="auto"for long documents and keepMarkdownas the primary vertical scroller. - Memoize custom
plugins,renderers,theme, andstylesobjects when they are created inside a component. - Use the headless API when you only need parsing, plain text extraction, search indexing, or a custom renderer.
| Problem | Solution |
|---|---|
| Math renders as code-style fallback | Install react-native-mathjax-svg and react-native-svg |
| iOS build fails after install | Run pod install in your iOS directory |
| Expo app doesn't load native module | Use a development build (expo prebuild + expo run), not Expo Go |
| Android heading font weight looks wrong | Set theme.headingWeight explicitly |
The apps/example directory contains a full demo app with these screens:
| Screen | File | Demonstrates |
|---|---|---|
| Bench | app/index.tsx |
Smoke tests + benchmark vs JS parsers |
| Default | app/render-default.tsx |
Built-in renderer defaults |
| Styles | app/render-default-styles.tsx |
Theme and style overrides |
| Custom | app/render-custom.tsx |
HTML AST rendering, custom renderers, AST transform |
| Stream | app/render-stream.tsx |
Live streaming with token append |
See CONTRIBUTING.md.
MIT
