Next.js Route Handler for AI: streaming responses in the App Router
We write app/api/chat/route.ts that streams a model response through the OpenAI SDK, plus a client component that renders text as it is generated. The key stays on the server.
4 min · · development
In the App Router, server endpoints are written as Route Handlers — GET, POST and other functions in a route.ts file. If you connect an AI model with streaming to such a handler, the user sees the answer as it is generated, and the API key never leaves the server. Below is a working route.ts with the OpenAI SDK, a client chat component and a breakdown of the errors such routes usually trip over.
Installation and environment variables
npm install openaiThe .env.local file in the project root:
ZUKKO_API_KEY=fetch_your_key
The variable name has no NEXT_PUBLIC_ prefix, so Next.js won't inline it into the client bundle — only server code can read it. Don't commit .env.local. Create the key on the API keys page.
A Next.js Route Handler with streaming
File app/api/chat/route.ts:
import OpenAI from "openai";
export const runtime = "nodejs";
const client = new OpenAI({
apiKey: process.env.ZUKKO_API_KEY,
baseURL: "https://api.zukko.pro/v1",
});
type ChatMessage = { role: "user" | "assistant"; content: string };
const ERRORS: Record<number, string> = {
401: "Invalid API key on the server",
402: "Insufficient balance",What matters in this code
- Base URL is
https://api.zukko.pro/v1, and the key is read fromprocess.env.ZUKKO_API_KEY. The client is created once at module level. - Bytes, not strings. A
Responsebody must consist ofUint8Arraychunks, so every piece of text is encoded withTextEncoder. If you enqueue a string, Node.js throws an error about a non-Uint8Array chunk. The streaming example in the Next.js documentation works the same way. - Empty
choices. The last chunk of the stream carries onlyusageand an emptychoicesarray. The lineif (chunk.choices.length === 0) continue;skips it — without it, accessingchunk.choices[0].deltafails at the very end of the answer. close()anderror()are called on separate branches. Callingclose()aftererror()throws by itself.- Cancellation. If the client closes the tab or aborts
fetch, the stream is cancelled andcancel()runs; it aborts the model request throughAbortController. - Errors before the stream starts (invalid key, no balance, model not allowed) are returned as regular JSON with the same status — the client checks
response.ok. A 503 with the codemodel_disabledmeans the model is turned off: pick an available one fromGET https://api.zukko.pro/v1/models. messages.slice(-20)caps the history: each request is billed together with the whole history you send.content-type: text/plain. We return raw text, not SSE events, sotext/event-streamwould be wrong here.
Client component
File app/chat.tsx:
"use client";
import { useState } from "react";
type Message = { role: "user" | "assistant"; content: string };
export default function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
async function send(e: React.FormEvent) {
e.preventDefault();
const text = input.trim();And render the component in app/page.tsx:
import Chat from "./chat";
export default function Page() {
return <Chat />;
}The component immediately adds an empty assistant message and appends every incoming piece to it. State is updated without mutation — via map with a new object for the last message; otherwise, in React strict mode, text can get duplicated.
Models and parameters
Switching models is a one-line change: claude-opus-5-5, gpt-5.6-sol, glm-5.3-flash and others. The model list changes; the current one is at GET https://api.zukko.pro/v1/models or on the "Models" page in your dashboard. Test which model is best for your product on your own conversations.
Be careful with temperature and top_p: newer Claude models (claude-sonnet-5, claude-opus-5-5) may reject a temperature other than 1 and a top_p below 0.99 with a 400 error. Better not to pass these parameters to them. For GPT and GLM you can set them as usual:
const stream = await client.chat.completions.create({
model: "gpt-5.6-sol",
messages,
stream: true,
temperature: 0.7,
max_tokens: 2048,
});You can estimate spend like this: with 2,000 input and 500 output tokens on claude-sonnet-5, a request costs (2,000 × 0.3 + 500 × 0.3) / 1,000,000 = $0.00075. See the full picture per key in the "Usage" section of the dashboard, and per-key limits in the limits documentation. The request format is described in the chat completions reference.
FAQ
Do I need the Anthropic SDK for Claude models?
No. All models — Claude, GPT, GLM — are available through the OpenAI-compatible format; the gateway converts the request. If you do need the Anthropic SDK, its base URL is https://api.zukko.pro without /v1; see the article on choosing a format.
Can I use the Vercel AI SDK?
Yes, the Next.js documentation mentions it as a ready-made streaming wrapper. Connect it through an OpenAI-compatible provider with the base URL https://api.zukko.pro/v1, and check the exact option names in the AI SDK documentation. The code above shows that streaming takes only a few dozen lines without a library.
Why does the answer arrive all at once instead of streaming?
Make sure the request has stream: true and that there's no buffering proxy between client and server. When self-hosting behind nginx, disable buffering for this route.
Can the key leak to the browser?
No, as long as the variable doesn't start with NEXT_PUBLIC_ and is used only in route.ts. The browser sees only the request to /api/chat. To be safe, use a separate key with a daily limit for the site.
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.