HiAPI
Блог

Руководство

Добавление генерации ИИ-изображений в приложение Next.js с помощью API hiapi

Прокси через обработчик маршрута, опрос асинхронных задач, постоянное хранилище и нулевой CLS — интеграция, близкая к production, примерно на 150 строках TypeScript.

Jun 16, 2026
Добавление генерации ИИ-изображений в приложение Next.js с помощью API hiapi

Подключение модели генерации изображений к приложению Next.js 14 App Router — небольшой проект, но почти все допускают ошибки в одних и тех же четырёх ключевых моментах:

  1. API требует 60–120 секунд — слишком долго для одного клиентского запроса и слишком долго для большинства edge-сред выполнения.
  2. Ключ API нельзя отправлять в браузер, поэтому нужен обработчик маршрута в качестве прокси.
  3. URL результатов действуют недолго, поэтому необходимо постоянное хранилище до того, как пользователь обновит страницу.
  4. Размер изображения неизвестен на момент возврата, и при прямой отрисовке это серьёзно ломает совокупный сдвиг макета (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-2 1K: $8,500/день

Архитектура выше не меняется. Меняется только поле model в шаге 2.


Шаг 8: чек-лист для production

Перед запуском:

  1. Настройте ограничение скорости на обработчике маршрута — иначе один IP может исчерпать ваш месячный бюджет за один день. Подойдут Upstash, Cloudflare Rate Limiting или простой счётчик на Redis.
  2. Проверяйте длину и содержимое промпта на сервере перед пересылкой. Платформа hiapi отклоняет некоторое содержимое; вы не захотите платить за задержку round-trip ради ошибки 400.
  3. Установите суточную квоту на пользователя — храните количество генераций по userId и YYYY-MM-DD и отклоняйте 11-й раз за день с дружелюбным сообщением.
  4. Сохраняйте до ответа — никогда не позволяйте URL результата дойти до клиента без прохождения через постоянное хранилище.
  5. Задайте размер контейнера результата заранее в соответствии с запрошенным соотношением сторон. Пропуск этого шага — разница между CLS 0 и CLS 0.4.
  6. Вынесите шаг сохранения в фон, чтобы клиент получал ответ за доли секунды: сразу верните 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 выше, и тот же компонент отрисует результат.

Похожие статьи

Все статьи →

Начните работать с постоянными артефактами ИИ

Получите API-ключ и вызывайте модели изображений, видео и аудио через один API на основе tasks.

Читать документацию API