CLAUDE LABJP
PRICING — September 1 was the scheduled date for the Sonnet 5 price increase, and it did not happen. The introductory $2/$10 per MTok now stands as the regular pricePARTNER — Salesforce and Anthropic announced Claudeforce, an expanded partnership. The Salesforce in Claude plugin ships with 37 prebuilt sales skills, from meeting prep to pipeline managementTRUST — Claudeforce serves Claude through Amazon Bedrock inside the Salesforce Trust Boundary, so data and inference never leave the security perimeter — an answer aimed squarely at regulated industriesBETA — Salesforce in Claude is with select pilot customers for now, with an open beta expected during SeptemberLIMITS — The 50% weekly-limit boost runs through September 13. From September 14 the permanent level is 25% above the pre-promotion baseline, roughly a 17% cut from todayRELEASE — Claude Code has shipped nothing since v2.1.251 on August 28. Against a pace of one release every 0.8 days, a four-day gap is among the longest yetPRICING — September 1 was the scheduled date for the Sonnet 5 price increase, and it did not happen. The introductory $2/$10 per MTok now stands as the regular pricePARTNER — Salesforce and Anthropic announced Claudeforce, an expanded partnership. The Salesforce in Claude plugin ships with 37 prebuilt sales skills, from meeting prep to pipeline managementTRUST — Claudeforce serves Claude through Amazon Bedrock inside the Salesforce Trust Boundary, so data and inference never leave the security perimeter — an answer aimed squarely at regulated industriesBETA — Salesforce in Claude is with select pilot customers for now, with an open beta expected during SeptemberLIMITS — The 50% weekly-limit boost runs through September 13. From September 14 the permanent level is 25% above the pre-promotion baseline, roughly a 17% cut from todayRELEASE — Claude Code has shipped nothing since v2.1.251 on August 28. Against a pace of one release every 0.8 days, a four-day gap is among the longest yet
Articles/API & SDK
API & SDK/2026-04-13Intermediate

Implementing Claude API SSE Streaming in Next.js App Router

Implement Server-Sent Events streaming from the Claude API in Next.js App Router — ReadableStream, a React hook, cross-chunk buffering, and the two silent failures that only show up in production.

Claude API119SSE6streaming22Next.js8App Router2React2TypeScript24

The first real friction point when integrating Claude into a Next.js app tends to be the same for everyone: a simple fetch call waits until Claude finishes generating the entire response before showing anything. Users stare at a blank area for several seconds. That's a bad experience, and it's completely avoidable.

With Server-Sent Events (SSE) streaming, you can show Claude's response as it's being generated — the same experience as Claude.ai or ChatGPT. This guide walks through how to wire that up in Next.js App Router.

It also covers two failure modes that hide inside almost every SSE tutorial. Both of them let the text appear correctly on screen while something else quietly goes wrong, which is exactly why they survive code review.

How Claude's SSE Streaming Works

When you pass stream: true to the Claude API, the response comes back as text/event-stream instead of a complete JSON body. Text arrives as a series of events:

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}

Concatenate each text_delta in order and you get the full response.

One thing to settle before writing any code: the Claude API never sends data: [DONE]. The terminator is event: message_stop. [DONE] is an OpenAI convention, and if you carry that habit over, your completion handler never fires. The text still streams in, so the UI looks fine — only the loading flag and the history append are left stranded. Half-working bugs are the slowest to find.

You will see [DONE] in the code below, but it is a sentinel our own Route Handler sends to our own frontend, not something forwarded from Claude. Keeping that distinction clear is what stops you from copying the bug out of someone else's gist.

EventMeaningHow we handle it
message_startGeneration begins, includes input tokensRead it if you track usage
content_block_deltaA piece of contentForward only delta.type === "text_delta"
message_deltaStop reason and output tokensUseful for cost accounting
message_stopNormal completionThis is the terminator, not [DONE]
errorMid-stream failureArrives with HTTP 200 — handle explicitly

Skipping the delta.type check works fine today and breaks the moment you enable extended thinking or tool use: thinking_delta and input_json_delta will leak straight into your visible text. Write the check now.

Route Handler with ReadableStream

In Next.js App Router, a route.ts file can return a ReadableStream directly:

// app/api/chat/route.ts
import Anthropic from "@anthropic-ai/sdk";
 
const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});
 
// Vercel Hobby cuts off at 10s by default — raise it for long generations
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) {
          // Text only — never thinking_delta or 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));
          }
 
          // Mid-stream errors arrive with HTTP 200. Catch them yourself.
          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;
          }
        }
 
        // Our own sentinel — not something Claude sent us
        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 is what tells the browser to treat this as a stream. The no-transform directive alongside no-cache matters more than it looks: some proxies will happily re-compress and re-chunk a response, which reintroduces buffering that no-cache alone does not prevent.

The event.type === "error" branch is the first silent failure closed off. A mid-stream error comes back inside a 200 response, so try/catch never sees it. Without that branch, a truncated answer gets stored as a successful one.

Losing Text at Chunk Boundaries — Measured

The second failure lives on the client. The common pattern is to call split("\n") on every chunk and look for data: lines.

That assumes SSE event boundaries line up with ReadableStream chunk boundaries. They don't. One chunk may hold several events, and one event may be split across two chunks. When it splits, JSON.parse throws, the fragment lands in a catch, and the text is gone.

Here is what that costs, measured in Node with a 23-character response split into four events and deliberately small chunks:

Chunk sizeSplit-per-chunk (naive)Buffered parser
17 bytes0 of 23 chars (3 parse failures)23 of 23
24 bytes0 of 23 chars (3 parse failures)23 of 23
40 bytes6 of 23 chars23 of 23
4096 bytes23 of 2323 of 23

That last row is the trap. With large chunks the naive parser looks perfect, and on localhost chunks are almost always large. Small chunks happen on slow connections, behind proxies, and on mobile networks. So the bug ships, and then it only affects some of your users, some of the time, with text that reads like it has holes punched in it.

The fix is small. Append to a buffer, split on the \n\n event delimiter, and carry the unfinished tail forward:

let buffer = "";
 
// on every read
buffer += decoder.decode(value, { stream: true });
 
const parts = buffer.split("\n\n");
buffer = parts.pop() ?? ""; // last element may be incomplete — keep it
 
for (const part of parts) {
  // part is one complete event
}

The { stream: true } option on decode serves the same purpose one level down: it lets TextDecoder hold on to a multi-byte character that got cut in half. Omit it and non-ASCII text turns into replacement characters.

Custom Hook for Streaming State

Use fetch with ReadableStream rather than EventSourceEventSource only supports GET and can't carry a request body.

// 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 = "";
 
    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("Response body is empty");
 
      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;
          }
 
          // Swallow parse failures only — never swallow errors
          let parsed: { text?: string; error?: string };
          try {
            parsed = JSON.parse(data);
          } catch {
            continue;
          }
 
          if (parsed.error) {
            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") {
        setError((e as Error).message);
      }
    } finally {
      // Keep whatever arrived, even on failure
      if (accumulated) {
        setMessages((prev) => [
          ...prev,
          { role: "assistant", content: accumulated },
        ]);
      }
      setStreamingText("");
      setIsLoading(false);
    }
  }, [messages]);
 
  const cancel = useCallback(() => {
    abortControllerRef.current?.abort();
    setIsLoading(false);
    setStreamingText("");
  }, []);
 
  return { messages, streamingText, isLoading, error, sendMessage, cancel };
}

The important detail is where the error handling sits. A single catch {} wrapped around both JSON.parse and a throw new Error(parsed.error) will eat the error along with the parse failure. Running that exact shape locally: the old version finished with the partial text and error === null, the corrected version surfaced rate_limit_error. Only catch what you mean to catch.

Appending accumulated in finally is deliberate too. If a stream dies halfway, the text that did arrive is still worth reading. Keep it and let the person decide whether to retry.

Wiring Up the Chat 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"
          >
            The response was interrupted ({error}). Please try again.
          </div>
        )}
      </div>
 
      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Type a message..."
          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"
          >
            Stop
          </button>
        ) : (
          <button
            type="submit"
            className="px-4 py-2 bg-blue-500 text-white rounded-lg"
          >
            Send
          </button>
        )}
      </form>
    </div>
  );
}

The role="alert" on the error box is worth the two seconds it takes to type. A red border communicates nothing to a screen reader user, and an interrupted answer is exactly the kind of thing they need to hear about.

Things That Still Trip People Up

Timeouts stack. Raising Vercel's maxDuration doesn't help if a CDN or reverse proxy in front of it has its own idle timeout. For long silences, send a periodic SSE comment line (: ping\n\n) to keep the connection alive. Comment lines don't start with data: , so your parser ignores them without any changes.

Clean up on unmount. A stream that outlives its component leaves setState calls with nowhere to go. Call cancel() from a useEffect cleanup, or centralize the AbortController as shown above.

Cancelling doesn't necessarily stop the bill. abort() closes the connection, but generation may continue server-side. The billing behavior is covered in Cancelling Claude API Streams the Right Way.

Streams that die mid-response have several distinct causes. If you're debugging one, Claude API Streaming Stops Mid-Response works through them by symptom.

Next Steps

Once the basics work, persistence is usually the next thing you want. Right now a page reload wipes the conversation; pairing this with Cloudflare KV or Vercel KV keeps it across sessions.

For the protocol itself, the official streaming documentation is the primary source. The SDK's client.messages.stream() abstracts away most of the server-side work — but the chunk-boundary buffering and the error routing covered here live on the frontend, outside the SDK. That part stays yours to own.

Working solo as an indie developer, I missed both of these for a long time. The text showed up on screen, so I assumed it was working. It took actually measuring it to realize some readers on thin connections had been getting answers with holes in them.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

API & SDK2026-04-13
Building Enterprise AI Backends with Claude API and NestJS: Production
Integrating Claude API into NestJS with dependency injection, TypeORM persistence, SSE streaming, JWT auth, and Bull queues—including measured evidence of how a naive SSE client parser silently drops characters, and the fix.
API & SDK2026-08-15
The Connection That Dies Mid-Thought Is Being Killed by Your Relay, Not the Upstream
Put a proxy or Worker in front of Claude Code and long thinking pauses start dying. The cause is neither the model nor the upstream — it is your relay holding the byte stream. Six relay behaviors compared side by side, plus how to verify yours before it ships.
API & SDK2026-07-09
When the RAG Started Being Confidently Wrong — Field Notes on Measuring Retrieval Misses With Groundedness
In a Claude API RAG, the answers stay fluent while the facts drift. Often the cause is a silent recall decay on the retrieval side, missing the document that holds the answer. Field notes on measuring groundedness and retrieval hit rate and walking the system back, with working code and real numbers.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →