Vercel AI SDK: connecting a Next.js app to Claude and GPT
The AI SDK's OpenAI and Anthropic providers build the path on top of baseURL differently — easy to get /v1 wrong in one route handler. Setup and the client-leak trap.
5 min · · Updated · development
Vercel AI SDK ships createOpenAI and createAnthropic factories that both accept baseURL and apiKey — that is the only thing you need to change to point streamText and generateText at a gateway instead of OpenAI's or Anthropic's own API.
In Next.js specifically, the provider usually lives inside a server-side route handler, while the client-side useChat hook knows nothing about baseURL — it just calls your own /api/chat. Because of that, gateway connection errors on the client all look the same ("something went wrong"), and the actual debugging happens on the server: in the route handler's logs, not the browser console.
OpenAI-compatible provider in a route handler
// app/api/chat/route.ts
import { createOpenAI } from '@ai-sdk/openai';
import { streamText } from 'ai';
const zukko = createOpenAI({
baseURL: 'https://api.zukko.pro/v1',
apiKey: process.env.ZUKKO_API_KEY,
});
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: zukko('gpt-5.6-sol'),
messages,
});
return result.toDataStreamResponse();
}The key is read from process.env.ZUKKO_API_KEY — no NEXT_PUBLIC_ prefix. This isn't a style preference: any variable with that prefix ends up in Next.js's client bundle and is visible in the browser's source. A route handler runs server-side, so a plain server env var is both enough and safe.
Anthropic-compatible provider for Claude
import { createAnthropic } from '@ai-sdk/anthropic';
import { streamText } from 'ai';
const claude = createAnthropic({
baseURL: 'https://api.zukko.pro/v1',
apiKey: process.env.ZUKKO_API_KEY,
});
const result = streamText({
model: claude('claude-sonnet-5'),
messages,
});Here baseURL includes /v1: the AI SDK's Anthropic provider appends only /messages to it (and sets the anthropic-version: 2023-06-01 header itself). That differs from Claude Code and the official Anthropic SDK, which need the address without /v1. If a project uses both providers — say, GPT for fast answers and Claude Sonnet 5 for tasks that need extended thinking — this is exactly where the common mistake happens: out of Claude Code habit, giving the Anthropic provider https://api.zukko.pro with no suffix. Both providers initialize without complaint, but the first call through claude(...) returns 404, because the request goes to /messages instead of /v1/messages.
Picking a model per task
It's worth keeping both providers configured from the start rather than wiring up the second one only when needed: the moment a task in the project calls for extended thinking or analyzing a multi-megabyte image, switching over is a one-line change to model, not a refactor of the whole route handler onto a different SDK.
const model = useVision
? claude('claude-sonnet-5')
: zukko('glm-5.3-flash');
const result = streamText({ model, messages });The Claude and GPT models in the catalogue accept images and carry a 1,000,000-token context; GLM has 128K and no vision, which is why the example sends image requests to Claude and text-only ones to the cheap glm-5.3-flash. Switching between models inside the same route handler needs no change to the message format. The current model list is at GET https://api.zukko.pro/v1/models.
Generating images in the same app
Image generation through Zukko is temporarily unavailable right now, so there's no need for a separate image route handler yet. When it returns, it will be a separate endpoint billed per image rather than per token — and it's easier to call with a plain fetch from your own route handler than through streamText. The current model list is at GET https://api.zukko.pro/v1/models or on the Models page in the dashboard.
Web search for models that don't have it
No model in the catalogue browses the web on its own — that's what POST /v1/web-search is for, and it wraps neatly into a tool for streamText:
import { tool } from 'ai';
import { z } from 'zod';
const webSearch = tool({
description: 'Looks up current information online',
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => {
const res = await fetch('https://api.zukko.pro/v1/web-search', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZUKKO_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
});
return res.json();
},
});
const result = streamText({ model: zukko('gpt-5.6-sol'), messages, tools: { webSearch } });It's a separate billed call with its own line in GET /v1/usage, not part of the main chat request's cost.
A 403 in a multi-tenant app
If an app hands out separate fetch_... keys to different customers (say, each tenant has its own key with its own limit), it's worth planning for 403 model_not_allowed_for_key ahead of time — it fires when a key's dashboard page restricts it to a specific model allowlist and the route handler tries to call a model outside that list:
try {
const result = streamText({ model: zukko(requestedModel), messages });
return result.toDataStreamResponse();
} catch (err) {
if (err?.code === 'model_not_allowed_for_key') {
return Response.json({ error: 'This model is not available on your plan' }, { status: 403 });
}
throw err;
}Without that handling, a user on a plan with a restricted model list sees a vague 500 instead of a clear message that the model just isn't allowed for them. The same applies to 402 insufficient_balance — but keep in mind the balance belongs to the account, not the key: if every tenant is on a key from the same account, the balance runs out for all of them at once. A separate budget per customer comes from key limits — a tenant who hits their own limit gets 429 key_daily_limit_reached or key_monthly_limit_reached, and that's worth surfacing directly in the UI rather than burying in a shared error log.
Idempotency on a broken stream
useChat on the client automatically retries when a connection drops mid-stream. It's important to understand that the drop itself doesn't stop the charge — the gateway reads the provider's response to the end and bills the whole generation. To keep the retry from being billed again as a new call, pass an Idempotency-Key through the headers of that specific streamText call. requestId must be unique per message and identical on its retry — the message id sent from the client, for instance, not a value the route handler generates afresh for every request:
const result = streamText({
model: zukko('gpt-5.6-sol'),
messages,
headers: { 'Idempotency-Key': requestId },
});Repeating the same header value within 24 hours returns the same answer without charging again — useful on a flaky mobile connection, where a stream cutting off mid-response is almost always a network timeout, not a token limit.
A quick check outside Next.js
Before digging into why the route handler returns an empty stream, verify the key and model work directly:
curl https://api.zukko.pro/v1/chat/completions \
-H "Authorization: Bearer fetch_your_key" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-5.6-sol","messages":[{"role":"user","content":"ping"}]}'If curl answers but Next.js doesn't, the cause is almost always an environment variable that never reached the server runtime — for example, forgetting to restart next dev after editing .env.local. Balance and per-request spend are visible in the dashboard via GET /v1/balance and GET /v1/usage, and keys are issued at https://zukko.pro/app/api-keys. On a Vercel deployment, double-check the variable is added to the right environment (Production/Preview/Development) — a value set only for Development silently won't show up in production, and the symptom looks exactly like a missing key.
zukko
Pay 25×
less
for Claude Code & ChatGPT
One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.
Related articles
- Prompt Injection: What It Is and How to Protect a Bot and an AgentHow prompt injection works through user input and third-party documents, and which measures limit the damage: data isolation, tool permissions, key limits.
- Your Own Neural Network Proxy on Node.js: Keep the API Key Off the FrontendA small Express server between your frontend and the model API: the key stays on the server, and models, request size and rate are capped.
- DeepSeek API in Python: Requests, Streaming, JSON and Error HandlingReady code for DeepSeek V4.1 Flash and V4 Pro on the OpenAI SDK: a request, streaming, JSON parsing and careful error handling.
- Prompt Engineering Examples: 12 Ready-Made Templates for WorkTwelve prompt templates for everyday tasks: what each one says, why it is built that way, and what a single API call costs.