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

bash
// 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

bash
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.

bash
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:

bash
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:

bash
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:

bash
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:

bash
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.

Get API keyDocumentation

api.zukko.pro · fetch_*

Vercel AI SDK with Claude and GPT — Next.js setup · Zukko