Oct 2026 · Tutorial · ~11 min read
Claude API + Next.js — Building AI Features in Your Web App
By Safdar Ali — frontend engineer, Bengaluru
I'm Safdar Ali, a frontend engineer in Bengaluru. Clients increasingly ask for AI features inside their Next.js apps — chat assistants, content summarisers, smart search. The pattern is always the same: call Claude from the server, stream the response, rate-limit abuse, never expose your API key. This guide walks through a production-ready claude api nextjs setup I use on safdarali.in experiments and client dashboards. No vendor lock-in beyond Anthropic's SDK — the architecture ports to any LLM provider.
Architecture — why server-side only
Never call Claude from the browser with your API key. Anyone can open DevTools, steal the key, and run up your bill. All Claude calls belong in Route Handlers, Server Actions, or dedicated API routes — behind auth and rate limits.
// WRONG — API key in client bundle
"use client";
const res = await fetch("https://api.anthropic.com/v1/messages", {
headers: { "x-api-key": process.env.NEXT_PUBLIC_CLAUDE_KEY! }, // exposed!
});
// RIGHT — server Route Handler
// app/api/chat/route.ts
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY, // server-only env var
});
export async function POST(req: Request) {
const { message } = await req.json();
const response = await client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
messages: [{ role: "user", content: message }],
});
return Response.json({ text: response.content[0].type === "text" ? response.content[0].text : "" });
}Store ANTHROPIC_API_KEY in .env.local without the NEXT_PUBLIC_ prefix. Add it to Vercel project env vars for production deploys.
Install the SDK once per project: npm install @anthropic-ai/sdk. I pin the version in package.json — AI SDKs update frequently and breaking changes in streaming APIs have caught me twice when I left the caret range too loose. Lock it, test streaming after every upgrade.
Model selection in 2026: Claude Sonnet balances cost and quality for most web app features — chat assistants, summarisation, content suggestions. Opus is for complex reasoning tasks where latency matters less. Haiku for high-volume, low-complexity classification. Start with Sonnet; upgrade only when quality feedback demands it.
Basic Claude API route in Next.js App Router
// app/api/chat/route.ts
import Anthropic from "@anthropic-ai/sdk";
import { NextRequest } from "next/server";
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const userMessage = body.message as string;
if (!userMessage || userMessage.length > 4000) {
return Response.json({ error: "Invalid message" }, { status: 400 });
}
const completion = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
system: "You are a helpful assistant for a developer portfolio site.",
messages: [{ role: "user", content: userMessage }],
});
const text =
completion.content[0]?.type === "text" ? completion.content[0].text : "";
return Response.json({ text });
} catch (error) {
console.error("Claude API error:", error);
return Response.json({ error: "AI service unavailable" }, { status: 503 });
}
}Client component calls /api/chat with fetch — no keys in the browser, no CORS headaches on same-origin deploys.
Wrap Claude calls in a thin service layer — lib/claude.ts — so route handlers stay thin and you can swap models or add logging in one place. I export a createChatCompletion function that accepts message, system prompt, and max tokens. Route handlers validate input and call the service; they do not instantiate the Anthropic client directly. That separation saved a model upgrade from touching six files down to one.
Streaming responses — better UX for chat UIs
Waiting 5–10 seconds for a full JSON response feels broken in chat interfaces. Stream tokens as they arrive using the Anthropic SDK's stream helper and Next.js ReadableStream.
// app/api/chat/stream/route.ts
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
export async function POST(request: Request) {
const { message } = await request.json();
const stream = await anthropic.messages.stream({
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
messages: [{ role: "user", content: message }],
});
const encoder = new TextEncoder();
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(encoder.encode(event.delta.text));
}
}
controller.close();
},
});
return new Response(readable, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}// BEFORE — client waits for full response, blank UI for seconds
const res = await fetch("/api/chat", { method: "POST", body: JSON.stringify({ message }) });
const data = await res.json();
setReply(data.text); // user sees nothing until here
// AFTER — client reads stream incrementally
const res = await fetch("/api/chat/stream", {
method: "POST",
body: JSON.stringify({ message }),
});
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let accumulated = "";
while (reader) {
const { done, value } = await reader.read();
if (done) break;
accumulated += decoder.decode(value);
setReply(accumulated); // UI updates token by token
}Rate limiting — protect your wallet
A public AI endpoint without rate limits is an invitation for abuse. I use a simple in-memory or Redis-backed limiter keyed by IP or user ID.
// lib/rate-limit.ts — simple sliding window
const requests = new Map<string, number[]>();
export function rateLimit(key: string, limit = 10, windowMs = 60_000): boolean {
const now = Date.now();
const timestamps = (requests.get(key) ?? []).filter((t) => now - t < windowMs);
if (timestamps.length >= limit) return false;
timestamps.push(now);
requests.set(key, timestamps);
return true;
}
// In route handler:
import { rateLimit } from "@/lib/rate-limit";
export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (!rateLimit(ip, 10, 60_000)) {
return Response.json({ error: "Too many requests" }, { status: 429 });
}
// ... Claude call
}For production at scale, swap the in-memory Map for Upstash Redis or Vercel KV — the in-memory version resets on every cold start and does not work across serverless instances.
Add per-user limits on top of IP limits if you have authenticated users — 50 requests per hour for logged-in users, 10 for anonymous. Store usage in Redis with TTL keys. On a portfolio chat experiment I ran last year, IP-only limiting blocked entire office buildings behind shared NAT; user-based keys fixed false positives.
Cost control goes beyond rate limits. Set max_tokens conservatively — 512 for short replies, 1024 for paragraphs. Log input length and reject prompts over 4,000 characters before they reach Claude. A single malicious user pasting War and Peace into your chat widget should cost you one 400 response, not a four-figure API bill.
Server Action vs Route Handler for Claude calls
| Criteria | Route Handler (/api/chat) | Server Action |
|---|---|---|
| Streaming | Native ReadableStream | Limited — prefer routes for streams |
| External clients (mobile app) | Yes | Next.js only |
| Form-based AI submit | Extra fetch layer | Clean fit |
| Rate limit middleware | Standard pattern | Inside action function |
| My default for chat UI | Yes — streaming route | One-shot summaries only |
Client chat component wired to streaming API
"use client";
import { useState } from "react";
export function ChatWidget() {
const [input, setInput] = useState("");
const [reply, setReply] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setReply("");
const res = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: input }),
});
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let text = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value);
setReply(text);
}
}
setLoading(false);
}
return (
<form onSubmit={handleSubmit}>
<textarea value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit" disabled={loading}>Ask</button>
<p>{reply}</p>
</form>
);
}Production checklist before launch
API key in server env only — verified. Rate limiting on every AI endpoint. Input length caps and basic sanitisation. Error responses that never leak stack traces or key names. Logging token usage for cost monitoring. Timeout handling — Claude calls can hang; set AbortSignal with a 30s ceiling. Auth gate if the feature is not public — free anonymous chat on a portfolio gets expensive fast.
I log approximate token counts per request to a simple analytics table. When a client's bill spiked 3x in one week, the logs showed a bot hitting an unauthenticated endpoint — rate limiting fixed it in an hour.
System prompts deserve the same care as API keys — they shape every response. Keep them in a server-side constants file, not hardcoded in the route handler where they get duplicated. Version them when you change behaviour so you can roll back if a prompt edit degrades output quality. For a content summariser on a client blog, we A/B tested two system prompts for a week before picking the shorter, more factual one.
Error handling for users should be human-readable — "AI service temporarily unavailable, try again in a minute" — not Anthropic error codes. Log the full error server-side with request ID for debugging. Never return error.message from the SDK directly to the client; it sometimes includes internal details you do not want public.
Test streaming under slow network conditions — Chrome DevTools throttling to Slow 3G — before launch. Streaming feels instant on office WiFi in Bengaluru; on mobile data in tier-2 cities, token-by-token rendering prevents the "frozen UI" problem that non-streaming endpoints create. Your users in India will hit slow networks first; test for them, not for your dev machine.
Environment setup checklist
# .env.local (never commit)
ANTHROPIC_API_KEY=sk-ant-...
# .env.example (commit this — no real keys)
ANTHROPIC_API_KEY=your_key_here
# package.json dependency
"@anthropic-ai/sdk": "^0.39.0"
# Vercel deploy: add ANTHROPIC_API_KEY in project settings
# Test locally: curl -X POST http://localhost:3000/api/chat \
# -H "Content-Type: application/json" \
# -d '{"message":"Hello"}'Rotate API keys quarterly. Anthropic dashboard shows usage by key — set billing alerts at ₹2,000 and ₹5,000 thresholds so you notice spikes before the invoice surprises you. For client projects, use separate keys per project so you can attribute costs and revoke without affecting other apps.
The single takeaway
Claude API + Next.js is straightforward when you respect server boundaries: keys stay server-side, responses stream to the client, abuse gets rate-limited. Start with a non-streaming route to prove the integration, add streaming for UX, add Redis rate limiting before you share the URL publicly.
Most Next.js apps in 2026 do not need a full AI platform — they need one or two well-bounded features with clear cost controls. Build the FAQ widget, ship it, measure engagement for two weeks, then decide if RAG, fine-tuning, or multi-turn memory is worth the complexity. The Claude API makes the first feature cheap; your engineering discipline keeps the bill predictable as usage grows.
Related: Cursor + Claude workflow, Server Actions guide. Questions: safdarali.in/contact.
Bookmark Anthropic's pricing page and SDK changelog — model names and per-token costs change. A production app hardcoding model strings without abstraction breaks silently when models deprecate. Abstract model selection behind one config constant and review the changelog when you deploy monthly.
If this helped you
I publish free tutorials and write-ups like this in my spare time — no paywall on the guides. If it saved you an afternoon of trial and error, you can support the work:
- Buy me a coffee at buymeacoffee.com/safdarali
- Subscribe to my YouTube channel — it's free; 70+ React & Next.js tutorials
Related reading
More guides on safdarali.in — same author, production-focused.
- Tutorial
Prisma + PostgreSQL + Next.js — Full Stack Setup Guide
Prisma Next.js tutorial — PostgreSQL setup, schema design, server-side queries, and production deploy checklist.
Jul 2026Read article →
- Tutorial
Next.js App Router Complete Beginner Guide 2026
Next.js App Router tutorial 2026 — complete beginner guide with layouts, Server Components, and step-by-step code.
Jun 2026Read article →