Build a working Claude chatbot in React using the Anthropic SDK via a backend proxy. Full source for the React chat UI, streaming hooks, and a minimal Express or Next.js API route.
Browser-side React cannot call the Claude API directly (your key would be exposed). The correct pattern: React UI → backend proxy (Express/Next.js) → Anthropic API. This guide shows the full stack.
The Route Handler runs server-side, so no separate Express server is needed.
import Anthropic from "@anthropic-ai/sdk";
import { NextResponse } from "next/server";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from env
export async function POST(req) {
const { messages } = await req.json();
const stream = await client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages,
});
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
controller.enqueue(encoder.encode(chunk.delta.text));
}
}
controller.close();
},
});
return new Response(readable, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
"use client";
import { useState, useRef } from "react";
export default function Chat() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
async function send(e) {
e.preventDefault();
if (!input.trim() || streaming) return;
const userMsg = { role: "user", content: input };
const next = [...messages, userMsg];
setMessages(next);
setInput("");
setStreaming(true);
// Add empty assistant placeholder
setMessages([...next, { role: "assistant", content: "" }]);
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: next }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let full = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
full += decoder.decode(value, { stream: true });
setMessages([...next, { role: "assistant", content: full }]);
}
setStreaming(false);
}
return (
<main style={{ maxWidth: 640, margin: "0 auto", padding: 24, fontFamily: "sans-serif" }}>
<h1>Claude Chat</h1>
<div style={{ minHeight: 300, border: "1px solid #ddd", borderRadius: 8, padding: 16, marginBottom: 16 }}>
{messages.map((m, i) => (
<div key={i} style={{ marginBottom: 12, textAlign: m.role === "user" ? "right" : "left" }}>
<span style={{
display: "inline-block", padding: "8px 12px", borderRadius: 16,
background: m.role === "user" ? "#0070f3" : "#f0f0f0",
color: m.role === "user" ? "#fff" : "#000",
}}>{m.content}</span>
</div>
))}
{streaming && <p style={{ color: "#aaa" }}>Claude is typing…</p>}
</div>
<form onSubmit={send} style={{ display: "flex", gap: 8 }}>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Ask Claude anything…"
style={{ flex: 1, padding: 10, borderRadius: 8, border: "1px solid #ddd" }}
/>
<button type="submit" disabled={streaming} style={{ padding: "10px 20px", borderRadius: 8 }}>
Send
</button>
</form>
</main>
);
}
import express from "express";
import Anthropic from "@anthropic-ai/sdk";
import cors from "cors";
const app = express();
app.use(cors({ origin: "http://localhost:5173" })); // Vite dev port
app.use(express.json());
const client = new Anthropic();
app.post("/api/chat", async (req, res) => {
const { messages } = req.body;
res.setHeader("Content-Type", "text/plain");
res.setHeader("Transfer-Encoding", "chunked");
const stream = await client.messages.stream({ model: "claude-sonnet-4-6", max_tokens: 1024, messages });
for await (const chunk of stream) {
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
res.write(chunk.delta.text);
}
}
res.end();
});
app.listen(3001, () => console.log("Proxy on :3001"));
import { useState, useCallback } from "react";
export function useClaudeStream() {
const [reply, setReply] = useState("");
const [loading, setLoading] = useState(false);
const send = useCallback(async (messages) => {
setLoading(true);
setReply("");
const res = await fetch("http://localhost:3001/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let full = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
full += decoder.decode(value, { stream: true });
setReply(full);
}
setLoading(false);
return full;
}, []);
return { reply, loading, send };
}
// npm install ai @ai-sdk/anthropic
// app/api/chat/route.js
import { anthropic } from "@ai-sdk/anthropic";
import { streamText } from "ai";
export async function POST(req) {
const { messages } = await req.json();
const result = streamText({ model: anthropic("claude-sonnet-4-6"), messages });
return result.toDataStreamResponse();
}
// components/Chat.jsx
import { useChat } from "ai/react";
export function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<form onSubmit={handleSubmit}>
{messages.map(m => <div key={m.id}>{m.role}: {m.content}</div>)}
<input value={input} onChange={handleInputChange} placeholder="Say something…" />
<button type="submit">Send</button>
</form>
);
}
| Approach | Setup complexity | Streaming | Best for |
|---|---|---|---|
| Next.js Route Handler (manual) | Low | ReadableStream | Full control, no extra deps |
| Vercel AI SDK + useChat | Very low | Built-in hook | Rapid prototyping |
| CRA/Vite + Express proxy | Medium | fetch reader loop | Non-Next.js React apps |
To estimate the cost of your Claude chatbot, use the Claude API Cost Calculator. For streaming patterns in other frameworks, see the FastAPI streaming guide and Next.js detailed example.