I had 5 Claude-powered Vercel apps returning mysterious authentication errors overnight — despite the API key being correctly set. The error pointed at auth but the real problem was the Anthropic SDK's streaming implementation. Here's the exact fix.
The symptom
You deploy a Next.js app to Vercel. You set ANTHROPIC_API_KEY in project settings. The API route uses the Anthropic Node.js SDK. Locally, everything works. On Vercel, you get one of these:
Connection error.
Could not resolve authentication method.
Expected either apiKey or authToken to be set.Or the response body is empty. Or the request returns 500. You check the Vercel logs — the API key is there. You redeploy with logging. The key is being read. The SDK still fails.
Why it happens
The Anthropic Node.js SDK v0.37.0+ has a compatibility issue with Vercel's serverless function runtime for streaming responses. The SDK uses a specific mechanism to resolve the API key from environment variables at the time of the streaming call, and Vercel's runtime isolates environment variables differently than a standard Node.js process. The result is that the SDK can't find the key it was explicitly passed — even when you do new Anthropic({apiKey: process.env.ANTHROPIC_API_KEY}).
Non-streaming calls (client.messages.create() without stream: true) may work fine. The issue is specific to client.messages.stream() and the underlying streaming implementation.
The fix: raw fetch
Replace the SDK entirely with a raw fetch() call to the Anthropic API. This works reliably on Vercel (and every other runtime) because it doesn't depend on any SDK-level environment resolution.
Here's the complete before/after for a streaming API route:
Before (SDK — broken on Vercel)
import Anthropic from "@anthropic-ai/sdk";
export async function POST(req: Request) {
const { messages } = await req.json();
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY
});
const stream = await client.messages.stream({
model: "claude-haiku-4-5",
max_tokens: 1500,
system: "You are a helpful assistant.",
messages,
});
// This crashes on Vercel — Connection error
return new Response(stream.toReadableStream());
}After (raw fetch — works everywhere)
export async function POST(req: Request) {
const { messages } = await req.json();
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.ANTHROPIC_API_KEY!,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-haiku-4-5",
max_tokens: 1500,
stream: true,
system: "You are a helpful assistant.",
messages,
}),
});
if (!response.ok) {
const error = await response.text();
return new Response(JSON.stringify({ error }), { status: response.status });
}
// Transform SSE to text stream
const readable = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
try {
const event = JSON.parse(data);
if (
event.type === "content_block_delta" &&
event.delta?.type === "text_delta"
) {
controller.enqueue(encoder.encode(event.delta.text));
}
} catch {
// Skip malformed events
}
}
}
}
} finally {
controller.close();
}
},
});
return new Response(readable, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-cache",
},
});
}How to consume the stream on the client
The stream above returns plain text — each chunk is the raw text delta. On the client:
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
// Append text to your state
setOutput(prev => prev + text);
}If you need token counts or stop reasons
The simple version above discards the Anthropic SSE metadata (token usage, stop reason, etc.). If you need those, add a second emit layer: instead of emitting raw text, emit JSON events:
// In the SSE parsing loop:
if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
const payload = JSON.stringify({ type: "text", text: event.delta.text });
controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
}
if (event.type === "message_start") {
inputTokens = event.message?.usage?.input_tokens || 0;
}
if (event.type === "message_delta") {
outputTokens = event.usage?.output_tokens || 0;
}
if (event.type === "message_stop") {
const payload = JSON.stringify({ type: "done", inputTokens, outputTokens });
controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
}Then set the response content type to text/event-stream and parse accordingly on the client.
Does this affect non-streaming calls?
Non-streaming client.messages.create() calls appear to work correctly on Vercel with the SDK. The bug is specific to the streaming path. However, I've switched all my Vercel AI routes to raw fetch anyway — it removes a dependency, works on any runtime (Vercel Edge, Cloudflare Workers, Node.js, Deno), and is less code.
Why not just use the Vercel AI SDK?
The Vercel AI SDK (ai package) handles some of this boilerplate and works reliably with Anthropic via the @ai-sdk/anthropic adapter. If you're starting a new project and want a higher-level abstraction, it's a solid choice.
I prefer raw fetch for demos and portfolio projects because it makes the protocol visible — you can see exactly what's going over the wire, which is useful when you're building to understand the technology, not just ship a product.
The underlying Anthropic SSE format
Understanding the raw format helps when debugging. The Anthropic streaming API returns newline-delimited SSE events in this order:
event: message_start # input_tokens, model, etc.
data: {"type":"message_start","message":{...}}
event: content_block_start # starts a text content block
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: content_block_delta # the actual text tokens
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
... (more delta events)
event: content_block_stop
event: message_delta # output_tokens in usage field
event: message_stopFor most use cases, you only need to handle content_block_delta events where delta.type === "text_delta". The rest is metadata.
Quick checklist if you're debugging this
- ✅
ANTHROPIC_API_KEYis set in Vercel project settings (not just .env.local) - ✅ Using raw fetch, not the SDK, for streaming routes
- ✅ Model ID is correct for your API key — older model IDs like
claude-3-5-haiku-20241022may 404 if your key was issued after a model deprecation. Useclaude-haiku-4-5orclaude-sonnet-4-6 - ✅ Content-Type is
application/jsonon the fetch request, nottext/plain - ✅
anthropic-version: 2023-06-01header is included (required) - ✅
stream: trueis in the request body (not just a header)