HiAPI
Blog

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.

Jun 16, 2026
KI-Bildgenerierung in einer Next.js-App mit der hiapi API

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:

  1. Die API benötigt 60–120 Sekunden – zu lang für eine einzelne Client-Anfrage und zu lang für die meisten Edge-Runtimes.
  2. API-Schlüssel dürfen nicht an den Browser gesendet werden, daher wird ein Route-Handler als Proxy benötigt.
  3. Ergebnis-URLs sind nur kurz gültig, daher muss die persistente Speicherung abgeschlossen sein, bevor der Nutzer die Seite neu lädt.
  4. 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 abgeholt
  • running – wird gerade generiert
  • success – output[0].url ist bereit (signiert, zeitlich begrenzt gültig)
  • fail – error.code und error.message sind 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/Tag
  • Nano-Banana-2 1K: $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:

  1. 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.
  2. 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.
  3. Setze ein tägliches Kontingent pro Nutzer – speichere die Generierungsanzahl nach userId und YYYY-MM-DD und lehne beim 11. Mal des Tages mit einer freundlichen Meldung ab.
  4. Persistiere vor der Antwort – lass die Ergebnis-URL niemals den Client erreichen, bevor sie durch persistenten Speicher gegangen ist.
  5. 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.
  6. 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 →

Starte mit persistenten KI-Artefakten

Hol dir deinen API-Key und rufe Bild-, Video- und Audiomodelle über eine einzige task-basierte API auf.

API-Dokumentation lesen