Skip to main content
Answers to frequently asked questions about using Streamdown in your projects.

General

Streamdown is specifically designed for AI-powered streaming applications. Key differences:
  • Incomplete Markdown handling — Streamdown integrates the remend preprocessor to complete unclosed bold, italic, code, and link markers in real-time. react-markdown has no equivalent capability.
  • Block-level memoization — Streamdown splits content into blocks and memoizes each one. Only the last block is re-rendered as new tokens arrive, making it far more efficient for long streaming responses.
  • Stable block keys — React keys are index-based, so already-completed blocks never unmount and remount.
  • Security defaultsrehype-sanitize and rehype-harden are applied out of the box.
  • Drop-in compatible — The components prop API is identical to react-markdown, so migration requires minimal changes.
Yes. Streamdown accepts a components prop with the same shape as react-markdown. You can override any Markdown element:
<Streamdown
  components={{
    h1: ({ children }) => <h1 className="text-3xl font-bold">{children}</h1>,
    a: ({ href, children }) => (
      <a href={href} target="_blank" rel="noreferrer">{children}</a>
    ),
  }}
>
  {markdown}
</Streamdown>
Streamdown supports both remarkPlugins and rehypePlugins props and ships with remarkGfm enabled by default. Most plugins written for react-markdown work without modification.Streamdown also provides first-party plugins for common use cases:
  • Syntax highlighting@streamdown/code (Shiki)
  • Math@streamdown/math (KaTeX)
  • Diagrams@streamdown/mermaid
  • CJK@streamdown/cjk
These are passed via the plugins prop rather than remarkPlugins/rehypePlugins.

Setup

Tailwind needs to scan Streamdown’s distribution files to include the utility classes it uses.Tailwind v4 — add a @source directive to your globals.css:
globals.css
@source "../node_modules/streamdown/dist/*.js";
If you use optional plugins, add a matching line for each one you’ve installed:
globals.css
@source "../node_modules/@streamdown/code/dist/*.js";
@source "../node_modules/@streamdown/math/dist/*.js";
Tailwind v3 — add Streamdown to the content array in tailwind.config.js:
tailwind.config.js
content: [
  // ... your other content paths
  "./node_modules/streamdown/dist/*.js",
]
Adjust paths based on your project structure — the relative path from your config file to node_modules may differ.
In a monorepo, the node_modules directory for a workspace package may be hoisted to the repository root. Adjust the Tailwind @source (or content) path to point to the hoisted location:
globals.css
/* If node_modules is at the repo root and your app is in apps/web/ */
@source "../../../node_modules/streamdown/dist/*.js";
If you use pnpm with shamefully-hoist: false, each package has its own node_modules, so the path relative to your CSS entry file stays the same as a standard project.
Streamdown’s default components are built on the shadcn/ui design system and rely on CSS custom properties. Without these variables, components may render with missing backgrounds, incorrect borders, or broken spacing.If you already use shadcn/ui, these variables are set up automatically. Otherwise, add the following minimal set to your global CSS:
globals.css
:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --radius: 0.625rem;
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --card-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --border: oklch(0.269 0 0);
  --input: oklch(0.269 0 0);
  --primary: oklch(0.985 0 0);
  --primary-foreground: oklch(0.205 0 0);
  --radius: 0.625rem;
}
Use the shadcn/ui theme generator to create a custom palette.

Streaming

Streamdown supports two rendering modes controlled by the mode prop:
  • streaming (default) — Content is split into blocks and rendered incrementally. Remend completes incomplete syntax on every update. React Transitions are used to avoid blocking the UI during rapid updates.
  • static — The full content is rendered as a single Markdown document without block splitting or remend preprocessing. Use this for already-complete content that does not change.
{/* Streaming — incomplete syntax is healed */}
<Streamdown mode="streaming" isAnimating={isLoading}>
  {streamingText}
</Streamdown>

{/* Static — content is already complete */}
<Streamdown mode="static">
  {completedText}
</Streamdown>
When parseIncompleteMarkdown is enabled (the default), Streamdown calls remend on the raw Markdown string before any parsing occurs. Remend counts opening and closing markers and appends missing closing syntax — for example, **bold text becomes **bold text**.This preprocessing runs on the string level, before the unified/remark pipeline builds its AST. After remend runs, the completed string is split into blocks with parseMarkdownIntoBlocks and each block is rendered by a memoized Block component.You can configure which completions are active via the remend prop:
<Streamdown remend={{ inlineKatex: true, links: false }}>
  {markdown}
</Streamdown>
You can also disable the feature entirely:
<Streamdown parseIncompleteMarkdown={false}>
  {markdown}
</Streamdown>
Content flicker is usually caused by unstable block keys. Streamdown generates keys from the block index (${id}-${index}), so keys are stable as long as the number of blocks doesn’t change. When a new paragraph starts, a new block key is created, which is expected behavior.If you see unexpected remounting of already-completed blocks:
  1. Check that you are not passing a new components object on every render — use useMemo or define it outside the component.
  2. Check that rehypePlugins and remarkPlugins references are stable.
  3. Check that you are not passing an inline object to animated on every render — Streamdown handles this, but unnecessary re-renders in the parent can cause it.

Plugins

Install the @streamdown/code plugin and pass it via the plugins prop:
npm i @streamdown/code shiki
import { Streamdown } from 'streamdown';
import { createCodePlugin } from '@streamdown/code';

const codePlugin = createCodePlugin();

export default function Page() {
  return (
    <Streamdown plugins={{ code: codePlugin }}>
      {markdown}
    </Streamdown>
  );
}
Add the Tailwind @source path for the plugin to your globals.css.
Install the @streamdown/math plugin:
npm i @streamdown/math
import { Streamdown } from 'streamdown';
import { createMathPlugin } from '@streamdown/math';

const mathPlugin = createMathPlugin();

export default function Page() {
  return (
    <Streamdown plugins={{ math: mathPlugin }}>
      {markdown}
    </Streamdown>
  );
}
KaTeX CSS must also be available in your app. Import katex/dist/katex.min.css in your global stylesheet or app entry point.
Install the @streamdown/mermaid plugin:
npm i @streamdown/mermaid
import { Streamdown } from 'streamdown';
import { createMermaidPlugin } from '@streamdown/mermaid';

const mermaidPlugin = createMermaidPlugin();

export default function Page() {
  return (
    <Streamdown plugins={{ mermaid: mermaidPlugin }}>
      {markdown}
    </Streamdown>
  );
}

Performance

Streamdown memoizes rendering at two levels:
  1. Streamdown component — memoized with a custom comparator that does shallow comparison on all props. It only re-renders when children, isAnimating, mode, or other relevant props change.
  2. Block component — each block is individually memoized. Only blocks whose content, isIncomplete, or dir props change are re-rendered. This means completed blocks stay frozen in the React tree while only the last (actively streaming) block updates.
Stable references for components, rehypePlugins, and remarkPlugins are important. Define them outside your component or use useMemo:
// Good — stable reference
const components = useMemo(() => ({ h1: MyH1 }), []);

// Bad — new object on every render
<Streamdown components={{ h1: MyH1 }}>
Streamdown assigns each parsed Markdown block a stable React key derived from its index: ${generatedId}-${index}. Using the index rather than a content hash means a block’s identity doesn’t change as its content grows during streaming.If a content hash were used as the key, React would unmount and remount the entire block every time a new token arrived — discarding focus state, scroll position, and animation state. Index-based keys ensure React treats each update as a prop change to an existing component rather than a replacement.

Security

Streamdown applies rehype-sanitize and rehype-harden by default as part of its rehype plugin pipeline. rehype-sanitize enforces an allowlist of HTML tags and attributes, preventing script injection. rehype-harden applies additional protections against unsafe link protocols and data URIs.This is enabled automatically — you don’t need to configure anything for basic safety.
Use the allowedTags prop to extend the sanitization allowlist:
<Streamdown
  allowedTags={{
    mention: ['user_id'],
    highlight: [],
  }}
>
  {markdown}
</Streamdown>
Each key is a tag name and its value is an array of permitted attributes. Tags not listed in allowedTags are stripped during sanitization.
Only add tags you trust. Custom tags with href or src attributes can introduce security risks if their values come from untrusted user content.
Yes. Pass a custom rehypePlugins array to replace the defaults:
import { Streamdown } from 'streamdown';
import rehypeSanitize from 'rehype-sanitize';
import { myCustomSchema } from './schema';

<Streamdown
  rehypePlugins={[
    rehypeRaw,
    [rehypeSanitize, myCustomSchema],
    harden,
  ]}
>
  {markdown}
</Streamdown>
When you provide a custom rehypePlugins array, the allowedTags prop no longer extends the schema — you are responsible for the full sanitization configuration.

Error scenarios

This warning occurs when Next.js tries to treat Shiki as an external package. Install Shiki explicitly and add it to transpilePackages in next.config.ts:
next.config.ts
export default {
  // ... other config
  transpilePackages: ['shiki'],
};
When using Streamdown with Vite and server-side rendering, you may see a TypeError [ERR_UNKNOWN_FILE_EXTENSION] error for CSS files such as katex.min.css. Add Streamdown to ssr.noExternal in vite.config.ts:
vite.config.ts
export default {
  // ... other config
  ssr: {
    noExternal: ['streamdown'],
  },
};
This prevents Vite from treating Streamdown as an external module during SSR, ensuring CSS files are processed correctly.
This error occurs when using the Mermaid plugin with Next.js or Turbopack. Mermaid’s dependency tree includes Node.js-only packages. Configure Next.js to exclude them from client-side bundling:
next.config.js
export default {
  serverComponentsExternalPackages: ['langium', '@mermaid-js/parser'],

  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.resolve.alias = {
        ...config.resolve.alias,
        'vscode-jsonrpc': false,
        'langium': false,
      };
    }
    return config;
  },
};
This is an upstream issue in the Mermaid repository.

Build docs developers (and LLMs) love