はじめに
ChatGPT風のチャットUIを作ったことがある人なら、この苦しみを知っているはずです。
AIの応答をリアルタイムでストリーミング表示しようとすると、Markdownが途中で途切れて崩れる。コードブロックが閉じていない状態で表示されて見た目がぐちゃぐちゃ。リストの途中で改行されて変なレイアウトになる。これ、意外と根深い問題なんですよね。
react-markdownを使っていた自分も、ストリーミング中の表示崩れには何度も悩まされました。「完全に受信してからレンダリングすれば良いじゃん」という話もありますが、それだとユーザー体験が悪い。やっぱりリアルタイムで文字が流れてくるあの体験が欲しいわけです。
そんな中で登場したのがStreamdownです。Vercelが開発した、AIストリーミングに特化したMarkdownレンダラーで、公開からわずか4ヶ月でGitHubスター3,400超え。これ、同じ悩みを持っていた人が世界中にいたということなんでしょうね。
とはいえ、説明を読むより実際に触ってみた方が早いと思います。閉じていない太字やコードブロックを含むMarkdownがどう描画されるか、その場で試せるサンプルを用意したので、先に挙動を見たい方はこちらからどうぞ。
Streamdownとは
Streamdownは、react-markdownのドロップイン置き換えとして設計されたReactコンポーネントです。「A drop-in replacement for react-markdown, designed for AI-powered streaming」というのが公式のキャッチコピー。
要するに、AIがMarkdown形式で応答を返してくるシナリオ、特にストリーミングでちょっとずつテキストが流れてくる状況で、崩れることなく綺麗にレンダリングしてくれるライブラリです。
普通のreact-markdownだと、不完全なMarkdownブロック、たとえば閉じていないコードブロックや途中のリストを渡すと表示が崩れます。Streamdownはそこを解決するために設計されているんですよね。
特徴・メリット
1. 不完全なMarkdownをシームレスに処理
これがStreamdownの最大の特徴です。
ストリーミング中は、Markdownの構文が途中で切れている状態が頻発します。たとえば、バッククォート3つで始まるコードブロックが閉じていない、見出しの#の直後で途切れている、リンクの](が来る前に切れている、など。
Streamdownはこうした「未終了ブロック」を適切に解析して、崩れることなく表示してくれます。個人的には、これだけでも乗り換える価値があると思っています。
2. 組み込みのTailwind CSSスタイル
見出し、リスト、コードブロックなど、一般的なMarkdown要素に対してTailwindベースのスタイルが最初から適用されています。
react-markdownだと、コンポーネントをカスタマイズしてスタイルを当てる作業が必要でしたが、Streamdownならそのまま使ってもそこそこ綺麗に表示される。時短になりますね。
3. CJK言語の完全サポート
これ、地味に嬉しいポイントです。
日本語、中国語、韓国語のテキストで、表意文字の句読点を使った強調マーカーが正しく機能します。英語圏のライブラリだと、日本語の処理がおかしくなることがあるんですよね。そこをちゃんと考慮してくれているのは助かります。
4. Mermaid図の描画対応
コードブロック内のMermaid記法を、そのまま図として描画できます。
AIにフローチャートやシーケンス図を生成してもらうケースが増えてきているので、これは実用的な機能です。わざわざ別のライブラリを組み合わせる必要がない。
5. Shikiによるシンタックスハイライト
コードブロックのシンタックスハイライトには、Shikiが使われています。
Prism.jsやhighlight.jsではなくShikiというのが今どき感がありますね。VSCodeと同じトークナイザーを使っているので、ハイライトの精度が高いです。
6. KaTeXで数式レンダリング
LaTeX形式の数式をKaTeXでレンダリングできます。
技術系のAIチャットだと、数式を返してくることも多いので、これが標準で対応しているのは便利です。
7. セキュリティ対策
予期しないオリジンからの画像やリンクに対する、組み込みのセキュリティ対策があります。
AIが生成したMarkdownをそのまま表示するのは、セキュリティ的にリスクがあります。rehype-hardenを使って、安全なレンダリングを実現しているのは安心ポイントですね。
インストール方法
npmでインストールします。
npm i streamdown
Tailwind CSSを使っている場合は、globals.cssに以下を追加する必要があります。
@source "../node_modules/streamdown/dist/*.js";
これで、Streamdownが使っているTailwindクラスがビルドに含まれるようになります。Tailwind v4の新しい記法ですね。
Streamdownのサンプルを動かす
Streamdownの核心は、<Streamdown>コンポーネントにMarkdown文字列をそのまま渡すだけで、閉じていない太字や途中で終わったリンク、閉じ忘れたコードブロックまで崩さずに描画してくれる点にあります。react-markdownだとエラーにはならないまでも表示が乱れがちな部分です。
要点だけを抜き出すと、次のようになります。
import { Streamdown } from "streamdown";
// 閉じていない強調やコードブロックがあっても崩れない
const markdown = "**まだ閉じていない強調 や `未完成のコード";
export default function Preview() {
return <Streamdown>{markdown}</Streamdown>;
}
実際に動かせるものが下です。左のテキストエリアには、太字が閉じられていない状態や、
https の途中で終わっているリンク、バッククォート3つで始まる閉じていないコードフェンスを
あえて含めています。右側にリアルタイムでレンダリング結果が表示されます。
テキストエリアを書き換えると、右側のプレビューがその場で更新されます。試しに ** を
片方だけ消してみたり、コードフェンスのjsの後ろに文章を続けてみたりすると、
<Streamdown>がどこまで柔軟に不完全なMarkdownを解釈するかが体感できるはずです。
末尾の閉じていないコードフェンスを、対応するバッククォート3つで閉じてあげると、
コードブロックとして確定表示に切り替わる様子も確認できます。
基本的な使い方
最小構成
import { Streamdown } from "streamdown";
export default function ChatMessage({ content }: { content: string }) {
return <Streamdown>{content}</Streamdown>;
}
react-markdownを使っていた人なら、ほぼ同じ感覚で使えます。子要素としてMarkdown文字列を渡すだけ。
ストリーミング表示の実装例
Vercel AI SDKと組み合わせる場合のパターンです。
"use client";
import { useChat } from "ai/react";
import { Streamdown } from "streamdown";
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<div className="flex flex-col gap-4">
{messages.map((message) => (
<div key={message.id}>
<p className="font-bold">{message.role}:</p>
<Streamdown>{message.content}</Streamdown>
</div>
))}
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="メッセージを入力..."
className="border rounded px-4 py-2 w-full"
/>
</form>
</div>
);
}
AIからの応答がリアルタイムでストリーミングされても、Markdownが崩れることなく表示されます。
コードブロックのカスタマイズ
コピーボタンやダウンロード機能は標準で備わっていますが、さらにカスタマイズしたい場合もあるでしょう。
import { Streamdown } from "streamdown";
export default function Page() {
const markdown = `
# サンプルコード
\`\`\`typescript
function greet(name: string): string {
return \`Hello, \${name}!\`;
}
console.log(greet("World"));
\`\`\`
`;
return (
<div className="prose">
<Streamdown>{markdown}</Streamdown>
</div>
);
}
実践的なユースケース
AIチャットボットのUI
これが最も典型的なユースケースです。
"use client";
import { useState, useEffect } from "react";
import { Streamdown } from "streamdown";
interface Message {
role: "user" | "assistant";
content: string;
}
export default function Chatbot() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
const userMessage: Message = { role: "user", content: input };
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsLoading(true);
// ストリーミングレスポンスの処理
const response = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ message: input }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let assistantContent = "";
setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
while (reader) {
const { done, value } = await reader.read();
if (done) break;
assistantContent += decoder.decode(value);
setMessages((prev) => {
const newMessages = [...prev];
newMessages[newMessages.length - 1].content = assistantContent;
return newMessages;
});
}
setIsLoading(false);
};
return (
<div className="max-w-2xl mx-auto p-4">
<div className="space-y-4 mb-4">
{messages.map((msg, i) => (
<div
key={i}
className={`p-4 rounded-lg ${
msg.role === "user" ? "bg-blue-100" : "bg-gray-100"
}`}
>
<Streamdown>{msg.content}</Streamdown>
</div>
))}
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isLoading}
className="flex-1 border rounded px-4 py-2"
placeholder="質問を入力..."
/>
<button
type="submit"
disabled={isLoading}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
送信
</button>
</form>
</div>
);
}
ドキュメント生成ツール
AIがドキュメントを生成する際にも使えます。
import { Streamdown } from "streamdown";
interface DocPreviewProps {
content: string;
isGenerating: boolean;
}
export function DocPreview({ content, isGenerating }: DocPreviewProps) {
return (
<div className="prose prose-lg max-w-none">
<Streamdown>{content}</Streamdown>
{isGenerating && (
<span className="inline-block w-2 h-4 bg-gray-400 animate-pulse" />
)}
</div>
);
}
技術ブログのプレビュー
Markdownで書いた記事のプレビューにも使えます。Mermaidや数式が含まれていても対応できるので、技術系のコンテンツに向いています。
import { Streamdown } from "streamdown";
export function BlogPreview({ markdown }: { markdown: string }) {
return (
<article className="prose prose-slate max-w-none">
<Streamdown>{markdown}</Streamdown>
</article>
);
}
Streamdownでタイプライター風にAI応答を描画する
AIチャットボットのUIで実際に体感してほしいのが、文字が少しずつ増えていく「タイプライター風」の描画です。useStateで保持している文字列をsetIntervalで少しずつ伸ばしながら<Streamdown>に渡すだけで、react-markdownでは崩れがちな途中経過も安定して表示できます。
要点はこの部分です。
const [text, setText] = useState('')
useEffect(() => {
let i = 0
const timer = setInterval(() => {
i += 3
setText(full.slice(0, i))
if (i >= full.length) clearInterval(timer)
}, 40)
return () => clearInterval(timer)
}, [])
return <Streamdown>{text}</Streamdown>
実際に動かせるサンプルです。見出しや太字、コードブロック、番号付きリストが混ざったMarkdownを3文字ずつ流し込んでいます。
「最初から再生」を押すたびに、見出し・太字・コードブロック・リストが順番に流し込まれる様子を確認できます。setIntervalの間隔(40)を小さくすると流れが速くなり、i += 3の増分を変えると1回あたりに追加される文字数が変わります。実際のAI SDKでは、ここがuseChatのストリーミングイベントに置き換わるだけです。
StreamdownでMermaid図をコードブロックから描画する
Streamdownは、mermaidという言語指定のコードブロックをフローチャートやシーケンス図として描画する機能も持っています。ただしv2系ではMermaidのレンダリングは本体に同梱されておらず、@streamdown/mermaidという別パッケージをpluginspropで渡す形になりました。
使い方はこうです。
import { Streamdown } from "streamdown";
import { mermaid } from "@streamdown/mermaid";
<Streamdown plugins={{ mermaid }}>
{"```mermaid\nflowchart LR\n A --> B\n```"}
</Streamdown>
実際に動かせるサンプルです。コードブロックの下に表示される描画ボタンを押すと、flowchart記法がそのまま図として展開されます。
markdown内の矢印(-->)やラベルを書き換えると、描画ボタンを押した際の図の形が変わります。分岐を1つ増やしたい場合はC -- ラベル --> ノード名の行を追加するだけです。plugins={{ mermaid }}を外すと、通常のコードブロックとしてテキストのまま表示される違いも比較してみてください。
Streamdownで表やチェックリストなどMarkdown要素を編集する
Streamdownはremark-gfmを内蔵しているため、テーブルやタスクリスト、取り消し線といったGitHub Flavored Markdownの要素もそのまま描画できます。react-markdownからの移行でも、プラグイン構成を変えずに使える部分です。
要点はこの部分です。
const markdown = `
| 項目 | 対応 |
| --- | --- |
| テーブル | ✅ |
- [x] タスクリスト
- [ ] 未完了の項目
`
<Streamdown>{markdown}</Streamdown>
実際に動かせるサンプルです。左のテキストエリアにテーブルとタスクリストのMarkdownを入れてあります。
テキストエリアに行(| セル | セル |)を追加すると、右側の表に列がそのまま反映されます。- [ ]を- [x]に書き換えるとチェック済みのタスクとして描画される違いも、その場で確認できます。
まとめ
Streamdownは、AIストリーミング時代のMarkdownレンダリングという、ニッチだけど確実に需要のある問題を解決してくれるライブラリです。
個人的に評価しているポイントはこのあたり。
- 不完全なMarkdownでも崩れない
- react-markdownからの移行が簡単
- CJK言語のサポートがしっかりしている
- Mermaid、KaTeX、Shikiが標準装備
- Tailwindとの親和性が高い
正直なところ、ChatGPT風のUIを作ろうとして「ストリーミング中にMarkdownが崩れる」問題にぶつかった人は多いと思います。これまでは自前で対策を書いたり、完全に受信してからレンダリングしたりという妥協が必要でした。
Streamdownはその問題を正面から解決してくれます。Vercel製という安心感もありますし、AI関連のプロダクトを作っているなら、選択肢の一つとして検討する価値は十分にあると思いますね。
まずはnpm i streamdownで試してみてください。