CLAUDE LABEN
FORK — Claude Code 2.1.212で/forkの挙動が変わりました。会話を新しいバックグラウンドセッションへ複製し、作業を続けたまま並走できます。従来のセッション内サブエージェントは/subtaskに移りましたLIMITS — WebSearchの呼び出しがセッション単位で既定200回に制限されました。サブエージェントの起動も既定200回が上限で、暴走した検索・委譲のループを止められますMCPBG — 2分を超えるMCPツール呼び出しは自動的にバックグラウンドへ移り、セッションが固まらなくなりました。しきい値はCLAUDE_CODE_MCP_AUTO_BACKGROUND_MSで調整できますPLANFIX — プランモードがtouchやrmといったファイルを変更するBashコマンドを、許可プロンプトもcanUseToolコールバックも通さずに実行してしまう不具合が修正されましたSONNET5 — Claude Sonnet 5は導入価格として入力100万トークンあたり2ドル、出力10ドルで提供中です。8月31日を過ぎると3ドルと15ドルに戻りますIPO — Anthropicが早ければ10月の株式公開を視野に、引受銀行が投資家との面談を組み始めたと報じられていますFORK — Claude Code 2.1.212で/forkの挙動が変わりました。会話を新しいバックグラウンドセッションへ複製し、作業を続けたまま並走できます。従来のセッション内サブエージェントは/subtaskに移りましたLIMITS — WebSearchの呼び出しがセッション単位で既定200回に制限されました。サブエージェントの起動も既定200回が上限で、暴走した検索・委譲のループを止められますMCPBG — 2分を超えるMCPツール呼び出しは自動的にバックグラウンドへ移り、セッションが固まらなくなりました。しきい値はCLAUDE_CODE_MCP_AUTO_BACKGROUND_MSで調整できますPLANFIX — プランモードがtouchやrmといったファイルを変更するBashコマンドを、許可プロンプトもcanUseToolコールバックも通さずに実行してしまう不具合が修正されましたSONNET5 — Claude Sonnet 5は導入価格として入力100万トークンあたり2ドル、出力10ドルで提供中です。8月31日を過ぎると3ドルと15ドルに戻りますIPO — Anthropicが早ければ10月の株式公開を視野に、引受銀行が投資家との面談を組み始めたと報じられています
記事一覧/API & SDK
API & SDK/2026-04-13中級

Claude API のSSEストリーミングをNext.js App Routerに実装する

Claude APIのServer-Sent EventsストリーミングをNext.js App Routerに実装する方法を解説。ReadableStream・React Hooks・エラーハンドリングまで、実際に動くコードで順を追って説明します。

Claude API115SSE4ストリーミング9Next.js7App Router2React2TypeScript24

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_deltatextフィールドを順番に連結していけば、完成した文章になります。なおmessage_startmessage_stopなどのイベントも存在するが、テキスト表示だけならcontent_block_deltatext_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のヘルパーを活用することをすすめる。

シェア

お読みいただきありがとうございます

Claude Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

API & SDK2026-05-04
Bun × Claude API で本番運用するエッジAIサービス — Node.js移行の判断軸と落とし穴を回避する実装パターン
Claude APIをBun上で本番運用するための実装ガイド。Node.jsからの移行判断軸・組み込みSQLite/WebSocketの活用・ストリーミング最適化・典型的な落とし穴の回避策まで、動作するコードと実測値で解説します。
API & SDK2026-04-20
Claude API のストリーミングで詰まる3つの落とし穴——実装してわかった本当の難しさ
Claude APIのストリーミング実装で実際にはまりがちなエラーハンドリング・バックプレッシャー・トークンカウントの3点を、動くコードと一緒に解説します。
API & SDK2026-04-13
Claude API × NestJS で作るエンタープライズ AI バックエンド本番設計
NestJSのDIシステム・TypeORM・SSEストリーミング・JWT認証・Bullキューを組み合わせてClaude APIを組み込んだ本番グレードのAIバックエンドを構築する完全実装ガイド。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →