CLAUDE LABJP
FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as OctoberFORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Articles/API & SDK
API & SDK/2026-04-13Intermediate

Implementing Claude API SSE Streaming in Next.js App Router

Learn how to implement Server-Sent Events streaming from the Claude API in Next.js App Router. Covers ReadableStream, React hooks, cancellation, and error handling with production-ready code.

Claude API115SSE4streaming21Next.js7App 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 word by word as it's being generated — the same experience as Claude.ai or ChatGPT. This guide walks through exactly how to wire that up in Next.js App Router, including the React hook pattern, cancellation, and the edge cases that trip people up.

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 is sent in chunks as events:

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

Each text_delta event contains a small piece of the generated text. Concatenate them in order and you get the full response. For displaying streaming text, you only need to handle content_block_delta events with delta.type === "text_delta" — the other event types (message_start, message_stop, etc.) can be ignored unless you need usage statistics.

Route Handler with ReadableStream

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

// 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();
 
  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) {
          if (
            event.type === "content_block_delta" &&
            event.delta.type === "text_delta"
          ) {
            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: "Stream error occurred" })}\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",
    },
  });
}

The three response headers matter: Content-Type: text/event-stream tells the browser this is an SSE stream, Cache-Control: no-cache prevents proxies and CDNs from buffering the response, and Connection: keep-alive keeps the TCP connection open for the duration of streaming.

Custom Hook for Streaming State

On the client side, use fetch with ReadableStream rather than EventSourceEventSource only supports GET requests and doesn'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 abortControllerRef = useRef<AbortController | null>(null);
 
  const sendMessage = useCallback(async (userText: string) => {
    // Cancel any in-flight stream before starting a new one
    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("No response body");
 
      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let accumulated = "";
 
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
 
        // A single chunk may contain multiple SSE events
        const chunk = decoder.decode(value, { stream: true });
        const lines = chunk.split("\n");
 
        for (const line of lines) {
          if (\!line.startsWith("data: ")) continue;
          const data = line.slice(6);
          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 {
            // Ignore parse errors from incomplete chunks
          }
        }
      }
 
      setMessages((prev) => [
        ...prev,
        { role: "assistant", content: accumulated },
      ]);
      setStreamingText("");
    } catch (error) {
      if ((error as Error).name \!== "AbortError") {
        console.error("Stream error:", error);
      }
    } finally {
      setIsLoading(false);
    }
  }, [messages]);
 
  const cancel = useCallback(() => {
    abortControllerRef.current?.abort();
    setIsLoading(false);
    setStreamingText("");
  }, []);
 
  return { messages, streamingText, isLoading, sendMessage, cancel };
}

Wiring Up the Chat UI

With the hook in place, the UI component stays clean:

// 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="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>
  );
}

Things That Trip People Up

Chunk boundaries don't align with SSE events. A single ReadableStream chunk can contain multiple SSE events, or a single event can be split across two chunks. The split("\n") approach handles the multi-event case, but strictly speaking you need a buffer to handle split events. For most use cases the current approach works fine; for production, consider using a proper SSE parser library.

Vercel timeout on long responses. The default function timeout on Vercel's Hobby plan is 10 seconds. Add export const maxDuration = 60; to your route handler to extend it to 60 seconds for longer generations.

Memory leaks on unmount. If a component unmounts while a stream is still running, the stream continues in the background. Use useEffect cleanup or ensure AbortController.abort() gets called on unmount.

Mixing SSE with Server Components. Next.js App Router's streaming applies to React Suspense boundaries, not Claude's SSE stream. Don't confuse the two — Claude's stream needs "use client" and a client-side fetch call.

Next Steps

Once basic streaming is working, the natural next step is persisting conversation history across page reloads. Cloudflare KV or Vercel KV work well for this. You can store conversation sessions server-side and retrieve them by session ID stored in a cookie.

For production code, the Anthropic TypeScript SDK provides a client.messages.stream() helper that abstracts away much of the low-level SSE handling shown here. The manual implementation above is useful for understanding what's happening under the hood, but the SDK helper is more robust for production use.

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 $10 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
A complete production guide to integrating Claude API into NestJS using dependency injection, TypeORM, SSE streaming, JWT auth, and Bull queues—with working code you can deploy today.
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.
API & SDK2026-07-08
Contract-Test Every Tool Before You Submit or Automate an MCP Connector
A connector that works once in a chat can still break silently in an unattended job through misread response shapes or double-fired writes. Here is a small harness that machine-checks tool descriptions, response contracts, idempotency, and latency, with measured 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 →