Concept
Every "AI feature" in a modern web app, a chat panel, a summarize button, an autocomplete suggestion, ultimately reduces to one thing: your server sending an HTTP request to POST /v1/messages and getting a structured response back. The SDK exists purely to make that one request shape pleasant to work with in TypeScript: typed request/response objects, automatic retries on 429/5xx, streaming helpers, and a tool_use loop helper, but underneath, it is always the same Messages API.
This app already has @anthropic-ai/sdk installed at ^0.106.0 (confirmed in package.json), this topic uses that exact SDK surface, not a hypothetical one.
The absolute minimum integration
import Anthropic from "@anthropic-ai/sdk";
// Zero-arg constructor resolves credentials from the environment:
// ANTHROPIC_API_KEY, or an `ant auth login` profile. Never hardcode a key.
const client = new Anthropic();
const response = await client.messages.create({
model: "claude-opus-4-8", // the CURRENT recommended default, not a placeholder
max_tokens: 1024,
messages: [{ role: "user", content: "Summarize this changelog in two sentences." }],
});
// response.content is a discriminated union (ContentBlock[]), narrow by `.type`
for (const block of response.content) {
if (block.type === "text") {
console.log(block.text);
}
}Two details matter more than they look like they should. First, model: "claude-opus-4-8" is not an arbitrary choice for this course, it is the actual current recommended default model id, and it is a complete string on its own (never append a date suffix like -20250601 to it; that pattern belongs to older, retired model ids only). Second, response.content is an array of typed content blocks, not a single string, a response can contain a text block, a tool_use block, or a thinking block, and production code must check block.type before reading block.text, exactly as shown above.
Why this call belongs on the server, never in the browser
// ❌ NEVER do this in a client component / browser bundle
"use client";
const client = new Anthropic({ apiKey: "sk-ant-..." }); // this key ships to every visitor's browserAn API key embedded in any code that reaches the browser, including a Next.js Client Component, a NEXT_PUBLIC_* env var, or inline <script> content, is visible to anyone who opens dev tools and reads the bundled JavaScript. The fix is always the same shape: the browser calls your own backend route (a Next.js Route Handler, an Express endpoint, a serverless function), and only that server-side code holds the real Anthropic API key.
// app/api/chat/route.ts, a Next.js Route Handler (runs server-side only)
import Anthropic from "@anthropic-ai/sdk";
import { NextRequest } from "next/server";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from server env, never sent to the client
export async function POST(req: NextRequest) {
const { prompt } = await req.json();
const response = await client.messages.create({
model: "claude-opus-4-8",
max_tokens: 1024,
messages: [{ role: "user", content: prompt }],
});
The browser fetches /api/chat; it never sees ANTHROPIC_API_KEY, never sees the SDK, and never sees the raw Messages API shape unless you choose to forward it.
Adaptive thinking and effort, the current controls, not budget_tokens
Older material describes extended thinking as a fixed budget_tokens number you had to hand-tune. That parameter is deprecated on current-generation models. The current mechanism is adaptive thinking plus an effort level, and it is confirmed directly against this app's installed SDK version:
const response = await client.messages.create({
model: "claude-opus-4-8",
max_tokens: 4096,
thinking: { type: "adaptive" }, // Claude decides internally how much to think
output_config: { effort: "high" }, // low | medium | high | xhigh | max, inside output_config, not top-level
messages: [{ role: "user", content: "Plan a database migration with zero downtime." }],
});thinking: { type: "adaptive" } hands the "how much reasoning does this need" decision to the model itself, rather than forcing you to pick a fixed token budget up front. effort is a separate, complementary dial: it controls the overall thoroughness/cost tradeoff (fewer, more consolidated tool calls and terser output at low; maximum thoroughness at max). The two compose, set adaptive thinking once, then tune effort per route based on how latency-sensitive or intelligence-sensitive that particular feature is.
const stream = client.messages.stream({model: 'claude-opus-4-8',max_tokens: 1024,messages: [{ role: 'user', content: 'Explain TCP handshakes' }],});
…
Calling .stream() opens a long-lived connection instead of waiting for one full response. Nothing has arrived yet, the UI should show a pending state, not a blank one.
Handling errors with typed exceptions
Never string-match error.message. The SDK exports a typed exception class per HTTP status:
import Anthropic from "@anthropic-ai/sdk";
try {
const response = await client.messages.create({ /* ... */ });
} catch (error) {
if (error instanceof Anthropic.RateLimitError) {
// 429, back off and retry; the SDK already retries 429/5xx internally up to max_retries
} else if (error instanceof Anthropic.AuthenticationError) {
// 401, bad or missing API key, fail fast, don't retry
} else if (error instanceof Anthropic.APIError) {
// any other non-2xx response
console.error(error.status, error.message);
}
}Catching from most-specific to least-specific preserves the distinction between retryable failures (rate limits, transient 5xx, connection errors, the SDK's default max_retries: 2 already covers these automatically) and non-retryable ones (a malformed request, a bad model id) that should surface to the user or the logs immediately instead of being silently retried.
Try It
Predict what this route handler actually sends to the browser before checking the solution.
export async function POST(req: NextRequest) {
const { prompt } = await req.json();
const response = await client.messages.create({
model: "claude-opus-4-8",
max_tokens: 200,
messages: [{ role: "user", content: prompt }],
});
return Response.json(response);
}What's wrong with returning response directly to the browser, compared to extracting just the text?
Solution
It works, but it leaks more than the UI needs and couples your frontend to the raw Messages API shape. response includes usage (token counts, arguably fine to expose, but it's billing-relevant internal data), the full content array (which a future model version could restructure, e.g. adding new block types), and potentially stop_reason values your frontend has no handling for yet. The safer pattern is to shape a small, stable response object yourself, { text: "...", stopReason: response.stop_reason }, so a future SDK upgrade that changes response's internal shape doesn't silently break frontend code that was reaching into response.content[0].text directly. This is the same reason REST APIs generally return a deliberate DTO instead of an ORM row.
Implement It Yourself
Build a tiny wrapper that adds the two things almost every production integration needs on top of the raw SDK call: a timeout override and a normalized error shape the frontend can render without knowing about SDK exception classes.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
type AskResult =
| { ok: true; text: string }
| { ok: false; code: "rate_limited" | "auth" | "bad_request" | "unknown"; message: string };
async function askClaude(prompt: string): Promise<AskResult> {
try {
// Per-request timeout override, client.with_options-style pattern.
This is the actual shape of a real integration layer: a typed result the rest of your app can switch on, with SDK-specific exception handling contained in one place instead of scattered through every route that calls Claude.
Under the Hood
The request/response cycle here is an ordinary HTTP call riding on Node's event loop, the await client.messages.create(...) call doesn't block the server process while waiting for Anthropic's response, which is the same non-blocking I/O model covered in The Node.js Event Loop. Keeping the API key server-side is a direct application of the same trust boundary covered in XSS: anything that reaches the browser bundle is untrusted-readable by definition, which is exactly why a secret key can never live in client-side code.
Common Mistakes
1. Calling the SDK from a Client Component
"use client"; // ❌ this file's code, and any imported secret, ships to the browser
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ apiKey: process.env.NEXT_PUBLIC_ANTHROPIC_KEY });NEXT_PUBLIC_* environment variables are inlined into the client bundle at build time by design, using that prefix for an API key is the same mistake as hardcoding it, just one layer removed. Anthropic API calls belong in a Route Handler, Server Action, or other server-only code path.
2. Reaching into response.content[0].text without checking .type
// ❌ assumes content[0] is always a text block
const text = response.content[0].text;If the model's first content block happens to be a thinking block (when adaptive thinking is enabled) or a tool_use block, .text is undefined and this either throws (TypeScript will actually flag this as a type error on the discriminated union) or silently produces undefined in a JS codebase. Always find the block by .type === "text" rather than assuming the position.
3. Retrying every error, including 400s
// ❌ retries a permanently-invalid request forever
while (true) {
try {
return await client.messages.create(params);
} catch {
continue; // a bad model id or malformed schema will never succeed on retry
}
}A 400 (bad request) or 401 (auth) is not a transient failure, retrying it wastes time and can mask a real bug (a typo'd model id, a missing required field) as an intermittent network issue. Only retry 429 and 5xx-class errors, and prefer the SDK's built-in max_retries over a hand-rolled loop.
Best Practices
- Always call the SDK from server-side code, Route Handlers, Server Actions, serverless functions, never from a Client Component or any code that ends up in the browser bundle.
- Default to
claude-opus-4-8unless you have a specific, measured reason to use a different tier, it is the current recommended model for this SDK version. - Use
thinking: { type: "adaptive" }plusoutput_config.effortinstead of a fixedbudget_tokensvalue, the model self-regulates reasoning depth, andefforttunes the cost/quality tradeoff explicitly. - Catch the SDK's typed exception classes, ordered most-specific to least-specific, instead of string-matching
error.message. - Shape a small, stable response object for the frontend instead of forwarding the raw SDK response, it insulates your UI from future SDK/response-shape changes.
Performance Tips
- Reuse a single
Anthropicclient instance across requests (module-level, not re-constructed per request), the SDK manages its own HTTP connection pooling internally. - For any response likely to exceed roughly 16,000 output tokens, switch to
client.messages.stream(), non-streaming requests above that size risk hitting SDK-level HTTP timeouts (covered in depth in the streaming topic next). - Set a request-level timeout override (
{ timeout: 15_000 }in milliseconds for TypeScript) on latency-sensitive routes so a slow model response doesn't hold a serverless function open indefinitely.
