Claude APIをNext.jsアプリに組み込もうとしたとき、最初に直面する問題があります。fetchでレスポンスを受け取るシンプルな実装では、Claudeが数秒かけて生成した文章が一度に画面に現れます。その間、ユーザーは真っ白な画面を見つめ続けることになります。
Server-Sent Events(SSE)を使ったストリーミング実装に切り替えると、Claudeが考えながら文章を書く様子をリアルタイムで表示できます。ChatGPTやClaudeのWebインターフェースと同じ体験を、自分のアプリで再現できるわけです。
ただ、この実装には「動いているように見えて、実は静かに壊れている」箇所が二つあります。個人開発で回しているチャット画面で、細い回線のときだけ文章が虫食いになるという報告を受け、ようやく気づいたものです。基本の組み立てを追いながら、その二つを実測で潰していきます。
SSEストリーミングの仕組みを理解する
Claude APIにstream: trueを渡すと、レスポンスがtext/event-stream形式で返ってきます。通常のJSONレスポンスとは異なり、生成されたテキストが断片(チャンク)に分けられて逐次送信されます。
各イベントの形式はこうなっています。
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"こんにちは"}}
text_deltaのtextフィールドを順番に連結していけば、完成した文章になります。
ここで一点、実装前に押さえておきたい前提があります。Claude APIはdata: [DONE]を送りません。 終端はevent: message_stopです。[DONE]はOpenAI系のAPIで使われる慣習であり、その感覚のままClaudeのストリームを[DONE]待ちで組むと、終端処理が永久に発火しません。本文はtext_deltaで流れてくるので画面表示だけは正しく、ローディング状態や履歴への追加だけが取り残されます。半分だけ動くので、発見が遅れます。
以下のコードでも[DONE]という文字列が出てきますが、これは自分のRoute Handlerが自分のフロントエンドに向けて送る、自作の終端合図です。Claudeから受け取ったものをそのまま転送しているわけではありません。この区別が曖昧なまま他所のコードを写すと、上の罠を踏みます。
主要なイベント種別を整理しておきます。
| イベント | 意味 | この実装での扱い |
|---|---|---|
message_start | 生成開始・入力トークン数を含む | 使用量記録に使うなら拾う |
content_block_delta | 本文の断片 | delta.type === "text_delta" のみ本文へ |
message_delta | 停止理由・出力トークン数 | 課金計測に使える |
message_stop | 正常終了 | これが終端。[DONE]ではない |
error | ストリーム途中で発生した障害 | HTTP 200 のまま届くので要注意 |
delta.typeでの絞り込みを省くと、extended thinkingを有効にしたときにthinking_deltaが、ツール利用時にinput_json_deltaが本文へ混入します。今は使っていなくても、後で機能を足した瞬間に壊れる場所なので、最初から書いておくのが安全です。
Next.js App Router でのRoute Handler実装
Next.js 13以降のApp Routerでは、route.tsでReadableStreamを返すことでSSEをシンプルに実装できます。
// app/api/chat/route.ts
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
// Vercel Hobby は既定 10 秒で切れる。長文生成では必ず伸ばす
export const maxDuration = 60;
export async function POST(request: Request) {
const { messages } = await request.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
const anthropicStream = await client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages,
});
for await (const event of anthropicStream) {
// 本文だけを通す。thinking_delta / input_json_delta を混ぜない
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
const data = `data: ${JSON.stringify({ text: event.delta.text })}\n\n`;
controller.enqueue(encoder.encode(data));
}
// 途中で届く error イベント。HTTP は 200 のままなので自分で拾う
if (event.type === "error") {
const errData = `data: ${JSON.stringify({
error: event.error?.type ?? "stream_error",
})}\n\n`;
controller.enqueue(encoder.encode(errData));
controller.close();
return;
}
}
// 自作の終端合図(Claude から来たものではない)
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
} catch (error) {
const errData = `data: ${JSON.stringify({
error: error instanceof Error ? error.message : "stream_error",
})}\n\n`;
controller.enqueue(encoder.encode(errData));
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}Content-Type: text/event-streamの設定が重要です。これがないとブラウザがSSEとして認識せず、ストリームを正しく処理できません。Cache-Controlにno-transformを添えているのは、間に入るプロキシがレスポンスを圧縮・再構成してバッファリングするのを避けるためです。no-cacheだけでは足りない構成に何度か遭遇しました。
event.type === "error" の分岐が、一つ目の静かな壊れ方への備えです。ストリーム途中の障害はHTTPステータス200のままerrorイベントとして届くため、try/catchには引っかかりません。ここで拾わないと、途中で打ち切られた回答が「成功」として履歴に残ります。
チャンク境界で本文が消える — 実測
二つ目の壊れ方は、クライアント側にあります。よく見かけるのが、受け取ったチャンクを毎回split("\n")してdata: 行を探す書き方です。
これは、SSEイベントの区切りとReadableStreamのチャンク境界が一致する保証がないという前提を踏み外しています。1つのチャンクに複数イベントが入ることも、1つのイベントが2つのチャンクにまたがることもあります。またがった瞬間、JSON.parseが失敗し、その断片はcatchに落ちて捨てられます。
どのくらい落ちるのか、手元のNodeで測ってみました。日本語23文字の応答を4イベントに分け、意図的に小さなチャンクサイズで割った結果です。
| チャンクサイズ | 毎回 split する旧実装 | バッファを持つ実装 |
|---|---|---|
| 17 バイト | 0 / 23 文字(全滅・parse失敗3回) | 23 / 23 文字 |
| 24 バイト | 0 / 23 文字(全滅・parse失敗3回) | 23 / 23 文字 |
| 40 バイト | 6 / 23 文字(17文字欠落) | 23 / 23 文字 |
| 4096 バイト | 23 / 23 文字 | 23 / 23 文字 |
最後の行が厄介です。チャンクが十分に大きければ旧実装でも全文が届きます。ローカル開発では応答が一気に来やすく、まず再現しません。細切れになるのは、回線が細いとき、プロキシを挟んだとき、モバイル回線のときです。つまり本番でだけ、特定のユーザーにだけ、文章が虫食いになるという形で現れます。
対策は難しくありません。チャンクをバッファへ足し込み、イベント区切りである\n\nで切り出して、末尾の未完成な断片だけを次回へ持ち越します。
let buffer = "";
// reader から受け取るたびに
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() ?? ""; // 最後の要素は未完成かもしれないので持ち越す
for (const part of parts) {
// part は完結した1イベント
}decoder.decode(value, { stream: true })のstream: trueも同じ趣旨です。マルチバイト文字がチャンク境界で割れたとき、TextDecoderが内部で継ぎ足してくれます。これを省くと日本語が文字化けします。
Reactでリアルタイム表示するカスタムフック
以上を踏まえて、クライアント側のフックを組みます。EventSourceではなくfetch+ReadableStreamを使うのは、EventSourceがPOSTリクエストに対応していないためです。
// hooks/useClaudeStream.ts
import { useState, useCallback, useRef } from "react";
interface Message {
role: "user" | "assistant";
content: string;
}
export function useClaudeStream() {
const [messages, setMessages] = useState<Message[]>([]);
const [streamingText, setStreamingText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (userText: string) => {
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
const newMessages: Message[] = [
...messages,
{ role: "user", content: userText },
];
setMessages(newMessages);
setStreamingText("");
setError(null);
setIsLoading(true);
let accumulated = "";
let failed = false;
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: newMessages }),
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error("レスポンスボディがありません");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let done = false;
while (!done) {
const { done: readerDone, value } = await reader.read();
if (readerDone) break;
buffer += decoder.decode(value, { stream: true });
// イベント区切りで切り出し、未完成の末尾は持ち越す
const parts = buffer.split("\n\n");
buffer = parts.pop() ?? "";
for (const part of parts) {
const line = part
.split("\n")
.find((l) => l.startsWith("data: "));
if (!line) continue;
const data = line.slice(6);
if (data === "[DONE]") {
done = true;
break;
}
// パース失敗だけを握りつぶす。error は外へ出す
let parsed: { text?: string; error?: string };
try {
parsed = JSON.parse(data);
} catch {
continue;
}
if (parsed.error) {
failed = true;
setError(parsed.error);
done = true;
break;
}
if (parsed.text) {
accumulated += parsed.text;
setStreamingText(accumulated);
}
}
}
await reader.cancel().catch(() => {});
} catch (e) {
if ((e as Error).name !== "AbortError") {
failed = true;
setError((e as Error).message);
}
} finally {
// 部分応答でも、取得できた分は履歴に残す
if (accumulated) {
setMessages((prev) => [
...prev,
{ role: "assistant", content: accumulated },
]);
}
setStreamingText("");
setIsLoading(false);
if (failed) {
// 呼び出し側で再送ボタンを出すなどの判断に使う
}
}
}, [messages]);
const cancel = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
setStreamingText("");
}, []);
return { messages, streamingText, isLoading, error, sendMessage, cancel };
}エラー処理をtryの内側と外側で分けたのが要点です。以前の書き方では、JSON.parseの失敗を無視するためのcatch {}が、その中でthrowしたエラーまで一緒に飲み込んでいました。同じ状況を手元で流したところ、旧実装は本文だけが残ってエラーがnullのまま、新実装はrate_limit_errorが画面まで到達しました。握りつぶす対象は「パース失敗だけ」に限定します。
finallyでaccumulatedが空でないときに履歴へ追加しているのも意図的です。途中で失敗しても、そこまでに届いた文章は読者にとって価値があります。捨てずに残して、再送は利用者に選ばせます。
チャットUIへの組み込み
フックができたら、UIへの組み込みはシンプルになります。
// app/chat/page.tsx
"use client";
import { useState } from "react";
import { useClaudeStream } from "@/hooks/useClaudeStream";
export default function ChatPage() {
const [input, setInput] = useState("");
const { messages, streamingText, isLoading, error, sendMessage, cancel } =
useClaudeStream();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
const text = input;
setInput("");
await sendMessage(text);
};
return (
<div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((msg, i) => (
<div
key={i}
className={`p-3 rounded-lg ${
msg.role === "user" ? "bg-blue-100 ml-8" : "bg-gray-100 mr-8"
}`}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
))}
{streamingText && (
<div className="bg-gray-100 mr-8 p-3 rounded-lg">
<p className="whitespace-pre-wrap">{streamingText}</p>
<span className="inline-block w-2 h-4 bg-gray-600 animate-pulse ml-1" />
</div>
)}
{error && (
<div
role="alert"
className="bg-red-50 text-red-700 text-sm p-3 rounded-lg"
>
応答が中断されました({error})。もう一度お試しください。
</div>
)}
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="メッセージを入力..."
className="flex-1 border rounded-lg px-4 py-2"
disabled={isLoading}
/>
{isLoading ? (
<button
type="button"
onClick={cancel}
className="px-4 py-2 bg-red-500 text-white rounded-lg"
>
停止
</button>
) : (
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-lg"
>
送信
</button>
)}
</form>
</div>
);
}エラー表示にrole="alert"を添えています。スクリーンリーダー利用者にとって、視覚的な赤い枠は届きません。中断の通知は音声でも届くべきものです。
残りの落とし穴
タイムアウトの層が複数ある:VercelのmaxDurationを伸ばしても、その手前のCDNやリバースプロキシに別のアイドルタイムアウトが設定されていることがあります。長い沈黙が続く構成では、定期的にコメント行(: ping\n\n)を流して接続を生かす方法が有効です。SSEのコメント行はクライアントのdata: 判定に引っかからないため、パーサを変えずに追加できます。
アンマウント時の後始末:コンポーネントが消えた後もストリームが生きていると、setStateが宙に浮きます。useEffectのクリーンアップでcancel()を呼ぶか、上のようにAbortControllerを一元管理してください。
キャンセルしても課金は止まらない場合がある:abort()は接続を切りますが、サーバー側の生成が即座に止まるとは限りません。コスト面の挙動はClaude API のストリーミングを途中で止める — AbortController の正しい使い方と請求の仕組みに詳しくまとめています。
症状から原因を辿りたいとき:途中で切れる現象には複数の原因があります。切り分けの手順はClaude APIストリーミングが途中で切れる — 症状別の診断と修正パターンが近道です。
さらに発展させるなら
基本的な実装が動いたら、次に効いてくるのは会話履歴の永続化です。現状ではページをリロードするとメッセージが消えてしまいます。Cloudflare KVやVercel KVと組み合わせると、セッションをまたいで会話を保持できます。
SSEの低レベルな処理そのものは、Anthropic公式のストリーミングドキュメントに一次情報がまとまっています。SDKのclient.messages.stream()は多くを抽象化してくれますが、本記事で扱ったチャンク境界とエラーの取り扱いは、SDKの外側であるフロントエンド側に残ります。ここだけは自分で持つ必要があります。
私自身、この二つの取りこぼしは長いあいだ気づけませんでした。画面には文章が出ているので、正しく動いていると思い込んでいたのです。数字で確かめて初めて、細い回線のユーザーが虫食いの回答を読んでいた可能性に思い至りました。実装の参考になれば幸いです。