Streaming Markdown in React
A model writes Markdown one token at a time. The chat UI hands the renderer the
whole document again on every token, so most of what it renders is not valid
Markdown yet: a fence with no closing fence, one asterisk of a **strong**
pair, a table row that stops mid cell.
@react-markdown-kit/renderer renders every one of those prefixes. There is no
streaming mode and no separate component.
import Markdown, { defineMarkdownPreset, gfm } from '@react-markdown-kit/renderer'
const preset = defineMarkdownPreset({ extensions: [gfm()] })
export function Answer({ text }: { text: string }) {
// `text` grows on every token. Nothing else changes.
return <Markdown preset={preset}>{text}</Markdown>
}
Every claim on this page links to a test in
packages/renderer/tests/streaming.test.tsx.
Run it yourself:
pnpm vitest run packages/renderer/tests/streaming.test.tsx
Watch it stream
The document below is the one the tests use, with its top heading demoted to
## so this page keeps one h1: four finished blocks, then a GFM table and an
unclosed TypeScript fence arriving token by token. The split is
the same one the tests use, so words stay whole and every whitespace and
punctuation character is its own token, which makes ``` arrive as
three separate tokens.
## Streaming renderer
A finished paragraph with a [link](https://example.com) and `code`.
> A finished block quote.
```js
const finished = true
```
| package | size |
| --- | ---: |
| renderer | 12 kB |
| editor | 30 kB |
```ts
const partial = {
answer: 42,
Streaming renderer
A finished paragraph with a link and code.
A finished block quote.
const finished = true
| package | size |
|---|---|
| renderer | 12 kB |
| editor | 30 kB |
const partial = {
answer: 42,
Press Replay from the first token. Watch the heading, the paragraph, the
quote and the closed js fence stay exactly where they are while the table
and the open fence are written below them.
What holds while a document streams
Seven cases are streamed prefix by prefix: an unclosed code fence, half-written emphasis and strong, a table mid-row, a list mid-item, a heading with no trailing newline, a link with an unclosed bracket, and a fenced block that closes late. Each case is a fixed set of finished blocks plus that tail, so "the finished part" is a known string and its HTML is a known string.
No prefix throws. Asserted for all seven cases ("renders every prefix of case without throwing"), and for a document that is never well formed at any prefix, a fence inside a list inside a block quote ("renders every prefix of nested unclosed constructs without throwing").
The finished part is byte-identical. At every prefix, the HTML starts with exactly the HTML of the finished blocks ("keeps the closed prefix byte-identical while case streams"). Nothing above the block being written is re-laid-out, re-ordered or re-escaped.
Nothing is duplicated. Exactly one copy of the finished HTML, one
<h1>Streaming renderer</h1> and one closed js code block at every prefix
("never duplicates a closed node while case streams").
An open fence never leaks as markup. While a ```md fence is open,
# not a heading and - not a list never render as <h1> or <li>
("does not leak the contents of an open fence as markup"),
and the paragraph after a late fence appears only once the closing fence
arrives (test).
The end state is the one-shot render. A mounted React root is grown token by token and compared with a root that never saw a partial prefix ("ends byte-identical to a one-shot render of case"). That comparison is what would catch stale memoisation; comparing a string render with itself would not.
Partial constructs degrade to the characters typed so far
Nothing is dropped and nothing is invented. These outputs are pinned exactly ("renders source as expected").
| Prefix | Rendered |
|---|---|
*em | <p>*em</p> |
**str | <p>**str</p> |
_u | <p>_u</p> |
~~str | <p>~~str</p> |
`code | <p>`code</p> |
[text | <p>[text</p> |
[text]( | <p>[text](</p> |
 so a change in either is a test failure rather than a surprise. A streaming UI sees them as content that rewrites itself, so they are listed here.
A setext underline rewrites a finished paragraph
Paragraph text renders <p>Paragraph text</p>. One token later,
Paragraph text\n- renders <h2>Paragraph text</h2>
("turns a finished paragraph into a heading when the next line starts a setext underline").
A paragraph is therefore not settled until a line arrives that cannot underline it. The trailing paragraph of a stream can visibly jump to a heading. If your UI needs frozen output, treat the last paragraph as provisional, or split the stream on blank lines and freeze only blocks that are followed by one.
A half-typed URL links to the truncated host
http://ex renders <p><a href="http://ex">http://ex</a></p>, and
http://example.com/pa links to that partial path
("links a half-typed URL to the truncated host while it streams").
A link rendered mid-stream can point somewhere real but wrong. If anchors are clickable during streaming, disable the last one until its block closes.
Milder: a table is a paragraph until its delimiter row is complete
| a | b |\n| - renders <p>| a | b |\n| -</p>, then flips to a <table>
once | - | - | lands
("shows a table as a paragraph until the delimiter row is complete").
This is not a defect: the flip happens inside the block being written, and
nothing above it moves.
Why the finished part is stable
packages/renderer/src/markdown.tsx
contains no memo, no useMemo and no manual keys. Every prefix is a full
parse and a full React render.
Stability comes from React reconciliation over a structurally identical tree,
not from caching: the finished blocks parse to the same nodes every time, so
React updates nothing above the block being written. The test
"reuses the heading element across growth instead of recreating it"
holds a reference to the <h1> element and asserts the same element instance
survives every growth step. That is what keeps scroll position, focus and CSS
animations on already-rendered content intact.
The practical consequence: there is no incremental-parsing machinery to configure, and no cache to invalidate when the document changes.
Precompiled documents
compileMarkdown is equally tolerant, so a server can parse each prefix and
send the document instead of the string.
import Markdown, { compileMarkdown, defineMarkdownPreset, gfm } from '@react-markdown-kit/renderer'
const preset = defineMarkdownPreset({ extensions: [gfm()] })
// Accepts every prefix; content problems become diagnostics on the document,
// not exceptions. Only a configuration mistake throws.
const document = compileMarkdown(partialText, { preset })
<Markdown preset={preset} document={document} />
- Every prefix compiles without throwing (test).
- A precompiled prefix renders exactly like the same prefix as a string (test).
- The finished part stays byte-identical for precompiled documents too (test).
- A mounted root fed a precompiled document that grows token by token matches a
one-shot root at every step, ending with exactly one
<h1>and one<table>(test).
Re-rendering from a compiled document is about 2.8 times faster than
re-rendering from a string, because parsing is about two thirds of the work
(benchmark results,
benchmarks/run.mjs). That
payoff applies to a document you re-render often, such as one already finished
in the transcript. A document still streaming is parsed once per token either
way.
With the AI SDK
useChat hands you message parts that grow. Render the text parts with
<Markdown> and change nothing else.
'use client'
import { useChat } from '@ai-sdk/react'
import Markdown, { defineMarkdownPreset, gfm } from '@react-markdown-kit/renderer'
const preset = defineMarkdownPreset({ extensions: [gfm()] })
export function Transcript() {
const { messages } = useChat()
return messages.map((message) => (
<article key={message.id} className="rmk-document">
{message.parts.map((part, index) =>
part.type === 'text' ? (
<Markdown preset={preset} key={index}>
{part.text}
</Markdown>
) : null,
)}
</article>
))
}
Two notes for a transcript.
- Give each message a stable key. React then reconciles a growing message against itself rather than against its neighbour.
- The renderer is a server component by default, with no
use clientdirective. Theuse clientabove is on your chat component, becauseuseChatis a hook. Rendering the finished transcript on the server needs no directive at all: Markdown in Next.js.
Against Streamdown
Streamdown is a Markdown component
built for AI chat. The table below is one measurement only: what a browser
downloads for one import of each package, generated by
scripts/compare-bundles.mjs
and stored in
docs/data/bundle-sizes.json,
measured on 2026-09-20. React and React DOM are external in every
row. 1 KB is 1024 bytes.
| Entry bundled | Version | Minified | Gzipped | Licence |
|---|---|---|---|---|
| @react-markdown-kit/renderer, CommonMark | 0.1.0 | 118.5 KB | 36.8 KB | MIT |
| @react-markdown-kit/renderer + GFM preset | 0.1.0 | 158.1 KB | 48.5 KB | MIT |
| streamdown | 2.6.0 | 506.0 KB | 152.2 KB | Apache-2.0 |
With GFM on both sides that is 48.5 KB against 152.2 KB gzipped, a difference of 103.7 KB.
What the table does not say:
- It does not compare features. A package that ships more code does more with it, and this row is one import, not a feature list. Read Streamdown's own documentation before choosing on bytes.
- It does not measure CSS. The kit's stylesheet is optional and scoped to
.rmk-document; the JS numbers above exclude stylesheets on both sides. - It is one entry expression per row, recorded in the JSON next to the bytes,
bundled with esbuild and gzipped. Re-run
pnpm size:bundlesto refresh it.
What this page does claim about the kit is tested: every behaviour above comes from one file of 59 tests, which streams seven cases prefix by prefix and pins the two prefixes where the output legitimately rewrites itself. Nothing here describes how another package behaves, because these tests cover ours and not theirs.
FAQ
- Can React Markdown Kit render a half-written Markdown document?
- Yes. Every prefix of a document renders without throwing, including an unclosed code fence, half-written emphasis, a table stopped mid row, a list stopped mid item and a link with an unclosed bracket. The seven cases are asserted prefix by prefix in packages/renderer/tests/streaming.test.tsx.
- Does already rendered content move while more tokens arrive?
- The HTML of the finished blocks is byte-identical at every later prefix, so nothing above the block being written is re-ordered or re-escaped. Two constructs are exceptions by the CommonMark and GFM rules: a paragraph followed by a setext underline becomes a heading, and a half-typed URL autolinks to the truncated host.
- Do I need a special streaming component or an incremental parser?
- No. The renderer re-parses and re-renders the whole prefix on every token. packages/renderer/src/markdown.tsx contains no memo, no useMemo and no manual keys; React reconciliation keeps the finished DOM nodes in place, which a test asserts by checking the same h1 element survives every growth step.
- Can I stream into a precompiled document?
- Yes. compileMarkdown accepts every prefix, htmlFromDocument(prefix) equals the same prefix rendered from a string, and a mounted root fed a growing precompiled document ends byte-identical to a root that only ever saw the finished document.
Next
Try prefixes in the renderer demo · @react-markdown-kit/renderer on npm · The streaming tests
React Markdown renderer · How to render Markdown in React · Markdown in Next.js · Compiling documents · Migrate from react-markdown