The streaming-markdown library that doesn't flicker.
Incremental block parsing · Speculative inline closure · React 19 + Next.js App Router · Built-in syntax highlighting · Optional Shiki, KaTeX, Mermaid
Live demo · API · Plugin guide · Threat model
Every AI chat app has the same bug. When tokens stream in, react-markdown re-parses and re-renders the entire document on every token. At 100 tok/s that's 100 full re-renders per second — code blocks re-highlighted, tables rebuilt, lists re-measured. The result: visible flicker, dropped frames, and wasted CPU.
// ❌ The approach every AI app ships with
function Chat({ text }) {
// Re-parses ALL markdown, re-renders ALL components — per token
return <ReactMarkdown>{text}</ReactMarkdown>;
}StreamMD parses incrementally. Only the currently-streaming block re-renders. Closed blocks freeze via React.memo. Code blocks highlight once, on close. Unclosed **bo renders as bold now with data-tentative="true" — so when ** arrives there's nothing to repaint.
// ✅ StreamMD — only the active block updates
import { StreamMD } from 'stream-md';
import 'stream-md/styles.css';
function Chat({ text }) {
return <StreamMD text={text} theme="dark" />;
}Drop-in replacement. Streaming goes from janky to smooth.
npm install stream-md
# Optional adapters (lazy-loaded; install only what you use):
npm install shiki # real syntax highlighting
npm install katex # math
npm install mermaid # diagramsPeer deps: react@>=18, react-dom@>=18. Works with React 19 + Next.js App Router out of the box.
| react-markdown | streamdown | StreamMD | |
|---|---|---|---|
| Parse cost per token | Full doc | Full doc | New tokens only |
| Closed blocks re-render | ✓ every token | ✓ every token | Memoized |
| Syntax highlighting per token | Re-runs | Re-runs | Once on close |
| Speculative inline closure (no flicker) | ✗ | ✗ | ✓ |
"use client" baked in |
n/a | ✓ | ✓ |
| RSC server parser | ✗ | ✗ | ✓ (stream-md/server) |
| Plugin API | rehype/remark | ✗ | ✓ first-class |
URL sanitizer (javascript: blocked) |
requires plugin | ✓ | ✓ default |
| CSP-safe (no inline styles) | ✗ | ✗ | ✓ |
| Bundle (gz, default) | ~70 KB | ~22 KB | ~10 KB |
(Bundle numbers are approximate; verify with bundlephobia. Run the suite under bench/ to reproduce parse-cost numbers locally.)
import { StreamMD } from 'stream-md';
import 'stream-md/styles.css';
export function ChatBubble({ text }: { text: string }) {
return <StreamMD text={text} theme="dark" />;
}That's it. Pass the full accumulated text — StreamMD diffs internally.
app/page.tsx:
'use client';
import { useChat } from '@ai-sdk/react';
import { AssistantMarkdown } from 'stream-md/next';
import 'stream-md/styles.css';
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<main>
{messages.map((m) =>
m.role === 'assistant' ? (
<AssistantMarkdown key={m.id} message={m} theme="dark" />
) : (
<p key={m.id}>{m.content}</p>
),
)}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
</form>
</main>
);
}app/api/chat/route.ts:
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export const runtime = 'edge'; // works on Edge — stream-md/server is Edge-safe
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({ model: openai('gpt-4o-mini'), messages });
return result.toDataStreamResponse();
}Server-render saved messages on first paint, then upgrade to live streaming on the client:
// app/messages/[id]/page.tsx — a Server Component
import { StreamMDServer } from 'stream-md/next';
import 'stream-md/styles.css';
import { getMessage } from '@/lib/db';
export default async function MessagePage({ params }: { params: { id: string } }) {
const message = await getMessage(params.id);
return <StreamMDServer text={message.body} theme="dark" />;
}<StreamMDServer> emits pure HTML — zero client JS for static content. For live streaming use the client <StreamMD> from stream-md or stream-md/next.
Pure parser sub-path that works in Cloudflare Workers, Vercel Edge, etc.:
import { parseToBlocks } from 'stream-md/server';
export const runtime = 'edge';
export async function GET() {
const blocks = parseToBlocks('# Hello\n\n**world**\n');
return Response.json(blocks); // serializable Block[]
}For full control over rendering — useful with EventSource, custom transports, or when you don't want the React component:
import { useStreamMD } from 'stream-md';
function CustomRenderer() {
const { blocks, activeIndex, push, reset } = useStreamMD();
useEffect(() => {
const sse = new EventSource('/api/chat');
let acc = '';
sse.onmessage = (e) => {
acc += e.data;
push(acc);
};
return () => sse.close();
}, [push]);
return blocks.map((block, i) => (
<MyBlock key={block.id} block={block} active={i === activeIndex} />
));
}All extensions are lazy-loaded sub-paths — you pay zero bundle cost unless you import them.
import { StreamMD } from 'stream-md';
import { createShikiHighlighter } from 'stream-md/shiki';
const highlighter = await createShikiHighlighter({
theme: 'github-dark',
langs: ['ts', 'tsx', 'python', 'rust'],
});
<StreamMD text={text} highlighter={highlighter} />import 'katex/dist/katex.min.css';
import { StreamMD } from 'stream-md';
import { katexInlinePlugin, katexBlockPlugin } from 'stream-md/katex';
<StreamMD
text={text}
inlinePlugins={[katexInlinePlugin]}
blockPlugins={[katexBlockPlugin]}
/>Renders $E = mc^2$ inline and $$\int_0^\infty e^{-x^2}\,dx$$ as a block.
import { StreamMD } from 'stream-md';
import { mermaidBlockPlugin } from 'stream-md/mermaid';
<StreamMD text={text} blockPlugins={[mermaidBlockPlugin]} />
// Triggers on:
// ```mermaid
// graph TD; A-->B
// ```If you need full spec compliance and don't mind a larger bundle:
import { StrictStreamParser } from 'stream-md/strict';
const parser = new StrictStreamParser();
await parser.pushAsync(text);
const blocks = parser.getBlocks();Backed by micromark + GFM extensions. Useful for round-tripping authored content; the default hand-rolled parser is sufficient for LLM streaming.
import type { BlockPlugin } from 'stream-md/plugins';
import { fencedBlockPlugin } from 'stream-md/plugins';
export const csvBlock: BlockPlugin = fencedBlockPlugin({
name: 'csv',
openLine: /^```csv\s*$/,
closeLine: /^```\s*$/,
render: (block) => <CsvTable source={block.content} />,
});
<StreamMD text={text} blockPlugins={[csvBlock]} />For inline tokens (e.g. @mentions, :emoji:, hashtags):
import { delimitedInlinePlugin } from 'stream-md/plugins';
export const mention = delimitedInlinePlugin({
name: 'mention',
open: '@',
close: ' ',
tokenType: 'text',
triggers: '@',
});Three built-in presets, four extra themes, full CSS-variable control.
<StreamMD theme="dark" /> {/* default */}
<StreamMD theme="light" />
<StreamMD theme="none" /> {/* bring your own */}Named themes (separate stylesheet imports):
import 'stream-md/themes/catppuccin.css';
// then:
<div className="smd-theme-catppuccin-mocha">
<StreamMD text={text} theme="none" />
</div>Available: catppuccin-mocha, catppuccin-latte, tokyo-night, tokyo-night-storm, github-dark, github-light, solarized-dark, solarized-light.
Or write your own via custom properties:
.stream-md {
--smd-text: #e2e8f0;
--smd-heading: #ffffff;
--smd-link: #818cf8;
--smd-code-bg: rgba(0, 0, 0, 0.3);
--smd-hl-keyword: #c084fc;
--smd-hl-string: #86efac;
--smd-cursor: #818cf8;
/* see src/styles/stream-md.css for the full list */
}Replace any built-in renderer:
<StreamMD
text={text}
components={{
pre: ({ code, language, streaming }) => (
<MyCodeBlock code={code} lang={language} live={streaming} />
),
a: ({ href, children }) => <NextLink href={href}>{children}</NextLink>,
table: ({ headers, rows, alignments }) => (
<MyTable headers={headers} rows={rows} alignments={alignments} />
),
}}
/>| Prop | Type | Default | Description |
|---|---|---|---|
text |
string |
required | Current streamed markdown text |
theme |
'dark' | 'light' | 'none' |
'dark' |
Theme preset |
className |
string |
— | Additional CSS class |
components |
Partial<ComponentOverrides> |
— | Custom renderers |
onBlockComplete |
(block: Block) => void |
— | Called when a block is finalized |
limits |
Partial<Limits> |
defaults | Override doc/recursion caps |
highlighter |
HighlighterFn |
built-in | Custom syntax highlighter (e.g. Shiki) |
blockPlugins |
BlockPlugin[] |
— | Custom block types |
inlinePlugins |
InlinePlugin[] |
— | Custom inline tokens |
showCursor |
boolean |
true |
Blinking cursor on the active block |
Returns { blocks, activeIndex, incompleteLine, push, reset }.
import { parseToBlocks } from 'stream-md/server';
const blocks: Block[] = parseToBlocks(text); // JSON-serializableimport { StreamMDServer } from 'stream-md/next';
<StreamMDServer text={savedMessage} theme="dark" />import { AssistantMarkdown } from 'stream-md/next';
<AssistantMarkdown message={m} theme="dark" />Full type signatures are exported from each entry — your IDE will autocomplete.
LLM output is untrusted. StreamMD's defaults reflect that:
javascript:,vbscript:, and unsafedata:URLs are rejected by default, in links, images, and autolinks. Control characters can't smuggle a dangerous scheme.- Recursion cap on inline parser (default 4 levels)
- Document length cap (default 1 MB)
- External links:
target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer" - Tables avoid inline
styleattributes — works under strict CSP
See SECURITY.md for the full threat model.
We make three claims:
- Closed blocks don't re-render — verified by
React.memoandtests/components/StreamMD.test.tsx(snapshot stability). - Code is highlighted once, on close — verified by inspecting the DOM during streaming (active code blocks render plain
<code>). - Streaming a doc char-by-char produces the same final AST as atomic parsing — verified by a fast-check property test in
tests/parser/streaming-equivalence.test.ts. This is what makes "incremental" actually safe.
Reproduce locally:
npm run bench # tinybench atomic + streaming benchmarks
npm run test # full Vitest + property suite
npm run size # bundle budgets- import ReactMarkdown from 'react-markdown';
+ import { StreamMD } from 'stream-md';
+ import 'stream-md/styles.css';
- <ReactMarkdown>{text}</ReactMarkdown>
+ <StreamMD text={text} theme="dark" />That's it for 90% of cases. If you used remark-gfm for tables/strikethrough/task-lists, those are built in. If you used rehype-katex, swap to stream-md/katex. If you used rehype-highlight, swap to stream-md/shiki.
- v0.3: Vue, Svelte, Solid bindings (re-using
stream-md/core) - v0.3: web component (
<stream-md>) - v0.4: SSE/fetch-stream helper
- v0.4: editor mode (round-trip via contenteditable)
For plain text streaming that bypasses the DOM entirely, see ZeroJitter — canvas-based rendering with zero layout reflows.
stream-md → streaming markdown (smart DOM, incremental parsing)
zero-jitter → streaming plain text (canvas, zero reflows)
Together, they own the streaming-LLM display category.
See CONTRIBUTING.md. PRs welcome — the streaming-equivalence property test is the most important guardrail.
MIT © Jai