Skip to main content

Streaming Markdown in React

A model writes Markdown one token at a time, and a chat UI usually hands the renderer the whole document again on every token. That means most of what gets rendered isn't finished Markdown yet. You'll see a fence with no closing fence, one asterisk of a **strong** pair, or a table row that stops mid cell.

@react-markdown-kit/renderer renders all of those prefixes. You don't need a streaming mode or a separate component for it.

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>
}

The behaviour described on this page links back to tests in packages/renderer/tests/streaming.test.tsx, if you want to run them 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 only has one h1). It has four finished blocks, then a GFM table and an unclosed TypeScript fence that arrive token by token. It's split into tokens the same way the tests do it, so words stay whole and each whitespace and punctuation character is its own token. That means ``` comes in as three separate tokens.

Every frame is a full parse and a full render of the prefix so far
140 / 140
Tokens received
## 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,
Rendered from the prefix

Streaming renderer

A finished paragraph with a link and code.

A finished block quote.

const finished = true
packagesize
renderer12 kB
editor30 kB
const partial = {
  answer: 42,

Press Replay from the first token. The heading, the paragraph, the quote and the closed js fence should stay put while the table and the open fence get written below them.

What holds while a document streams​

The tests stream seven cases 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 both "the finished part" and its HTML are known strings we can compare against.

No prefix throws. This is checked for all seven cases ("renders every prefix of case without throwing"), and for a document that isn't 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 gets re-laid-out, re-ordered or re-escaped.

Nothing is duplicated. At every prefix there's exactly one copy of the finished HTML, one <h1>Streaming renderer</h1> and one closed js code block ("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 matches a one-shot render. The test grows a mounted React root token by token and compares it with a root that never saw a partial prefix ("ends byte-identical to a one-shot render of case"). We compare against a separate root because that's what would catch stale memoisation. Comparing a string render with itself wouldn't.

Partial constructs degrade to the characters typed so far​

The renderer doesn't drop characters or add any. These outputs are pinned exactly ("renders source as expected").

PrefixRendered
*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>
![alt](x.p<p>![alt](x.p</p>
# head<h1>head</h1>, no trailing newline needed
- item\n- haa two-item <ul>
1. one\n2. twa two-item <ol>
> quote\n> moa <blockquote>
```ts\nconst x<pre><code class="language-ts">const x\n</code></pre>, info string already applied
Half-written constructs, rendered live on this page
Markdown (edit me)
Rendered

*em then **str then `code then [text](

~~str then ![alt](x.p then 1. one

Delete characters from the left pane and each construct falls back to its literal text, without throwing or losing anything.

Two places where rendered output changes​

Both of these are CommonMark and GFM working as specified, so we don't treat them as bugs. They're pinned by tests ("the two prefixes where already rendered output changes"), so if either one changes, a test fails. In a streaming UI they look like content rewriting itself, which is why they're called out 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").

So a paragraph isn't really settled until a line arrives that can't underline it, and the last paragraph of a stream can visibly jump to a heading. If your UI needs output that doesn't change, treat the last paragraph as provisional, or split the stream on blank lines and only freeze blocks that have one after them.

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 at a real address that's the wrong one. If links are clickable while streaming, I'd 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 one is mostly harmless, since the flip happens inside the block being written and nothing above it moves.

Why the finished part is stable​

packages/renderer/src/markdown.tsx doesn't use memo, useMemo or manual keys. Every prefix gets a full parse and a full React render.

What keeps it stable is React reconciliation. The finished blocks parse to the same nodes every time, so the tree is structurally identical and React doesn't update anything above the block being written. There's no caching involved. 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's why scroll position, focus and CSS animations on content that's already rendered don't get reset.

It also means there's no incremental parser to configure and no cache to invalidate when the document changes.

Precompiled documents​

compileMarkdown handles partial input the same way, so a server can parse each prefix and send the compiled 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 mostly helps with documents you re-render a lot, like one that's already finished in the transcript. A document that's still streaming gets parsed once per token either way.

With the AI SDK​

useChat gives you message parts that grow over time. You can render the text parts with <Markdown> and leave everything else as it is.

'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>
))
}

A couple of notes for a transcript.

  • Give each message a stable key, so React reconciles a growing message against its previous render and not against the message next to it.
  • The renderer is a server component by default and has no use client directive. The use client above is on your chat component, because useChat is a hook. If you render the finished transcript on the server, you don't need the directive at all, see Markdown in Next.js.

Against Streamdown​

Streamdown is a Markdown component built for AI chat. The table below only measures one thing, which is what a browser downloads for one import of each package. It's 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 bundledVersionMinifiedGzippedLicence
@react-markdown-kit/renderer, CommonMark0.1.0118.5 KB36.8 KBMIT
@react-markdown-kit/renderer + GFM preset0.1.0158.1 KB48.5 KBMIT
streamdown2.6.0506.0 KB152.2 KBApache-2.0

With GFM on both sides that is 48.5 KB against 152.2 KB gzipped, a difference of 103.7 KB.

Some caveats on the table:

  • It doesn't compare features. A package that ships more code probably does more with it, and each row is a single import. I'd read Streamdown's own documentation before choosing on bytes alone.
  • It doesn't measure CSS. The kit's stylesheet is optional and scoped to .rmk-document, and the JS numbers above leave out stylesheets on both sides.
  • Each row is one entry expression (recorded in the JSON next to the bytes), bundled with esbuild and gzipped. Re-run pnpm size:bundles to refresh it.

The behaviour described above for the kit all comes from one file of 59 tests, which streams seven cases prefix by prefix and pins the two prefixes where the output is expected to rewrite itself. Those tests only cover this package, so this page doesn't say anything about how Streamdown behaves while streaming.

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