Next.js Route Handler для нейросети: стриминг ответа в App Router

Пишем app/api/chat/route.ts, который стримит ответ модели через OpenAI SDK, и клиентский компонент, который выводит текст по мере генерации. Ключ остаётся на сервере.

4 мин · · разработка

В App Router серверные эндпоинты пишутся как Route Handler — функции GET, POST и т. д. в файле route.ts. Если подключить к такому обработчику нейросеть со стримингом, пользователь видит ответ по мере генерации, а API-ключ не покидает сервер. Ниже — рабочий route.ts на OpenAI SDK, клиентский компонент чата и разбор ошибок, на которых такие маршруты обычно ломаются.

Установка и переменные окружения

bash
npm install openai

Файл .env.local в корне проекта:

ZUKKO_API_KEY=fetch_ваш_ключ

Имя переменной без префикса NEXT_PUBLIC_, поэтому Next.js не встроит её в клиентский бандл — она доступна только серверному коду. Файл .env.local не коммитится. Ключ создаётся на странице API-ключи.

Next.js Route Handler со стримингом

Файл app/api/chat/route.ts:

bash
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: "Неверный API-ключ на сервере",
  402: "На балансе недостаточно средств",

Что важно в этом коде

  • Базовый адрес — https://api.zukko.pro/v1, ключ читается из process.env.ZUKKO_API_KEY. Клиент создаётся один раз на уровне модуля.
  • Байты, а не строки. Тело Response должно состоять из Uint8Array, поэтому каждый кусок текста кодируется через TextEncoder. Если положить в поток строку, Node.js выдаст ошибку о не-Uint8Array чанке. Так же устроен пример со стримингом в документации Next.js.
  • Пустые choices. Последний чанк потока несёт только usage и пустой массив choices. Строка if (chunk.choices.length === 0) continue; пропускает его — без неё обращение к chunk.choices[0].delta упадёт в самом конце ответа.
  • close() и error() вызываются по разным веткам. Вызов close() после error() сам выбросит исключение.
  • Отмена. Если клиент закрыл вкладку или прервал fetch, поток отменяется и срабатывает cancel(); он прерывает запрос к модели через AbortController.
  • Ошибки до начала стрима (неверный ключ, нет баланса, модель не разрешена) возвращаются обычным JSON с тем же статусом — клиент проверяет response.ok. Ошибка 503 с кодом model_disabled означает, что модель выключена: возьмите доступную из GET https://api.zukko.pro/v1/models.
  • messages.slice(-20) ограничивает историю: каждый запрос оплачивается вместе со всей переданной историей.
  • content-type: text/plain. Мы отдаём сырой текст, а не события SSE, поэтому text/event-stream здесь был бы неверным.

Клиентский компонент

Файл app/chat.tsx:

bash
"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();

И подключите компонент на странице app/page.tsx:

bash
import Chat from "./chat";

export default function Page() {
  return <Chat />;
}

Компонент сразу добавляет пустое сообщение ассистента и дописывает в него каждый пришедший кусок. Состояние обновляется без мутаций — через map с новым объектом для последнего сообщения, иначе в строгом режиме React текст может задвоиться.

Модели и параметры

Модель меняется в одной строке: claude-opus-5-5, gpt-5.6-sol, glm-5.3-flash и другие. Набор моделей меняется, актуальный список — GET https://api.zukko.pro/v1/models или страница «Модели» в кабинете. Какая модель лучше для вашего продукта, проверьте на своих диалогах.

С temperature и top_p будьте осторожны: новые модели Claude (claude-sonnet-5, claude-opus-5-5) могут отклонить temperature, отличную от 1, и top_p ниже 0.99 с ошибкой 400. Для них эти параметры лучше не передавать. Для GPT и GLM их можно указывать как обычно:

bash
const stream = await client.chat.completions.create({
  model: "gpt-5.6-sol",
  messages,
  stream: true,
  temperature: 0.7,
  max_tokens: 2048,
});

Расход можно оценить так: при 2 000 входных и 500 выходных токенах на claude-sonnet-5 запрос стоит (2 000 × 0.3 + 500 × 0.3) / 1 000 000 = $0.00075. Полную картину по ключам смотрите в разделе «Использование» кабинета, а лимиты на ключ — в документации по лимитам. Формат запроса описан в справке по chat completions.

Частые вопросы

Нужен ли Anthropic SDK для моделей Claude?

Нет. Через OpenAI-совместимый формат доступны все модели: Claude, GPT, GLM — шлюз конвертирует запрос сам. Если вам всё же нужен Anthropic SDK, базовый адрес для него — https://api.zukko.pro без /v1; подробнее — в статье о выборе формата.

Можно ли использовать Vercel AI SDK?

Да, документация Next.js упоминает его как готовую обёртку для стриминга. Подключайте его через OpenAI-совместимого провайдера с базовым адресом https://api.zukko.pro/v1, а точные названия опций сверяйте с документацией AI SDK. Код выше показывает, что и без библиотеки стриминг занимает несколько десятков строк.

Почему ответ приходит целиком, а не по частям?

Проверьте, что в запросе стоит stream: true, а между клиентом и сервером нет буферизующего прокси. При деплое на собственный сервер за nginx отключите буферизацию для этого маршрута.

Может ли ключ утечь в браузер?

Нет, если переменная не начинается с NEXT_PUBLIC_ и используется только в route.ts. Браузер видит лишь запрос к /api/chat. Для надёжности заведите для сайта отдельный ключ с дневным лимитом.

zukko

Платите в 25×
меньше

за Claude Code & ChatGPT

Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.