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/sdkThe client reads ANTHROPIC_API_KEY from the environment, so keep the key on the server:
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.
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 ascontent_block_deltaevents whosedelta.typeistext_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, includingstop_reasonandusage. Use it for logging token counts instead of reassembling the text yourself.cancel()callsstream.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_tokenscan be large when streaming, because there is no single long-held HTTP response to time out.
Recent Claude models can decline a request and end with stop_reason: "refusal". Check stop_reason before treating the output as a complete
answer.
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:
"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
messagesis 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().usagereports 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.RateLimitErrorandAnthropic.APIError; retry rate limits and server errors, surface bad requests.