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フィールドを順番に連結していけば、完成した文章になります。なおmessage_startやmessage_stopなどのイベントも存在するが、テキスト表示だけならcontent_block_deltaのtext_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,
});
export async function POST(request: Request) {
const { messages } = await request.json();
// ReadableStreamを作成してレスポンスに渡す
const stream = new ReadableStream({
async start(controller) {
try {
// Claude APIのストリーミングを開始
const anthropicStream = await client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages,
});
// テキストデルタのみを処理
for await (const event of anthropicStream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
// SSE形式でチャンクを送信
const data = `data: ${JSON.stringify({ text: event.delta.text })}\n\n`;
controller.enqueue(new TextEncoder().encode(data));
}
}
// ストリーム終了を通知
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
controller.close();
} catch (error) {
// エラーをクライアントに伝える
const errData = `data: ${JSON.stringify({ error: "ストリームエラーが発生しました" })}\n\n`;
controller.enqueue(new TextEncoder().encode(errData));
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}Content-Type: text/event-streamの設定が重要です。これがないとブラウザがSSEとして認識せず、ストリームを正しく処理できません。またCache-Control: no-cacheを付けないと、プロキシやCDNがレスポンスをバッファリングしてしまう場合があります。
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 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("");
setIsLoading(true);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: newMessages }),
signal: controller.signal,
});
if (\!response.body) throw new Error("レスポンスボディがありません");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let accumulated = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// 複数のSSEイベントが1チャンクに含まれる場合がある
const lines = chunk.split("\n");
for (const line of lines) {
if (\!line.startsWith("data: ")) continue;
const data = line.slice(6); // "data: " を除去
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
if (parsed.error) throw new Error(parsed.error);
if (parsed.text) {
accumulated += parsed.text;
setStreamingText(accumulated);
}
} catch {
// JSONパースエラーは無視(不完全なチャンクの場合)
}
}
}
// ストリーミング完了後、messagesに追加
setMessages((prev) => [
...prev,
{ role: "assistant", content: accumulated },
]);
setStreamingText("");
} catch (error) {
if ((error as Error).name \!== "AbortError") {
console.error("ストリームエラー:", error);
}
} finally {
setIsLoading(false);
}
}, [messages]);
const cancel = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
setStreamingText("");
}, []);
return { messages, streamingText, isLoading, sendMessage, cancel };
}abortControllerRefを使ってキャンセル処理を実装しているのがポイントです。ユーザーが送信ボタンを連打した場合や、途中でキャンセルしたい場合に、前のリクエストを確実に破棄できます。
チャット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, 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>
)}
</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>
);
}実装上のよくある落とし穴
チャンク境界の問題:ReadableStreamのチャンクは、SSEイベントの境界と一致しない場合があります。1つのチャンクに複数のイベントが含まれることも、1つのイベントが複数のチャンクに分割されることもあります。上記のコードでは\nで分割してdata: プレフィックスを確認しているが、本番環境では不完全なJSONのハンドリングをより厳密にすべきです。
Vercelのタイムアウト:デフォルトではVercel Hobbyプランのレスポンスタイムアウトが10秒に設定されています。長い生成が必要な場合は、export const maxDuration = 60;をRoute Handlerに追加することで最大60秒まで延長できます。
メモリリーク:コンポーネントがアンマウントされたときにストリームが残ると、メモリリークの原因になります。useEffectのクリーンアップ関数でキャンセルを呼ぶか、上記のようにAbortControllerを適切に管理する点が肝心です。
フロントエンドでのストリームの扱い:Next.jsのApp Routerでは"use client"を付けたコンポーネントでのみWebAPIが使える。Route HandlerでサーバーサイドレンダリングとSSEを混在させると予期しない動作が起きることがあります。
さらに発展させるなら
基本的な実装ができたら、次のステップとして検討したいのが会話履歴の永続化です。現状ではページをリロードするとメッセージが消えてしまう。Cloudflare KVやVercel KVと組み合わせると、セッションをまたいで会話を保持できます。
また、Anthropicが提供するPython/TypeScript SDKにはclient.messages.stream()というヘルパーメソッドが用意されており、SSEの低レベルな処理を抽象化してくれます。今回紹介したコードはSSEの仕組みを理解するために手動実装しているが、実際のプロダクションコードではSDKのヘルパーを活用することをすすめる。