Command Palette

Search for a command to run...

0GitHub stars
Blog

Streaming Claude responses in a Next.js route handler

Stream tokens from Claude to the browser with the Anthropic TypeScript SDK, a Web ReadableStream, and a few lines of client code.

Waiting for a full model response before showing anything makes an AI feature feel slow, even when the total time is fine. Streaming fixes that: the first words appear almost immediately and the rest arrive as they are generated. This post wires Claude into a Next.js App Router route handler and streams plain text to the browser, with no extra framework.

Install the SDK

pnpm add @anthropic-ai/sdk

The client reads ANTHROPIC_API_KEY from the environment, so keep the key on the server:

.env.local
ANTHROPIC_API_KEY=sk-ant-...

Never prefix it with NEXT_PUBLIC_, or it ships to every visitor.

The route handler

Route handlers run on the Node.js runtime by default and return standard Web Response objects, so a ReadableStream is all you need to stream.

app/api/chat/route.ts
import Anthropic from "@anthropic-ai/sdk"
 
const client = new Anthropic()
 
// Long answers can take a while; raise the platform's default limit.
export const maxDuration = 60
 
export async function POST(request: Request) {
  const { messages } = (await request.json()) as {
    messages: Anthropic.MessageParam[]
  }
 
  const stream = client.messages.stream({
    model: "claude-sonnet-4-20250514",
    max_tokens: 64000,
    system: "You are a concise assistant for a developer documentation site.",
    messages,
  })
 
  const encoder = new TextEncoder()
 
  const body = new ReadableStream<Uint8Array>({
    async start(controller) {
      try {
        for await (const event of stream) {
          if (
            event.type === "content_block_delta" &&
            event.delta.type === "text_delta"
          ) {
            controller.enqueue(encoder.encode(event.delta.text))
          }
        }
 
        const message = await stream.finalMessage()
        if (message.stop_reason === "refusal") {
          controller.enqueue(encoder.encode("\n\n[The request was declined.]"))
        }
 
        controller.close()
      } catch (error) {
        controller.error(error)
      }
    },
    // The browser navigated away or aborted: stop generating (and paying).
    cancel() {
      stream.abort()
    },
  })
 
  return new Response(body, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Cache-Control": "no-store",
    },
  })
}

A few details matter here:

  • client.messages.stream() returns an async iterable of server-sent events. Text arrives as content_block_delta events whose delta.type is text_delta; everything else (message start, block boundaries, usage) can be ignored for a plain chat UI.
  • stream.finalMessage() resolves once the stream ends and gives you the complete message, including stop_reason and usage. Use it for logging token counts instead of reassembling the text yourself.
  • cancel() calls stream.abort(), which also aborts the in-flight request to the API. Without it, a user who closes the tab keeps the generation running until it finishes.
  • max_tokens can be large when streaming, because there is no single long-held HTTP response to time out.

Reading the stream in the browser

fetch exposes the body as a stream too, so the client side is a small loop over a reader:

components/chat.tsx
"use client"
 
import { useState } from "react"
 
export function Chat() {
  const [answer, setAnswer] = useState("")
  const [pending, setPending] = useState(false)
 
  async function ask(question: string) {
    setAnswer("")
    setPending(true)
 
    const response = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        messages: [{ role: "user", content: question }],
      }),
    })
 
    if (!response.ok || !response.body) {
      setPending(false)
      setAnswer("Something went wrong.")
      return
    }
 
    const reader = response.body.getReader()
    const decoder = new TextDecoder()
 
    while (true) {
      const { value, done } = await reader.read()
      if (done) break
      setAnswer((prev) => prev + decoder.decode(value, { stream: true }))
    }
 
    setPending(false)
  }
 
  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        const data = new FormData(event.currentTarget)
        void ask(String(data.get("question")))
      }}
    >
      <input name="question" placeholder="Ask something" />
      <button disabled={pending}>Send</button>
      <p className="whitespace-pre-wrap">{answer}</p>
    </form>
  )
}

Passing { stream: true } to TextDecoder.decode matters: a multi-byte character can be split across two chunks, and the flag keeps the decoder from emitting a broken character at the boundary.

Multi-turn conversations

The Messages API is stateless. To continue a conversation, send the full history every time, alternating user and assistant turns:

const messages: Anthropic.MessageParam[] = [
  { role: "user", content: "What is a route handler?" },
  { role: "assistant", content: "A route handler is..." },
  { role: "user", content: "How is it different from a server action?" },
]

Keep the history on the client (or in your database) and append the streamed assistant text once it completes. As conversations grow, prompt caching keeps the repeated prefix cheap; that is the subject of the next post.

Production checklist

  • Validate input. The route accepts arbitrary JSON from the browser. Check that messages is an array of the expected shape and cap its length before forwarding it.
  • Authenticate and rate limit. An open endpoint that calls a paid API is an invitation for abuse.
  • Log usage. finalMessage().usage reports input and output tokens per request, which is what you are billed on.
  • Handle errors by type. The SDK throws typed errors such as Anthropic.RateLimitError and Anthropic.APIError; retry rate limits and server errors, surface bad requests.
Command Palette

Search for a command to run...