Tutorial
KI-Bildgenerierung in einer Next.js-App mit der hiapi API
Route-Handler-Proxy, asynchrones Task-Polling, persistente Speicherung und null CLS – eine produktionsnahe Integration in etwa 150 Zeilen TypeScript.
Die Anbindung eines Bildgenerierungsmodells an eine Next.js 14 App Router-Anwendung ist ein kleines Projekt, doch fast alle machen an denselben vier entscheidenden Stellen Fehler:
- Die API benötigt 60–120 Sekunden – zu lang für eine einzelne Client-Anfrage und zu lang für die meisten Edge-Runtimes.
- API-Schlüssel dürfen nicht an den Browser gesendet werden, daher wird ein Route-Handler als Proxy benötigt.
- Ergebnis-URLs sind nur kurz gültig, daher muss die persistente Speicherung abgeschlossen sein, bevor der Nutzer die Seite neu lädt.
- Die Abmessungen der zurückgegebenen Bilder sind unbekannt; werden sie direkt gerendert, wird die Cumulative Layout Shift (CLS) massiv beeinträchtigt.
Dieses Tutorial führt dich durch eine produktionsnahe Integration der hiapi-Bild-API – Task-Erstellung, Polling, persistenter Upload und CLS-sicheres Rendering – in etwa 150 Zeilen TypeScript. Der gesamte Code lässt sich direkt in ein echtes app/-Verzeichnis übernehmen.
Die asynchrone Task-Konvention
hiapi bietet für alle Bild- und Videomodelle einen einheitlichen Endpunkt: POST https://api.hiapi.ai/v1/tasks. Er ist vollständig asynchron – Task absenden, bis zum Endzustand pollen, dann die Ausgabe herunterladen. Keine lang laufenden HTTP-Verbindungen, keine 100-Sekunden-Proxy-Timeouts und keine Fallstricke mit der Edge-Runtime.

Ein Task kann vier Zustände haben:
pending– angenommen, aber noch nicht abgeholtrunning– wird gerade generiertsuccess–output[0].urlist bereit (signiert, zeitlich begrenzt gültig)fail–error.codeunderror.messagesind gefüllt
Ein einzelnes 1K-Bild benötigt in der Regel 30–120 Sekunden. Der Großteil dieser Zeit entfällt auf die Ausführung des Modells; die API selbst verursacht keinen messbaren Overhead.
Schritt 1: Projekt-Setup
Du benötigst ein Next.js 14+ App Router-Projekt und eine Umgebungsvariable:
# .env.local
HIAPI_API_KEY=sk-... # niemals im Browser offenlegen
Füge diesen Schlüssel als serverseitiges Geheimnis zu deiner Deployment-Plattform hinzu (Vercel, Cloudflare Pages, Railway). Gib ihm nicht das Präfix NEXT_PUBLIC_.
Wenn du persistente Speicherung möchtest (empfohlen – Schritt 5 behandelt dies), füge außerdem hinzu:
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=...
R2_PUBLIC_BASE=https://cdn.your-domain.com # benutzerdefinierte Domain auf dem Bucket
Schritt 2: Der Route-Handler – warum er nötig ist

Der einzige Grund für einen Route-Handler: den API-Schlüssel auf dem Server zu halten. Jede Anfrage, die von einer React-Komponente ausgeht, läuft im Browser des Nutzers, und jeder in den Client-Code kompilierte String ist nur einen DevTools-Tab von der Entwendung entfernt.
Ein Route-Handler bietet dir außerdem eine einzige, auditierbare Stelle, um Rate-Limiting durchzusetzen, die Nutzung zu protokollieren und Prompts zu validieren, bevor Anfragen den kostenpflichtigen Endpunkt erreichen.
Erstelle app/api/images/route.ts:
// app/api/images/route.ts
import { NextResponse } from "next/server";
export const runtime = "nodejs"; // langes fetch benötigt Node, nicht Edge
export const maxDuration = 180; // Vercel: Serverless-Timeout verlängern
const HIAPI = "https://api.hiapi.ai/v1/tasks";
type CreateBody = {
prompt: string;
model?: string;
aspectRatio?: string; // z. B. "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 ist das Handle, das der Client zum Pollen verwendet
return NextResponse.json({ taskId: data.taskId });
}
Zwei Einstellungen sind wichtig, werden aber leicht vergessen:
runtime = "nodejs"– in vielen Umgebungen begrenzt die Edge-Runtime ein einzelnes fetch auf etwa 25 Sekunden, was für Polling-Schleifen ungeeignet ist.maxDuration = 180– Vercel-Serverless-Funktionen laufen standardmäßig 10 Sekunden im Hobby-Plan und 60 Sekunden im Pro-Plan; du musst diesen Wert manuell erhöhen, sonst wird die Route mitten im Polling beendet, wenn du dich entscheidest, serverseitig zu warten.
Schritt 3: Der Polling-Endpunkt (oder: warum nicht serverseitig warten)
Es gibt zwei Möglichkeiten, dem Client den Fortschritt zu zeigen:
| Ansatz | Vorteile | Nachteile |
|---|---|---|
| Serverseitig warten und in einem Rutsch zurückgeben | Nur eine Client-Anfrage nötig | Belegt den Serverless-Aufruf 60–120 Sekunden, neigt zu Timeouts, kein Fortschrittsfeedback |
Server gibt taskId zurück, Client pollt |
Geringe Serverkosten, Echtzeit-Fortschritt | Ein zusätzlicher Endpunkt muss gebaut werden |
In der Produktion ist die zweite Variante deutlich besser. Füge app/api/images/[id]/route.ts hinzu:
// 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,
});
}
Dieser Endpunkt ist das Ziel, das der Client alle 3 Sekunden pollt. Jeder Aufruf ist schnell (unter 200 ms) und zustandslos.
Schritt 4: Die Client-Komponente
Ein einfaches Formular, ein Ladezustand mit Ladeindikator und eine Ergebnisfläche. Das Polling wird per setInterval eingerichtet und beim Erreichen eines Endzustands oder beim Unmounten der Komponente gelöscht.
// 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>
{/* Reservierte Fläche – warum dieses div mit exaktem Seitenverhältnis wichtig ist, siehe Schritt 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>
);
}
Einige Produktionsverbesserungen solltest du jetzt schon im Blick behalten und später schrittweise ergänzen:
- Exponentielles Backoff (3 s → 5 s → 8 s) reduziert die Anzahl der Anfragen bei langsamen Generierungen.
- Ein Idle-Timeout (z. B. aufgeben nach 240 Sekunden) verhindert Zombie-Polling auf bereits tote Tasks.
- Zeige Teilfortschrittstexte an –
"running"vs."pending"– damit der Nutzer sicher ist, dass etwas passiert.
Schritt 5: Ergebnisse persistieren – Ausgabe-URLs laufen ab
Der häufigste Fehler bei jeder modernen Bild-API ist, output[0].url in eine Datenbank zu speichern und anzunehmen, alles sei erledigt. Diese URLs sind signiert und kurzlebig (meist Minuten bis Stunden); sobald der Nutzer neu lädt, erhält er 404.
Die Lösung besteht darin, die Bytes über deinen eigenen Server in persistenten Speicher zu leiten. Derselbe app/api/images/[id]/route.ts-Handler ist der richtige Ort dafür – führe dies nur beim ersten Empfang einer success-Antwort aus, bevor die URL an den Client zurückgegeben wird.
// Nach der `data.status === "success"`-Prüfung zu app/api/images/[id]/route.ts hinzufügen
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}`;
}
Verwende in der Status-Route persist(), sobald der Status success wird, und gib dann dem Client die persistierte URL zurück, nicht die signierte Upstream-URL. Der Client sieht nie eine ablaufende URL, und ein Neuladen am nächsten Tag funktioniert weiterhin.
Wenn du in deiner eigenen Datenbank persistierst (Postgres, Supabase usw.), speichere die persistierte URL – nicht die signierte URL.
Schritt 6: CLS beseitigen
Cumulative Layout Shift ist der stille Killer der Core Web Vitals bei KI-Bild-Apps. Bilder treffen erst mehr als 60 Sekunden nach dem Laden der Seite ein. Wenn du sie mit <img> in einem Container ohne festgelegte Größe renderst, verschiebt jedes Ergebnis alles darunter.
Zwei zuverlässige Techniken:
Seitenverhältnis per CSS reservieren. Das macht der Stil aspectRatio: "16/9" in der Komponente oben. Der Container behält seinen Platz, unabhängig davon, ob das Bild geladen ist oder nicht.
Seitenverhältnis in der Anfrage angleichen. Sende aspectRatio: "16:9" an hiapi und deklariere dann 16/9 am Container. Beide müssen übereinstimmen, sonst entstehen schwarze Balken oder – schlimmer – eine Form, die nicht zur reservierten passt.
Für die <Image>-Komponente von Next.js gilt dasselbe Prinzip: Übergib width- und height-Attribute, die dem angeforderten Verhältnis entsprechen. Verwende fill nur, wenn das Elternelement bereits eine Box mit fester Größe ist.
import Image from "next/image";
<Image
src={persistedUrl}
alt={prompt}
width={1280}
height={720}
className="rounded"
priority={false}
/>
Schritt 7: Modelle wählen und Kosten schätzen
Die Bildmodelle auf hiapi teilen dieselbe Endpunktstruktur – nur das Feld model unterscheidet sich. Gängige Optionen, Preis pro Bild bei Standardauflösung:
| Modell | Preis / 1K-Bild | Stärken |
|---|---|---|
qwen-image-2.0 |
$0.025 | Am günstigsten; standardmäßig 2K; starke Textwiedergabe für Chinesisch, Japanisch und Koreanisch |
gpt-image-2 |
$0.03 | Zuverlässige Seitenverhältnisse; starke Typografie |
Nano-Banana |
$0.05 | Sehr schnell (1–2 Sekunden); gute Charakterkonsistenz |
flux-1.1-pro |
$0.05 | Fotorealismus mit künstlerischem Einschlag |
Nano-Banana-2 |
$0.085 (1K) / $0.076 (2K) / $0.114 (4K) | High-End-Qualität, natives 4K |
Die ehrliche Standardwahl hängt von der Aufgabe ab. Für UI-Mockups und Überschriften ist gpt-image-2 am sichersten. Für Fotorealismus in nativem 4K wähle Nano-Banana-2. Für Batch-Aufgaben, bei denen ein weggeworfenes Bild pro Stück nicht schmerzt, wähle qwen-image-2.0 oder Nano-Banana.
Für eine App mit 100.000 Nutzern, die pro Sitzung und Tag ein Bild generiert:
qwen-image-2.0: $2,500/TagNano-Banana-21K: $8,500/Tag
Die obige Architektur bleibt unverändert. Nur das Feld model in Schritt 2 ändert sich.
Schritt 8: Checkliste für die Produktion
Vor dem Go-live:
- Setze Rate-Limiting auf den Route-Handlern durch – sonst kann eine einzige IP dein Monatsbudget an einem Nachmittag aufbrauchen. Upstash, Cloudflare Rate Limiting oder ein einfacher Redis-Zähler funktionieren alle.
- Validiere Prompt-Länge und -Inhalt vor der serverseitigen Weiterleitung. Die hiapi-Plattform lehnt bestimmte Inhalte ab; du willst nicht für einen 400-Fehler die Roundtrip-Latenz bezahlen.
- Setze ein tägliches Kontingent pro Nutzer – speichere die Generierungsanzahl nach
userIdundYYYY-MM-DDund lehne beim 11. Mal des Tages mit einer freundlichen Meldung ab. - Persistiere vor der Antwort – lass die Ergebnis-URL niemals den Client erreichen, bevor sie durch persistenten Speicher gegangen ist.
- Lege die Größe des Ergebnis-Containers im Voraus fest, passend zum angeforderten Seitenverhältnis. Diesen Schritt zu überspringen ist der Unterschied zwischen CLS 0 und CLS 0,4.
- Verlege den Persistenz-Schritt in den Hintergrund, damit der Client in unter einer Sekunde antwortet: Gib sofort die Upstream-URL zurück und starte einen Queue-Job, der nach R2 spiegelt und die Datenbankzeile aktualisiert.
Der letzte Punkt verdient einen zusätzlichen Satz: Die meisten Apps brauchen ihn nicht. Inline-Persistenz hält den Code einfach, und die zusätzlichen 1–2 Sekunden fallen nach der 60-sekündigen Wartezeit an, wenn der Nutzer bereits den Ladeindikator sieht.
Wie es weitergeht
Du hast jetzt eine vollständige, sichere und CLS-sichere Next.js-Integration der hiapi-Bild-API in etwa 150 Zeilen TypeScript. Von hier aus lässt sich dasselbe POST /v1/tasks-Muster auf Videomodelle (Seedance 2.0 für filmisches Material), Bild-zu-Bild-Workflows (füge einfach image_url innerhalb von input hinzu) und mehrstufige Bearbeitungen erweitern – alles ohne Änderung der Polling-Architektur.
Wenn du einen funktionierenden Ausgangspunkt möchtest, listet die hiapi-Modellseite alle Modelle auf, die auf diesen Endpunkt reagieren, und zeigt die Beispiel-Eingabestruktur für jedes Modell. Setze eines davon in das model-Feld oben, und dieselbe Komponente rendert das Ergebnis.
Ähnliche Artikel
Alle Artikel ansehen →-
Tutorial
So rufen Sie hailuo-2.3-fast/image-to-video über die HiAPI API auf: curl und Python in der Praxis
Die erste lauffähige Anfrage für die schnelle, kostengünstige Bild-zu-Video-Stufe – von der Modell-ID bis zum heruntergeladenen MP4
-
Tutorial
So rufen Sie hailuo-2.3/text-to-video über die HiAPI API auf: curl, Python und lauffähige Anfragebeispiele
-
Tutorial
Kandinsky 6.0 Pro Text-zu-Video auf HiAPI: curl- und Python-Tutorial
Eine anfrageorientierte praktische Demonstration: Aufbau des Aufrufs vom Prompt zum Videoclip, Absenden der Anfrage, Abfragen des Fertigstellungsstatus und Herunterladen des Ergebnisses