Tutoriel
Ajouter la génération d'images par IA à une application Next.js avec l'API hiapi
Proxy de route handler, interrogation de tâches asynchrones, stockage persistant et zéro CLS — une intégration proche de la production en environ 150 lignes de TypeScript.
Brancher un modèle de génération d'images sur une application Next.js 14 App Router n'est pas un gros projet, mais presque tout le monde se casse les dents sur les mêmes quatre points critiques :
- L'API prend 60 à 120 secondes — trop long pour une seule requête client, et trop long pour la plupart des runtimes edge.
- La clé d'API ne doit pas être envoyée au navigateur, il faut donc un route handler faisant office de proxy.
- Les URL de résultat ont une durée de vie courte, il faut donc les persister avant que l'utilisateur ne rafraîchisse la page.
- Les dimensions de l'image sont inconnues au moment où elle arrive ; rendre l'image directement détruit gravement le Cumulative Layout Shift.
Ce tutoriel vous guide à travers une intégration proche de la production de l'API d'images hiapi — création de tâche, interrogation, upload persistant et rendu sans CLS — en environ 150 lignes de TypeScript. Tout le code s'insère directement dans un vrai répertoire app/.
La convention de tâche asynchrone
hiapi expose un point d'entrée unique pour tous les modèles d'image et de vidéo : POST https://api.hiapi.ai/v1/tasks. Tout est entièrement asynchrone — soumettez une tâche, interrogez jusqu'à l'état terminal, puis téléchargez la sortie. Pas de HTTP longue durée, pas de timeout de proxy à 100 secondes, pas de piège de runtime Edge.

Une tâche peut avoir quatre états possibles :
pending— acceptée mais pas encore prise en chargerunning— en cours de générationsuccess—output[0].urlest prêt (signé, valable pour une durée limitée)fail—error.codeeterror.messagesont renseignés
Une image 1K prend généralement 30 à 120 secondes. L'essentiel de ce temps est consacré à l'exécution du modèle ; l'API elle-même n'ajoute pas de surcoût mesurable.
Étape 1 : configuration du projet
Vous avez besoin d'un projet Next.js 14+ App Router et d'une variable d'environnement :
# .env.local
HIAPI_API_KEY=sk-... # ne jamais exposer au navigateur
Ajoutez cette clé en tant que secret côté serveur sur votre plateforme de déploiement (Vercel, Cloudflare Pages, Railway). Ne lui donnez pas le préfixe NEXT_PUBLIC_.
Si vous voulez un stockage persistant (recommandé — couvert à l'étape 5), ajoutez aussi :
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=...
R2_PUBLIC_BASE=https://cdn.your-domain.com # domaine personnalisé sur le bucket
Étape 2 : le route handler — pourquoi il est nécessaire

La seule raison d'être d'un route handler : garder la clé d'API côté serveur. Toute requête partie d'un composant React s'exécute dans le navigateur de l'utilisateur, et n'importe quelle chaîne compilée dans le code client n'est qu'à un onglet DevTools d'être volée.
Le route handler vous offre aussi un point unique et auditable pour appliquer une limitation de débit, journaliser l'usage et valider les prompts avant que la requête n'atteigne un point d'entrée payant.
Créez app/api/images/route.ts :
// app/api/images/route.ts
import { NextResponse } from "next/server";
export const runtime = "nodejs"; // un fetch long nécessite Node, pas Edge
export const maxDuration = 180; // Vercel : prolonge le timeout serverless
const HIAPI = "https://api.hiapi.ai/v1/tasks";
type CreateBody = {
prompt: string;
model?: string;
aspectRatio?: string; // par ex. "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 est le handle utilisé par le client pour l'interrogation
return NextResponse.json({ taskId: data.taskId });
}
Deux réglages sont importants mais faciles à oublier :
runtime = "nodejs"— dans de nombreux environnements, le runtime Edge limite un fetch unique à environ 25 secondes, ce qui ne convient pas à une boucle d'interrogation.maxDuration = 180— les fonctions serverless Vercel sont par défaut à 10 secondes sur le plan Hobby et 60 secondes sur le plan Pro ; vous devez relever cette valeur manuellement, sinon, si vous choisissez d'attendre côté serveur, la route sera tuée en pleine interrogation.
Étape 3 : le point d'entrée d'interrogation (ou : pourquoi ne pas attendre côté serveur)
Deux options pour montrer la progression au client :
| Option | Avantages | Inconvénients |
|---|---|---|
| Attente côté serveur puis réponse unique | Une seule requête client | Occupe un appel serverless pendant 60–120 secondes, risque de timeout, aucun retour de progression |
Le serveur renvoie taskId, le client interroge |
Coût serveur faible, progression en temps réel | Nécessite de construire un point d'entrée de plus |
En production, la seconde option est clairement meilleure. Ajoutez 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,
});
}
C'est ce point d'entrée que le client interroge toutes les 3 secondes. Chaque appel est rapide (moins de 200 ms) et sans état.
Étape 4 : le composant client
Un formulaire simple, un état de chargement avec indicateur, et un canevas de résultat. L'interrogation est configurée via setInterval et nettoyée à l'entrée dans un état terminal ou au démontage du composant.
// 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="Un coucher de soleil sur une forêt de pins brumeuse, cinématographique, 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" ? "Génération…" : "Générer"}
</button>
{/* Canevas réservé — pourquoi ce div au rapport prédéfini est important, voir étape 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">En attente de la tâche {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>
);
}
Quelques améliorations de production méritent d'être notées dès maintenant, à ajouter progressivement ensuite :
- Un backoff exponentiel (3 s → 5 s → 8 s) réduit le nombre de requêtes lors des générations lentes.
- Un timeout d'inactivité (par exemple abandon après 240 secondes) évite l'interrogation zombie de tâches mortes.
- Affichez un texte de progression partiel —
"running"versus"pending"— pour rassurer l'utilisateur que quelque chose se passe.
Étape 5 : persister les résultats — les URL de sortie expirent
Avec n'importe quelle API d'image moderne, l'erreur la plus fréquente consiste à stocker output[0].url en base de données en croyant que tout est réglé. Ces URL sont signées et éphémères (généralement de quelques minutes à quelques heures) ; dès que l'utilisateur rafraîchit, c'est un 404.
La solution est de faire transiter les octets par votre propre serveur vers un stockage persistant. Le même handler app/api/images/[id]/route.ts est le bon endroit — exécutez-le uniquement lors de la première réception d'une réponse success, avant de renvoyer l'URL au client.
// À ajouter dans app/api/images/[id]/route.ts après la vérification `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}`;
}
Dans la route d'état, dès que le statut devient success, utilisez persist(), puis renvoyez au client l'URL persistée, et non l'URL signée en amont. Le client ne voit jamais d'URL qui expire, et un rafraîchissement le lendemain fonctionne toujours.
Si vous persistez votre propre base de données (Postgres, Supabase, etc.), stockez l'URL persistée — pas l'URL signée.
Étape 6 : éliminer le CLS
Le Cumulative Layout Shift est le tueur silencieux des Core Web Vitals sur les applications d'images par IA. Les images arrivent plus de 60 secondes après le chargement de la page. Si vous les rendez dans un conteneur sans dimensions définies avec une <img>, chaque résultat décale tout ce qui se trouve en dessous.
Deux techniques fiables :
Réservez le ratio d'aspect en CSS. C'est ce que fait le style aspectRatio: "16/9" dans le composant ci-dessus. Le conteneur conserve son espace, que l'image soit chargée ou non.
Faites correspondre le ratio d'aspect dans la requête. Envoyez aspectRatio: "16:9" à hiapi, puis déclarez 16/9 sur le conteneur. Les deux doivent concorder, sinon vous aurez des bandes noires ou, pire, une forme qui ne correspond pas à l'espace réservé.
Pour le composant <Image> de Next.js, le même principe s'applique : passez des attributs width et height correspondant au ratio demandé. N'utilisez pas fill à moins que le parent ne soit déjà une boîte à dimensions fixes.
import Image from "next/image";
<Image
src={persistedUrl}
alt={prompt}
width={1280}
height={720}
className="rounded"
priority={false}
/>
Étape 7 : choisir un modèle et estimer les coûts
Les modèles d'image sur hiapi partagent la même structure de point d'entrée — seul le champ model diffère. Choix courants, prix par image à la résolution par défaut :
| Modèle | Prix / image 1K | Points forts |
|---|---|---|
qwen-image-2.0 |
0,025 $ | Le moins cher ; 2K par défaut ; excellent rendu du texte chinois, japonais et coréen |
gpt-image-2 |
0,03 $ | Ratio d'aspect fiable ; excellente typographie |
Nano-Banana |
0,05 $ | Très rapide (1–2 secondes) ; bonne cohérence des personnages |
flux-1.1-pro |
0,05 $ | Photoralisme à tendance artistique |
Nano-Banana-2 |
0,085 $ (1K) / 0,076 $ (2K) / 0,114 $ (4K) | Qualité haut de gamme, 4K natif |
Le choix par défaut honnête dépend de la tâche. Pour les maquettes d'interface et les titres, gpt-image-2 est le plus sûr. Pour du photoralisme 4K natif, choisissez Nano-Banana-2. Pour du travail en volume où l'on peut jeter chaque image sans regret, choisissez qwen-image-2.0 ou Nano-Banana.
Pour une application de 100 000 utilisateurs générant une image par session et par jour :
qwen-image-2.0: 2 500 $/jourNano-Banana-21K : 8 500 $/jour
L'architecture ci-dessus ne change pas. Seul le champ model de l'étape 2 change.
Étape 8 : liste de vérification pour la production
Avant la mise en ligne :
- Appliquez une limitation de débit sur le route handler — sinon une seule IP peut épuiser votre budget mensuel en un après-midi. Upstash, Cloudflare Rate Limiting ou un simple compteur Redis font l'affaire.
- Validez la longueur et le contenu du prompt côté serveur avant de transmettre. La plateforme hiapi rejette certains contenus ; vous ne voulez pas payer la latence d'un aller-retour pour une erreur 400.
- Définissez un quota quotidien par utilisateur — stockez le nombre de générations par
userIdetYYYY-MM-DD, et refusez avec un message aimable à la 11e tentative du jour. - Persistez avant de répondre — ne laissez jamais une URL de résultat atteindre le client sans être passée par le stockage persistant.
- Pré-dimensionnez le conteneur de résultat pour correspondre au ratio d'aspect demandé. Sauter cette étape, c'est la différence entre un CLS de 0 et de 0,4.
- Passez l'étape de persistance en arrière-plan pour que le client réponde en moins d'une seconde : renvoyez immédiatement l'URL amont, lancez une tâche en file qui copie vers R2 et met à jour la ligne en base.
Ce dernier point mérite un mot de plus : la plupart des applications n'en ont pas besoin. La persistance en ligne garde le code simple, et les 1 à 2 secondes supplémentaires surviennent après l'attente de 60 secondes, alors que l'utilisateur regarde déjà l'indicateur de chargement.
Pour la suite
Vous disposez maintenant d'une intégration Next.js complète, sécurisée et sans CLS de l'API d'images hiapi, en environ 150 lignes de TypeScript. À partir de là, le même schéma POST /v1/tasks s'étend aux modèles vidéo (Seedance 2.0 pour des séquences cinématographiques), aux flux image-vers-image (ajoutez simplement image_url dans input) et aux éditions multi-tours — sans rien changer à l'architecture d'interrogation.
Si vous voulez un point de départ fonctionnel, la page des modèles hiapi liste tous les modèles qui répondent à ce point d'entrée et donne une structure d'entrée d'exemple pour chacun. Placez n'importe lequel dans le champ model ci-dessus, et le même composant rendra le résultat.
Articles similaires
Voir tous les articles →-
Tutoriel
Comment appeler hailuo-2.3-fast/image-to-video via l'API HiAPI : curl et Python en pratique
La première requête exploitable du palier image-vers-vidéo rapide et économique — de l'ID du modèle au téléchargement du MP4
-
Tutoriel
Comment appeler hailuo-2.3/text-to-video via l'API HiAPI : curl, Python et exemples de requêtes exécutables
-
Tutoriel
Utiliser Kandinsky 6.0 Pro texte-vers-vidéo sur HiAPI : tutoriel curl et Python
Démonstration pratique axée sur la requête : construire l'appel du prompt au clip vidéo, soumettre la requête, interroger l'état d'achèvement et télécharger le résultat