Tutorial
Adicione geração de imagens por IA ao seu app Next.js com a API hiapi
Proxy de route handler, polling de tarefas assíncronas, armazenamento persistente e zero CLS — uma integração quase de produção em cerca de 150 linhas de TypeScript.
Conectar um modelo de geração de imagens a um app Next.js 14 App Router é um projeto pequeno, mas quase todo mundo tropeça nos mesmos quatro pontos críticos:
- A API leva de 60 a 120 segundos — longo demais para uma única requisição do cliente, e longo demais para a maioria dos runtimes de edge.
- A chave da API não pode ir para o navegador, então você precisa de um route handler como proxy.
- As URLs de resultado têm validade curta, então você precisa de armazenamento persistente antes que o usuário recarregue a página.
- As dimensões da imagem são desconhecidas quando ela chega; renderizá-la diretamente destrói o Cumulative Layout Shift.
Este tutorial percorre uma integração quase de produção da API de imagens hiapi — criação de tarefa, polling, upload persistente e renderização segura para CLS — em cerca de 150 linhas de TypeScript. Todo o código pode ir direto para um diretório app/ real.
A convenção de tarefas assíncronas
A hiapi expõe um endpoint unificado para todos os modelos de imagem e vídeo: POST https://api.hiapi.ai/v1/tasks. É totalmente assíncrono — envie uma tarefa, faça polling até um estado terminal e baixe a saída. Sem HTTP de longa duração, sem timeouts de proxy de 100 segundos, sem armadilhas de runtime Edge.

Uma tarefa tem quatro estados possíveis:
pending— aceita, mas ainda não coletadarunning— sendo geradasuccess—output[0].urlestá pronto (assinada, validade limitada)fail—error.codeeerror.messagepreenchidos
Uma única imagem 1K normalmente leva de 30 a 120 segundos. A maior parte desse tempo é a execução do modelo; a API em si não adiciona overhead mensurável.
Passo 1: configuração do projeto
Você precisa de um projeto Next.js 14+ App Router e de uma variável de ambiente:
# .env.local
HIAPI_API_KEY=sk-... # nunca exponha ao navegador
Adicione essa chave como segredo do lado do servidor na sua plataforma de deploy (Vercel, Cloudflare Pages, Railway). Não adicione o prefixo NEXT_PUBLIC_.
Se você quiser armazenamento persistente (recomendado — coberto no Passo 5), adicione também:
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=...
R2_PUBLIC_BASE=https://cdn.your-domain.com # domínio personalizado no bucket
Passo 2: o route handler — por que ele é necessário

A única razão de existir de um route handler: manter a chave da API no servidor. Qualquer requisição feita a partir de um componente React roda no navegador do usuário, e qualquer string compilada no código do cliente está a uma aba DevTools de ser roubada.
Um route handler também oferece um único local auditável para aplicar rate limiting, registrar uso e validar prompts antes que a requisição chegue ao endpoint pago.
Crie app/api/images/route.ts:
// app/api/images/route.ts
import { NextResponse } from "next/server";
export const runtime = "nodejs"; // fetches longos precisam de Node, não Edge
export const maxDuration = 180; // Vercel: estende o timeout da função serverless
const HIAPI = "https://api.hiapi.ai/v1/tasks";
type CreateBody = {
prompt: string;
model?: string;
aspectRatio?: string; // por exemplo "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 é o handle que o cliente usa para o polling
return NextResponse.json({ taskId: data.taskId });
}
Duas configurações são importantes, mas fáceis de esquecer:
runtime = "nodejs"— em muitos ambientes, o runtime Edge limita um único fetch a cerca de 25 segundos, inviável para um loop de polling.maxDuration = 180— funções serverless da Vercel têm timeout padrão de 10 segundos no plano Hobby e 60 segundos no Pro; você precisa aumentar esse valor manualmente, ou a rota será encerrada no meio do polling se você optar por esperar no servidor.
Passo 3: o endpoint de polling (ou: por que não esperar no servidor)
Há duas formas de mostrar progresso ao cliente:
| Abordagem | Vantagens | Desvantagens |
|---|---|---|
| Esperar no servidor e retornar de uma vez | Apenas uma requisição do cliente | Ocupa a invocação serverless por 60–120 segundos, propenso a timeouts, sem feedback de progresso |
Servidor retorna taskId, cliente faz polling |
Custo baixo no servidor, progresso em tempo real | Precisa construir mais um endpoint |
Em produção, a segunda abordagem é claramente melhor. Adicione 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,
});
}
É esse endpoint que o cliente consulta a cada 3 segundos. Cada chamada é rápida (menos de 200ms) e sem estado.
Passo 4: o componente cliente
Um formulário simples, um estado de carregamento com indicador e uma tela de resultado. O polling é configurado com setInterval e limpo ao atingir um estado terminal ou ao desmontar o componente.
// 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="Um pôr do sol sobre uma floresta de pinheiros enevoada, cinematográfico, 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" ? "Gerando…" : "Gerar"}
</button>
{/* Tela reservada — por que esta div com proporção exata importa, veja o Passo 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">Aguardando a tarefa {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>
);
}
Algumas melhorias de produção merecem atenção desde já, para adicionar depois aos poucos:
- Backoff exponencial (3s → 5s → 8s) reduz a quantidade de requisições em gerações lentas.
- Timeout de inatividade (por exemplo, desistir após 240 segundos) evita polling zumbi de tarefas mortas.
- Exibir texto de progresso parcial —
"running"versus"pending"— deixa o usuário confiante de que algo está acontecendo.
Passo 5: persistir o resultado — a URL de saída expira
Com qualquer API de imagem moderna, o erro mais comum é salvar output[0].url no banco de dados e achar que está tudo resolvido. Essas URLs são assinadas e de vida curta (normalmente de minutos a horas); assim que o usuário recarrega a página, vira 404.
A solução é encaminhar os bytes pelo seu próprio servidor para armazenamento persistente. O mesmo handler app/api/images/[id]/route.ts é o lugar certo — execute apenas na primeira vez que receber uma resposta success, antes de retornar a URL ao cliente.
// Adicione em app/api/images/[id]/route.ts após a verificação `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}`;
}
Na rota de status, assim que o estado virar success, use persist() e então retorne ao cliente a URL persistida, em vez da URL assinada do upstream. O cliente nunca vê uma URL que expira, e um recarregamento amanhã ainda funciona.
Se você persistir no seu próprio banco de dados (Postgres, Supabase etc.), armazene a URL persistida — não a URL assinada.
Passo 6: eliminar o CLS
Cumulative Layout Shift é o assassino silencioso das Core Web Vitals em apps de imagem com IA. A imagem só chega mais de 60 segundos após o carregamento da página. Se você a renderizar com <img> dentro de um contêiner sem dimensões definidas, cada resultado desloca tudo o que está abaixo dele.
Duas técnicas confiáveis:
Reserve a proporção com CSS. É isso que o estilo aspectRatio: "16/9" no componente acima faz. O contêiner mantém seu espaço, independentemente de a imagem ter carregado ou não.
Combine a proporção na requisição. Envie aspectRatio: "16:9" para a hiapi e declare 16/9 no contêiner. Os dois precisam coincidir, senão aparecem faixas pretas ou, pior, um formato diferente do reservado.
Para o componente <Image> do Next.js, o mesmo princípio se aplica: passe os atributos width e height correspondentes à proporção solicitada. Não use fill, a menos que o elemento pai já seja uma caixa de tamanho fixo.
import Image from "next/image";
<Image
src={persistedUrl}
alt={prompt}
width={1280}
height={720}
className="rounded"
priority={false}
/>
Passo 7: escolher o modelo e estimar custos
Os modelos de imagem da hiapi compartilham a mesma estrutura de endpoint — só o campo model muda. Escolhas comuns, preço por imagem na resolução padrão:
| Modelo | Preço / imagem 1K | Pontos fortes |
|---|---|---|
qwen-image-2.0 |
$0.025 | Mais barato; 2K por padrão; forte renderização de texto em chinês, japonês e coreano |
gpt-image-2 |
$0.03 | Proporções confiáveis; forte capacidade tipográfica |
Nano-Banana |
$0.05 | Muito rápido (1–2 segundos); boa consistência de personagem |
flux-1.1-pro |
$0.05 | Fotorrealismo com estilo mais artístico |
Nano-Banana-2 |
$0.085 (1K) / $0.076 (2K) / $0.114 (4K) | Qualidade premium, 4K nativo |
A escolha padrão honesta depende da tarefa. Para mockups de UI e títulos, gpt-image-2 é a mais segura. Para fotorrealismo 4K nativo, escolha Nano-Banana-2. Para trabalhos em lote em que descartar cada imagem não dói, escolha qwen-image-2.0 ou Nano-Banana.
Para um app com 100 mil usuários, gerando uma imagem por sessão por dia:
qwen-image-2.0: $2.500/diaNano-Banana-21K: $8.500/dia
A arquitetura acima não muda. Só o campo model do Passo 2 muda.
Passo 8: checklist de produção
Antes de lançar:
- Aplique rate limiting nos route handlers — caso contrário, um único IP esgota seu orçamento mensal em uma tarde. Upstash, Cloudflare Rate Limiting ou um contador Redis simples resolvem.
- Valide o comprimento e o conteúdo do prompt antes de encaminhar no servidor. A plataforma hiapi rejeita certos conteúdos; você não quer pagar a latência de ida e volta por um erro 400.
- Defina uma cota diária por usuário — armazene a contagem de gerações por
userIdeYYYY-MM-DDe recuse na 11ª do dia com uma mensagem amigável. - Persista antes de responder — nunca deixe a URL de resultado chegar ao cliente sem passar pelo armazenamento persistente.
- Defina previamente o tamanho do contêiner do resultado para corresponder à proporção solicitada. Pular este passo é a diferença entre CLS 0 e CLS 0,4.
- Mova a etapa de persistência para segundo plano para dar ao cliente resposta em menos de um segundo: retorne a URL do upstream imediatamente e inicie um job na fila para copiar para o R2 e atualizar a linha do banco.
Vale dizer mais uma frase sobre o último ponto: a maioria dos apps não precisa disso. A persistência inline mantém o código simples, e os 1–2 segundos extras acontecem depois dos 60 segundos de espera, quando o usuário já está olhando para o indicador de carregamento.
Próximos passos
Você agora tem uma integração Next.js completa, segura e preparada para CLS com a API de imagens hiapi, em cerca de 150 linhas de TypeScript. A partir daqui, o mesmo padrão POST /v1/tasks se estende a modelos de vídeo (Seedance 2.0 para material cinematográfico), fluxos de imagem para imagem (basta adicionar image_url dentro de input) e edição em múltiplas rodadas — tudo sem mudar a arquitetura de polling.
Se você quiser um ponto de partida funcional, a página de modelos da hiapi lista todos os modelos que respondem a este endpoint e dá a estrutura de entrada de exemplo de cada um. Coloque qualquer um deles no campo model acima e o mesmo componente renderiza o resultado.
Artigos relacionados
Ver todos os artigos →-
Tutorial
Como chamar hailuo-2.3-fast/image-to-video pela API HiAPI: curl e Python na prática
A primeira requisição executável do nível rápido e econômico de geração de vídeo a partir de imagem — do ID do modelo ao download do MP4
-
Tutorial
Como chamar hailuo-2.3/text-to-video pela API HiAPI: curl, Python e exemplos de requisição executáveis
-
Tutorial
Usar Kandinsky 6.0 Pro text-to-video na HiAPI: tutorial de curl e Python
Demonstração prática com foco na requisição: montar a chamada de prompt para clipe de vídeo, enviar a requisição, consultar o status de conclusão e baixar o resultado