How to stream Claude API responses in a Next.js chat app
To stream Claude in Next.js, call client.messages.stream() from the Anthropic TypeScript SDK inside an App Router route handler, forward each text delta into a ReadableStream, and return it as the Response. On the page, a client component reads the response body with a reader and appends each chunk to the message as it arrives.
How do you stream Claude responses in Next.js?
You stream Claude responses in Next.js by calling the Claude API from a route handler on the server and returning its output as a streamed Response. The Anthropic TypeScript SDK's client.messages.stream() produces the reply piece by piece; you push each piece into a ReadableStream; the browser reads that stream and paints the text as it arrives.
This keeps the API key on the server, works on any host that supports streaming responses, and needs no extra libraries beyond the official SDK.
What do you need before you start?
You need a Next.js project that uses the App Router, a Claude API key, and the SDK:
npm install @anthropic-ai/sdk
Add the key to .env.local as ANTHROPIC_API_KEY=.... The SDK reads that variable automatically. Never prefix it with NEXT_PUBLIC_, because that would bundle it into the browser code.
How do you write the streaming route handler?
Create app/api/chat/route.ts. The handler validates the incoming conversation, opens a stream to Claude and forwards every text delta to the browser.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY
const MAX_MESSAGES = 30;
const MAX_CHARS = 4000;
export async function POST(req: Request) {
const { messages } = (await req.json()) as { messages: Anthropic.MessageParam[] };
if (!Array.isArray(messages) || messages.length === 0 || messages.length > MAX_MESSAGES) {
return new Response("Invalid conversation", { status: 400 });
}
for (const m of messages) {
if (typeof m.content !== "string" || m.content.length > MAX_CHARS) {
return new Response("Message too long", { status: 400 });
}
}
const stream = client.messages.stream({
model: "claude-opus-5",
max_tokens: 64000,
system: "You are a helpful assistant for our product. Answer briefly and clearly.",
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));
}
}
controller.close();
} catch (err) {
controller.error(err);
}
},
cancel() {
stream.abort(); // the user closed the page or pressed stop
},
});
return new Response(body, {
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache" },
});
}
A few choices here are deliberate:
- Streaming with a high
max_tokens. Long replies do not hit HTTP timeouts when streamed, so the limit can be generous. - Only
text_deltaevents are forwarded. The stream also carries events such asmessage_start,content_block_stopandmessage_delta; the browser does not need them for a plain chat. cancel()aborts the Claude stream. If the visitor leaves, you stop paying for tokens nobody will read.- The model ID is the current default Claude model at the time of writing (September 2026). Check Anthropic's model list when you build.
How does the browser read the stream?
The browser sends the whole conversation, then reads the response body chunk by chunk. Put this in a client component, for example app/chat/Chat.tsx.
"use client";
import { useState } from "react";
type Msg = { role: "user" | "assistant"; content: string };
export default function Chat() {
const [messages, setMessages] = useState<Msg[]>([]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
async function send() {
const history: Msg[] = [...messages, { role: "user", content: input }];
setMessages([...history, { role: "assistant", content: "" }]);
setInput("");
setBusy(true);
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history }),
});
if (!res.ok || !res.body) throw new Error(`Chat request failed: ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
setMessages((prev) => {
const last = prev[prev.length - 1];
return [...prev.slice(0, -1), { ...last, content: last.content + chunk }];
});
}
setBusy(false);
}
return (
<div>
{messages.map((m, i) => (
<p key={i}><strong>{m.role === "user" ? "You" : "Claude"}:</strong> {m.content}</p>
))}
<input value={input} onChange={(e) => setInput(e.target.value)} disabled={busy} />
<button onClick={send} disabled={busy || !input.trim()}>Send</button>
</div>
);
}
The key line is decoder.decode(value, { stream: true }). The stream: true flag stops multi-byte characters, such as accented letters or emoji, from breaking when they are split across two chunks.
Why does the conversation history go with every request?
The Claude Messages API is stateless: it does not remember earlier requests. Each call must include the full list of user and assistant turns so far. The component above keeps that list in React state and sends it every time.
For long chats, trim the history on the server (for example, keep the last 30 turns) or summarize older turns. Otherwise every request gets bigger, slower and more expensive.
How do you make the chat feel fast?
Streaming solves most of the waiting problem, but a few settings matter too:
| Problem | Cause | Fix |
|---|---|---|
| Long pause before the first word | The model is thinking before it writes | Lower the effort setting (output_config: { effort: "low" }) for simple chat replies |
| Text arrives all at once | A proxy or host buffers the response | Check that your host supports streamed responses; avoid middleware that reads the whole body |
| Stream stops on long answers | The host's function time limit | Raise the route's maximum duration on your host, or keep answers shorter |
| Costs grow over a long chat | Full history is resent every turn | Trim or summarize old turns; use prompt caching for a long, fixed system prompt |
Recent Claude models use adaptive thinking by default, which improves hard answers but adds a short pause first. For a quick support chat, a lower effort level is usually the better trade.
How do you keep a streaming chat secure?
A chat endpoint is a public door to a paid API, so protect it:
- Keep the key on the server and never log it.
- Validate input: the handler above rejects empty, huge or malformed conversations.
- Add rate limiting per user or IP address before launch.
- Put your rules in the
systemprompt, not in user messages, and do not trust therolevalues the browser sends without checking them. - Require sign-in if the chat can reach private data or tools.
Summary
Streaming Claude in Next.js takes two small pieces: a route handler that turns client.messages.stream() into a ReadableStream, and a client component that reads it with getReader(). Add input limits, stream cancellation and rate limiting, and you have a chat that feels fast and stays safe. To add tools to the same chat, see building an agent with tool use. For a full product built this way, see Next.js web apps.
Need this built? See Next.js web apps or get in touch.
By