npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

stream-md

v0.2.0

Published

Streaming markdown renderer for LLM token streams. Incremental parsing, zero flicker, built-in syntax highlighting, first-class Next.js / RSC support.

Downloads

1,321

Readme

StreamMD

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

npm bundle types ci license

Live demo · API · Plugin guide · Threat model


Why this exists

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.


Install

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 # diagrams

Peer deps: react@>=18, react-dom@>=18. Works with React 19 + Next.js App Router out of the box.


How it compares

| | 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.)


Quickstart

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.


Next.js + Vercel AI SDK (the headline recipe)

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();
}

React Server Components (RSC)

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.


Edge runtime

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

Hook API (advanced)

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} />
  ));
}

Extensions

All extensions are lazy-loaded sub-paths — you pay zero bundle cost unless you import them.

Shiki (production-grade syntax highlighting)

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

KaTeX (math)

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.

Mermaid (diagrams)

import { StreamMD } from 'stream-md';
import { mermaidBlockPlugin } from 'stream-md/mermaid';

<StreamMD text={text} blockPlugins={[mermaidBlockPlugin]} />

// Triggers on:
// ```mermaid
// graph TD; A-->B
// ```

Strict CommonMark + GFM

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.


Plugin authoring

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: '@',
});

Theming

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

Component overrides

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} />
    ),
  }}
/>

API

<StreamMD />

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

useStreamMD(options?)

Returns { blocks, activeIndex, incompleteLine, push, reset }.

Server / RSC

import { parseToBlocks } from 'stream-md/server';

const blocks: Block[] = parseToBlocks(text); // JSON-serializable

<StreamMDServer /> (RSC)

import { StreamMDServer } from 'stream-md/next';
<StreamMDServer text={savedMessage} theme="dark" />

<AssistantMarkdown /> (Vercel AI SDK)

import { AssistantMarkdown } from 'stream-md/next';
<AssistantMarkdown message={m} theme="dark" />

Full type signatures are exported from each entry — your IDE will autocomplete.


Security

LLM output is untrusted. StreamMD's defaults reflect that:

  • javascript:, vbscript:, and unsafe data: 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 style attributes — works under strict CSP

See SECURITY.md for the full threat model.


Performance methodology

We make three claims:

  1. Closed blocks don't re-render — verified by React.memo and tests/components/StreamMD.test.tsx (snapshot stability).
  2. Code is highlighted once, on close — verified by inspecting the DOM during streaming (active code blocks render plain <code>).
  3. 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

Migration from react-markdown

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


Roadmap

  • 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)

Companion: ZeroJitter

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.


Contributing

See CONTRIBUTING.md. PRs welcome — the streaming-equivalence property test is the most important guardrail.

License

MIT © Jai