Руководство
Добавление генерации ИИ-изображений в приложение Next.js с помощью API hiapi
Прокси через обработчик маршрута, опрос асинхронных задач, постоянное хранилище и нулевой CLS — интеграция, близкая к production, примерно на 150 строках TypeScript.
Подключение модели генерации изображений к приложению Next.js 14 App Router — небольшой проект, но почти все допускают ошибки в одних и тех же четырёх ключевых моментах:
- API требует 60–120 секунд — слишком долго для одного клиентского запроса и слишком долго для большинства edge-сред выполнения.
- Ключ API нельзя отправлять в браузер, поэтому нужен обработчик маршрута в качестве прокси.
- URL результатов действуют недолго, поэтому необходимо постоянное хранилище до того, как пользователь обновит страницу.
- Размер изображения неизвестен на момент возврата, и при прямой отрисовке это серьёзно ломает совокупный сдвиг макета (Cumulative Layout Shift).
Этот туториал проведёт вас через интеграцию, близкую к production, с API изображений hiapi — создание задачи, опрос, постоянная загрузка и отрисовка без CLS — примерно на 150 строках TypeScript. Весь код можно сразу разместить в реальном каталоге app/.
Соглашение об асинхронных задачах
hiapi предоставляет единую точку входа для всех моделей изображений и видео: POST https://api.hiapi.ai/v1/tasks. Она полностью асинхронна — отправьте задачу, опрашивайте до достижения терминального состояния, затем скачайте результат. Никаких длительных HTTP-соединений, никаких 100-секундных таймаутов прокси и никаких подводных камней Edge-среды выполнения.

У задачи четыре возможных состояния:
pending— принята, но ещё не взята в работуrunning— генерируетсяsuccess—output[0].urlготов (подписанный, ограниченного срока действия)fail— заполненыerror.codeиerror.message
Одно изображение 1K обычно генерируется за 30–120 секунд. Большая часть времени уходит на работу модели; сам API не добавляет измеримых накладных расходов.
Шаг 1: настройка проекта
Вам понадобятся проект Next.js 14+ с App Router и одна переменная окружения:
# .env.local
HIAPI_API_KEY=sk-... # никогда не раскрывайте браузеру
Добавьте этот ключ как серверный секрет на вашей платформе развёртывания (Vercel, Cloudflare Pages, Railway). Не добавляйте к нему префикс NEXT_PUBLIC_.
Если вы хотите постоянное хранилище (рекомендуется — это рассматривается в шаге 5), также добавьте:
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=...
R2_PUBLIC_BASE=https://cdn.your-domain.com # собственный домен на бакете
Шаг 2: обработчик маршрута — зачем он нужен

Единственная причина существования обработчика маршрута: держать ключ API на сервере. Любой запрос, отправленный из React-компонента, выполняется в браузере пользователя, а любая строка, скомпилированная в клиентский код, находится на расстоянии одной вкладки DevTools от кражи.
Обработчик маршрута также даёт вам единое, поддающееся аудиту место для применения ограничения скорости, записи использования и проверки промптов до того, как запрос достигнет платной точки входа.
Создайте app/api/images/route.ts:
// app/api/images/route.ts
import { NextResponse } from "next/server";
export const runtime = "nodejs"; // длительный fetch требует Node, а не Edge
export const maxDuration = 180; // Vercel: продлить таймаут serverless-функции
const HIAPI = "https://api.hiapi.ai/v1/tasks";
type CreateBody = {
prompt: string;
model?: string;
aspectRatio?: string; // например "16:9", "3:2", "1:1"
resolution?: "1K" | "2K" | "4K";
};
export async function POST(req: Request) {
const body = (await req.json()) as CreateBody;
if (!body?.prompt || body.prompt.length > 2000) {
return NextResponse.json({ error: "invalid prompt" }, { status: 400 });
}
const res = await fetch(HIAPI, {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.HIAPI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: body.model ?? "Nano-Banana-2",
input: {
prompt: body.prompt,
aspect_ratio: body.aspectRatio ?? "16:9",
resolution: body.resolution ?? "1K",
},
}),
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: text }, { status: res.status });
}
const { data } = await res.json();
// data.taskId — это дескриптор, который клиент использует для опроса
return NextResponse.json({ taskId: data.taskId });
}
Две настройки важны, но их легко забыть:
runtime = "nodejs"— во многих средах Edge-среда выполнения ограничивает один fetch примерно 25 секундами, что не подходит для цикла опроса.maxDuration = 180— serverless-функции Vercel по умолчанию имеют таймаут 10 секунд на плане Hobby и 60 секунд на плане Pro; вы должны увеличить это значение вручную, иначе, если вы решите ждать на сервере, маршрут будет прерван посреди опроса.
Шаг 3: эндпоинт опроса (или: почему не стоит ждать на сервере)
Есть два варианта показать прогресс клиенту:
| Вариант | Плюсы | Минусы |
|---|---|---|
| Сервер ждёт и возвращает результат один раз | Нужен только один клиентский запрос | Занимает serverless-вызов на 60–120 секунд, легко уходит в таймаут, нет обратной связи о прогрессе |
Сервер возвращает taskId, клиент опрашивает |
Низкая нагрузка на сервер, прогресс в реальном времени | Нужно построить дополнительный эндпоинт |
В production второй вариант явно лучше. Добавьте app/api/images/[id]/route.ts:
// app/api/images/[id]/route.ts
import { NextResponse } from "next/server";
export const runtime = "nodejs";
const HIAPI = "https://api.hiapi.ai/v1/tasks";
export async function GET(
_req: Request,
{ params }: { params: { id: string } },
) {
const res = await fetch(`${HIAPI}/${params.id}`, {
headers: { "Authorization": `Bearer ${process.env.HIAPI_API_KEY}` },
cache: "no-store",
});
if (!res.ok) {
return NextResponse.json({ error: await res.text() }, { status: res.status });
}
const { data } = await res.json();
// status ∈ { pending, running, success, fail }
return NextResponse.json({
status: data.status,
url: data.output?.[0]?.url ?? null,
error: data.error ?? null,
});
}
Именно этот эндпоинт клиент опрашивает каждые 3 секунды. Каждый вызов быстрый (менее 200 мс) и не имеет состояния.
Шаг 4: клиентский компонент
Простая форма, состояние загрузки с индикатором и холст результата. Опрос настраивается через setInterval и очищается при достижении терминального состояния или размонтировании компонента.
// app/generate/page.tsx
"use client";
import { useEffect, useRef, useState } from "react";
type Status = "idle" | "queued" | "running" | "done" | "error";
export default function GeneratePage() {
const [prompt, setPrompt] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [url, setUrl] = useState<string | null>(null);
const [taskId, setTaskId] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => () => {
if (pollRef.current) clearInterval(pollRef.current);
}, []);
async function start() {
setStatus("queued");
setUrl(null);
const r = await fetch("/api/images", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, aspectRatio: "16:9" }),
});
if (!r.ok) return setStatus("error");
const { taskId } = await r.json();
setTaskId(taskId);
setStatus("running");
pollRef.current = setInterval(async () => {
const p = await fetch(`/api/images/${taskId}`).then(x => x.json());
if (p.status === "success") {
setUrl(p.url);
setStatus("done");
clearInterval(pollRef.current!);
} else if (p.status === "fail") {
setStatus("error");
clearInterval(pollRef.current!);
}
}, 3000);
}
return (
<main className="mx-auto max-w-2xl p-8 space-y-6">
<textarea
value={prompt}
onChange={e => setPrompt(e.target.value)}
rows={3}
className="w-full border rounded p-2"
placeholder="A sunset over a misty pine forest, cinematic, 16:9"
/>
<button
onClick={start}
disabled={!prompt || status === "queued" || status === "running"}
className="px-4 py-2 rounded bg-black text-white disabled:opacity-50"
>
{status === "running" ? "Generating…" : "Generate"}
</button>
{/* Зарезервированный холст — почему важен этот div с точным соотношением сторон, см. шаг 6 */}
<div className="relative w-full" style={{ aspectRatio: "16/9" }}>
{status === "running" && (
<div className="absolute inset-0 grid place-items-center bg-neutral-100 rounded">
<span className="text-neutral-500">Waiting on task {taskId?.slice(0, 8)}…</span>
</div>
)}
{url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={prompt}
className="absolute inset-0 w-full h-full object-cover rounded"
/>
)}
</div>
</main>
);
}
Несколько production-улучшений стоит отметить сейчас, их можно добавлять постепенно:
- Экспоненциальная задержка (3 с → 5 с → 8 с) сокращает число запросов при медленной генерации.
- Таймаут простоя (например, отказ через 240 секунд) предотвращает зомби-опрос мёртвых задач.
- Показывайте частичный текст прогресса —
"running"и"pending"— чтобы пользователь был уверен, что что-то происходит.
Шаг 5: постоянное сохранение результата — URL вывода истекают
При использовании любого современного API изображений самая распространённая ошибка — сохранить output[0].url в базу данных и считать, что всё готово. Эти URL подписаны и недолговечны (обычно от нескольких минут до нескольких часов), и при обновлении пользователем вы получите 404.
Решение — пропустить байты через ваш собственный сервер в постоянное хранилище. Тот же обработчик app/api/images/[id]/route.ts — правильное место — выполняйте только при первом получении ответа success, до возврата URL клиенту.
// добавьте в app/api/images/[id]/route.ts после проверки `data.status === "success"`
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
async function persist(taskId: string, sourceUrl: string): Promise<string> {
const bytes = new Uint8Array(await (await fetch(sourceUrl)).arrayBuffer());
const key = `generations/${taskId}.jpg`;
await s3.send(new PutObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: key,
Body: bytes,
ContentType: "image/jpeg",
CacheControl: "public, max-age=31536000, immutable",
}));
return `${process.env.R2_PUBLIC_BASE}/${key}`;
}
В маршруте состояния, как только статус становится success, используйте persist(), затем возвращайте клиенту постоянный URL, а не подписанный URL upstream. Клиент никогда не увидит истекающий URL, и обновление завтра всё ещё будет работать.
Если вы сохраняете в свою базу данных (Postgres, Supabase и т. д.), сохраняйте постоянный URL — а не подписанный.
Шаг 6: устранение CLS
Совокупный сдвиг макета — тихий убийца Core Web Vitals в приложениях с ИИ-изображениями. Изображение приходит более чем через 60 секунд после загрузки страницы. Если вы отрисовываете его с помощью <img> в контейнере без заданных размеров, каждый результат сдвигает всё, что находится под ним.
Две надёжные техники:
Зарезервируйте соотношение сторон через CSS. Именно это делает стиль aspectRatio: "16/9" в компоненте выше. Контейнер сохраняет своё пространство независимо от того, загрузилось ли изображение.
Согласуйте соотношение сторон в запросе. Отправьте в hiapi aspectRatio: "16:9", затем объявите 16/9 на контейнере. Оба должны совпадать, иначе появятся чёрные полосы или, что хуже, форма не совпадёт с зарезервированной.
Для компонента Next.js <Image> действует тот же принцип: передайте атрибуты width и height, соответствующие запрошенному соотношению. Не используйте fill, если родительский элемент уже не является блоком фиксированного размера.
import Image from "next/image";
<Image
src={persistedUrl}
alt={prompt}
width={1280}
height={720}
className="rounded"
priority={false}
/>
Шаг 7: выбор модели и оценка стоимости
Модели изображений на hiapi используют одинаковую структуру эндпоинта — отличается только поле model. Распространённые варианты, цена за изображение в разрешении по умолчанию:
| Модель | Цена / изображение 1K | Преимущества |
|---|---|---|
qwen-image-2.0 |
$0.025 | Самая дешёвая; по умолчанию 2K; сильная отрисовка китайского, японского и корейского текста |
gpt-image-2 |
$0.03 | Надёжные соотношения сторон; сильные типографские возможности |
Nano-Banana |
$0.05 | Очень быстрая (1–2 секунды); хорошая согласованность персонажей |
flux-1.1-pro |
$0.05 | Художественная фотореалистичность |
Nano-Banana-2 |
$0.085 (1K) / $0.076 (2K) / $0.114 (4K) | Высокое качество, нативный 4K |
Честный выбор по умолчанию зависит от задачи. Для макетов UI и заголовков gpt-image-2 — самый надёжный. Для нативной 4K-фотореалистичности выбирайте Nano-Banana-2. Для пакетной работы, где не жалко выбросить каждое изображение, выбирайте qwen-image-2.0 или Nano-Banana.
Для приложения со 100 тыс. пользователей, генерирующих по одному изображению в день за сессию:
qwen-image-2.0: $2,500/деньNano-Banana-21K: $8,500/день
Архитектура выше не меняется. Меняется только поле model в шаге 2.
Шаг 8: чек-лист для production
Перед запуском:
- Настройте ограничение скорости на обработчике маршрута — иначе один IP может исчерпать ваш месячный бюджет за один день. Подойдут Upstash, Cloudflare Rate Limiting или простой счётчик на Redis.
- Проверяйте длину и содержимое промпта на сервере перед пересылкой. Платформа hiapi отклоняет некоторое содержимое; вы не захотите платить за задержку round-trip ради ошибки 400.
- Установите суточную квоту на пользователя — храните количество генераций по
userIdиYYYY-MM-DDи отклоняйте 11-й раз за день с дружелюбным сообщением. - Сохраняйте до ответа — никогда не позволяйте URL результата дойти до клиента без прохождения через постоянное хранилище.
- Задайте размер контейнера результата заранее в соответствии с запрошенным соотношением сторон. Пропуск этого шага — разница между CLS 0 и CLS 0.4.
- Вынесите шаг сохранения в фон, чтобы клиент получал ответ за доли секунды: сразу верните URL upstream, запустите задачу в очереди для зеркалирования в R2 и обновления строки в базе данных.
Последний пункт заслуживает ещё одного замечания: большинству приложений он не нужен. Встроенное сохранение упрощает код, а дополнительные 1–2 секунды происходят после 60-секундного ожидания, когда пользователь уже смотрит на индикатор загрузки.
Что дальше
Теперь у вас есть полная, безопасная, CLS-безопасная интеграция API изображений hiapi в Next.js примерно на 150 строках TypeScript. Отсюда тот же шаблон POST /v1/tasks масштабируется на видеомодели (Seedance 2.0 для кинематографичных материалов), процессы image-to-image (просто добавьте image_url внутри input) и многораундовое редактирование — и всё это без изменения архитектуры опроса.
Если вам нужна рабочая отправная точка, страница моделей hiapi перечисляет все модели, отвечающие на этот эндпоинт, и приводит пример структуры ввода для каждой. Поместите любую из них в поле model выше, и тот же компонент отрисует результат.
Похожие статьи
Все статьи →-
Руководство
Как вызвать hailuo-2.3-fast/image-to-video через HiAPI API: практика с curl и Python
Первый рабочий запрос к быстрому и недорогому уровню генерации видео по изображению — от ID модели до загрузки MP4
-
Руководство
Как вызывать hailuo-2.3/text-to-video через API HiAPI: curl, Python и рабочие примеры запросов
-
Руководство
Использование Kandinsky 6.0 Pro для преобразования текста в видео на HiAPI: руководство по curl и Python
Практическая демонстрация с упором на запросы: построение вызова от промпта до видеоклипа, отправка запроса, опрос статуса завершения и скачивание результата