Claude API Next.js Chatbot Tutorial (App Router)
If you're searching for a Claude API Next.js chatbot tutorial, you want a working app, not a conceptual overview. This guide builds a complete chatbot in Next.js 14 (App Router): a server-side route that calls Claude, a streaming response back to the browser, and a minimal React chat UI you can extend. Everything here runs with standard fetch and the Next.js Route Handlers — no extra chat library required.
By the end you'll have a /app/api/chat/route.ts endpoint and a <Chat /> client component that streams tokens into the UI as they arrive, which is what makes a chatbot feel responsive instead of sluggish.
Project setup
Start with a fresh Next.js app using the App Router:
npx create-next-app@latest claude-chatbot --typescript --app
cd claude-chatbot
Add your API key to .env.local. You can use Anthropic's API directly, or route through an API gateway like SubToAPI if you want a single key, usage dashboards, and team seats on top of your existing Claude access:
SUBTOAPI_KEY=sub_live_xxxxxxxxxxxx
The server-side API route
Keep the API key on the server. Never call Claude directly from client-side JavaScript — your key would be exposed in the browser. In Next.js, a Route Handler is the right place for this.
Create app/api/chat/route.ts:
export async function POST(req: Request) {
const { messages } = await req.json();
const upstream = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SUBTOAPI_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4-5",
max_tokens: 1024,
stream: true,
messages,
}),
});
if (!upstream.ok || !upstream.body) {
return new Response("Upstream error", { status: 502 });
}
return new Response(upstream.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
}
This route takes a conversation history (messages), forwards it to the Messages endpoint with stream: true, and pipes the server-sent events straight through to the client. No buffering, no manual chunk reassembly — the browser reads the same SSE stream the API returns.
Building the chat UI
The frontend needs three things: a list of messages, an input box, and a function that reads the streamed response and appends tokens to the last assistant message.
Create app/components/Chat.tsx:
"use client";
import { useState, useRef } from "react";
type Message = { role: "user" | "assistant"; content: string };
export default function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
async function sendMessage() {
if (!input.trim()) return;
const next = [...messages, { role: "user" as const, content: input }];
setMessages([...next, { role: "assistant", content: "" }]);
setInput("");
setLoading(true);
controllerRef.current = new AbortController();
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: next }),
signal: controllerRef.current.signal,
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let text = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
for (const line of chunk.split("\n")) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6);
if (payload === "[DONE]") continue;
try {
const event = JSON.parse(payload);
if (event.type === "content_block_delta") {
text += event.delta?.text ?? "";
setMessages((prev) => {
const copy = [...prev];
copy[copy.length - 1] = { role: "assistant", content: text };
return copy;
});
}
} catch {
// ignore partial JSON fragments
}
}
}
setLoading(false);
}
return (
<div style={{ maxWidth: 640, margin: "0 auto", padding: 24 }}>
<div style={{ minHeight: 300 }}>
{messages.map((m, i) => (
<p key={i}>
<strong>{m.role === "user" ? "You" : "Claude"}:</strong> {m.content}
</p>
))}
</div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
disabled={loading}
placeholder="Ask something…"
style={{ width: "100%", padding: 8 }}
/>
</div>
);
}
Drop <Chat /> into app/page.tsx and you have a working, streaming chatbot.
Handling conversation state and system prompts
Claude's Messages API is stateless — it doesn't remember previous turns on its own. Your route needs to send the full message history on every request, which is exactly what the messages array in the code above does. If you want a consistent persona or set of instructions, add a system field to the request body in the route handler rather than injecting it as a fake user message.
For production use, trim the message history before sending it (e.g., keep the last 20 turns) so token usage and latency stay predictable as conversations grow.
Why route through an API layer instead of calling Anthropic directly
You can swap the fetch URL in the route handler for Anthropic's own endpoint and it will work the same way — the Messages payload shape is identical. The reason to put something like SubToAPI in between is operational: you get a single sub_live_... key instead of juggling raw Anthropic credentials across environments, per-key usage metadata so you can see what each app or teammate is spending, and team seats if more than one person needs access. None of that changes the Next.js code above — you're just pointing at a different base URL.
If you're building past a single demo chatbot — multiple apps, a staging and production key, or a team that needs its own dashboard — check the pricing page and the quickstart to see how keys and seats are organized. The streaming docs and messages reference cover the exact request/response shapes used in this tutorial.
Deploying
This pattern works unchanged on Vercel, since Route Handlers run as serverless functions by default. Set SUBTOAPI_KEY (or your Anthropic key) as an environment variable in the Vercel project settings, deploy, and the streaming behavior carries over — Vercel's edge/serverless functions support streaming Response bodies natively.
questions
Does this work with the regular Anthropic API instead of a gateway? Yes. Swap the URL and Authorization header in the route handler for Anthropic's endpoint and key format — the request and streaming response shapes are the same.
Why use a Route Handler instead of calling Claude from the client? Calling an LLM API from client-side code exposes your API key in the browser. A server-side Route Handler keeps the key private and lets you control rate limiting, logging, and history trimming in one place.
How do I add tool use or function calling to this chatbot? Add a tools array to the request body in the route handler, following the same schema used for text messages. See the tool use docs for request/response examples you can drop into this setup.