CLAUDE LABEN
PRICING — 本日9月1日は Sonnet 5 の値上げ予定日でしたが、引き上げは実施されませんでした。導入価格の $2/$10 per MTok がそのまま標準価格として確定していますPARTNER — Salesforce と Anthropic が拡張提携「Claudeforce」を発表しました。Salesforce in Claude プラグインは商談準備やパイプライン管理など37のセールススキルを同梱しますTRUST — Claudeforce の推論は Amazon Bedrock 経由で Salesforce Trust Boundary の内側に留まります。データが境界の外へ出ない構成が、規制業種への回答になっていますBETA — Salesforce in Claude は現在は選抜パイロット顧客向けです。9月中のオープンベータ移行が予告されていますLIMITS — 週次上限の50%増は9月13日までです。9月14日からはプロモ前比+25%が恒久水準になります。今日の水準と比べると約17%の削減にあたりますRELEASE — Claude Code は v2.1.251(8月28日)から新しいリリースが出ていません。約0.8日に1本というペースからすると、4日の間隔は最長クラスですPRICING — 本日9月1日は Sonnet 5 の値上げ予定日でしたが、引き上げは実施されませんでした。導入価格の $2/$10 per MTok がそのまま標準価格として確定していますPARTNER — Salesforce と Anthropic が拡張提携「Claudeforce」を発表しました。Salesforce in Claude プラグインは商談準備やパイプライン管理など37のセールススキルを同梱しますTRUST — Claudeforce の推論は Amazon Bedrock 経由で Salesforce Trust Boundary の内側に留まります。データが境界の外へ出ない構成が、規制業種への回答になっていますBETA — Salesforce in Claude は現在は選抜パイロット顧客向けです。9月中のオープンベータ移行が予告されていますLIMITS — 週次上限の50%増は9月13日までです。9月14日からはプロモ前比+25%が恒久水準になります。今日の水準と比べると約17%の削減にあたりますRELEASE — Claude Code は v2.1.251(8月28日)から新しいリリースが出ていません。約0.8日に1本というペースからすると、4日の間隔は最長クラスです
記事一覧/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フック・チャンク境界のバッファリング・エラーの取りこぼしまで、実測を添えて順に説明します。

Claude API119SSE6ストリーミング10Next.js8App 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フィールドを順番に連結していけば、完成した文章になります。

ここで一点、実装前に押さえておきたい前提があります。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-Controlno-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ではなくfetchReadableStreamを使うのは、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が画面まで到達しました。握りつぶす対象は「パース失敗だけ」に限定します。

finallyaccumulatedが空でないときに履歴へ追加しているのも意図的です。途中で失敗しても、そこまでに届いた文章は読者にとって価値があります。捨てずに残して、再送は利用者に選ばせます。

チャット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の外側であるフロントエンド側に残ります。ここだけは自分で持つ必要があります。

私自身、この二つの取りこぼしは長いあいだ気づけませんでした。画面には文章が出ているので、正しく動いていると思い込んでいたのです。数字で確かめて初めて、細い回線のユーザーが虫食いの回答を読んでいた可能性に思い至りました。実装の参考になれば幸いです。

シェア

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

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

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥2,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つの落とし穴——実装してわかった本当の難しさ
ReadableStreamが非同期イテラブルでないというエラーに2時間悩んだ経験から、Claude APIストリーミング実装の3つの落とし穴を整理しました。ランタイム環境によるストリーム非互換、エラーハンドリングのタイミング、トークン見積もりミスによるコスト超過を動くTypeScriptで解説します。
API & SDK2026-04-13
Claude API × NestJS で作るエンタープライズ AI バックエンド本番設計
NestJSのDIコンテナにAnthropic SDKを組み込み、TypeORMでの会話履歴永続化、SSEストリーミング、JWT認証、BullとRedisによる非同期処理までを実装します。クライアント側SSEパーサがチャンク境界で文字を落とす問題も、実測値とともに扱います。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →