Build a streaming Claude AI chatbot in Next.js using the App Router. Route handler, useChat hook, and full TypeScript example with Anthropic SDK.
npx create-next-app@latest my-claude-app --typescript --app
cd my-claude-app
npm install @anthropic-ai/sdk
Add your key to .env.local (never prefix with NEXT_PUBLIC_):
ANTHROPIC_API_KEY=sk-ant-...
import Anthropic from "@anthropic-ai/sdk";
import { NextRequest } from "next/server";
const client = new Anthropic();
export async function POST(req: NextRequest) {
const { messages } = await req.json();
const stream = await client.messages.create({
model: "claude-sonnet-4-6-20251001",
max_tokens: 1024,
stream: true,
messages,
});
const readable = new ReadableStream({
async start(controller) {
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
controller.enqueue(new TextEncoder().encode(event.delta.text));
}
}
controller.close();
},
});
return new Response(readable, {
headers: { "Content-Type": "text/plain; charset=utf-8", "Transfer-Encoding": "chunked" },
});
}
"use client";
import { useState, useRef, useEffect } from "react";
type Message = { role: "user" | "assistant"; content: string };
export default function ChatPage() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
async function send() {
if (!input.trim() || streaming) return;
const userMsg: Message = { role: "user", content: input };
const newMessages = [...messages, userMsg];
setMessages([...newMessages, { role: "assistant", content: "" }]);
setInput("");
setStreaming(true);
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: newMessages }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let done = false;
while (!done) {
const { value, done: dr } = await reader.read();
done = dr;
const chunk = decoder.decode(value, { stream: !done });
setMessages((m) => {
const last = m[m.length - 1];
return [...m.slice(0, -1), { ...last, content: last.content + chunk }];
});
}
setStreaming(false);
}
return (
<main style={{ maxWidth: 640, margin: "2rem auto", padding: "0 1rem" }}>
<h1>Claude Chat</h1>
{messages.map((m, i) => (
<div key={i}><strong>{m.role === "user" ? "You" : "Claude"}:</strong>
<p style={{ whiteSpace: "pre-wrap" }}>{m.content}</p>
</div>
))}
<input value={input} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()} placeholder="Ask Claude…" />
<button onClick={send} disabled={streaming}>{streaming ? "…" : "Send"}</button>
</main>
);
}
npm install ai @ai-sdk/anthropic
// app/api/chat/route.ts
import { anthropic } from "@ai-sdk/anthropic";
import { streamText } from "ai";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
return streamText({ model: anthropic("claude-sonnet-4-6-20251001"), messages }).toDataStreamResponse();
}
// app/page.tsx
"use client";
import { useChat } from "ai/react";
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<form onSubmit={handleSubmit}>
{messages.map((m) => <div key={m.id}><b>{m.role}</b>: {m.content}</div>)}
<input value={input} onChange={handleInputChange} placeholder="Message…" />
<button>Send</button>
</form>
);
}
| Approach | Pros | Cons |
|---|---|---|
| Raw Anthropic SDK | No extra deps, full control over streaming | Manual stream piping boilerplate |
| Vercel AI SDK + @ai-sdk/anthropic | useChat hook, auto retry, 10 lines total | Extra dependency, Vercel-specific patterns |
Estimate request costs with the Claude API Cost Calculator.