HiAPI
Blog

Tutoriale

Aggiungere la generazione di immagini AI a un'app Next.js con l'API hiapi

Proxy tramite route handler, polling di attività asincrone, archiviazione persistente e CLS zero: un'integrazione quasi di produzione in circa 150 righe di TypeScript.

Jun 16, 2026
Aggiungere la generazione di immagini AI a un'app Next.js con l'API hiapi

Integrare un modello di generazione di immagini in un'app Next.js 14 App Router è un progetto contenuto, ma quasi tutti commettono gli stessi quattro errori critici:

  1. L'API richiede 60–120 secondi: troppo per una singola richiesta client, e troppo anche per la maggior parte dei runtime edge.
  2. La chiave API non può essere inviata al browser, quindi serve un route handler come proxy.
  3. Gli URL dei risultati hanno una validità breve, quindi serve un'archiviazione persistente prima che l'utente aggiorni la pagina.
  4. Le dimensioni delle immagini non sono note al momento del ritorno; se renderizzate direttamente, compromettono gravemente il Cumulative Layout Shift.

Questo tutorial ti guida attraverso un'integrazione quasi di produzione dell'API di immagini hiapi: creazione dell'attività, polling, upload persistente e rendering sicuro rispetto al CLS, in circa 150 righe di TypeScript. Tutto il codice può essere inserito direttamente in una directory app/ reale.


Il contratto delle attività asincrone

hiapi espone un unico endpoint per tutti i modelli di immagini e video: POST https://api.hiapi.ai/v1/tasks. È completamente asincrono: invii l'attività, esegui il polling finché non raggiunge uno stato terminale, quindi scarichi l'output. Nessuna connessione HTTP di lunga durata, nessun timeout del proxy a 100 secondi, nessuna insidia del runtime Edge.

Un'attività può trovarsi in quattro stati possibili:

  • pending: accettata ma non ancora prelevata
  • running: in fase di generazione
  • success: output[0].url è pronto (firmato, valido per tempo limitato)
  • fail: error.code e error.message sono popolati

Una singola immagine 1K richiede tipicamente 30–120 secondi. La maggior parte del tempo è impiegata dall'esecuzione del modello; l'API stessa non aggiunge un overhead misurabile.


Passo 1: configurazione del progetto

Ti servono un progetto Next.js 14+ App Router e una variabile d'ambiente:

# .env.local
HIAPI_API_KEY=sk-...   # non esporre mai al browser

Aggiungi questa chiave come segreto lato server sulla tua piattaforma di deploy (Vercel, Cloudflare Pages, Railway). Non aggiungere il prefisso NEXT_PUBLIC_.

Se vuoi l'archiviazione persistente (consigliata: il passo 5 la copre), aggiungi anche:

R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=...
R2_PUBLIC_BASE=https://cdn.your-domain.com   # dominio personalizzato sul bucket

Passo 2: il route handler — perché serve

Il route handler esiste per un solo motivo: mantenere la chiave API lato server. Qualsiasi richiesta effettuata da un componente React viene eseguita nel browser dell'utente, e qualsiasi stringa compilata nel codice client è a una scheda DevTools dal furto.

Il route handler ti offre anche un unico punto verificabile per imporre limiti di frequenza, registrare l'utilizzo e validare i prompt prima che le richieste raggiungano un endpoint a pagamento.

Crea app/api/images/route.ts:

// app/api/images/route.ts
import { NextResponse } from "next/server";

export const runtime = "nodejs";          // fetch lunghe richiedono Node, non Edge
export const maxDuration = 180;           // Vercel: timeout serverless esteso

const HIAPI = "https://api.hiapi.ai/v1/tasks";

type CreateBody = {
  prompt: string;
  model?: string;
  aspectRatio?: string;   // ad es. "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 è l'handle usato dal client per il polling
  return NextResponse.json({ taskId: data.taskId });
}

Due impostazioni sono importanti ma facili da dimenticare:

  • runtime = "nodejs": in molti ambienti il runtime Edge limita una singola fetch a circa 25 secondi, inadatto a un ciclo di polling.
  • maxDuration = 180: le funzioni serverless Vercel hanno un default di 10 secondi sul piano Hobby e 60 secondi sul piano Pro; devi aumentarlo manualmente, altrimenti, se scegli di attendere lato server, la route viene terminata a metà polling.

Passo 3: l'endpoint di polling (ovvero: perché non attendere lato server)

Ci sono due modi per mostrare l'avanzamento al client:

Approccio Vantaggi Svantaggi
Attesa lato server con risposta unica Una sola richiesta client Occupata una chiamata serverless per 60–120 secondi, facile da andare in timeout, nessun feedback di avanzamento
Il server restituisce taskId, il client esegue il polling Costo server basso, avanzamento in tempo reale Serve costruire un endpoint in più

In produzione, la seconda opzione è chiaramente migliore. Aggiungi 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,
  });
}

Questo è l'endpoint che il client interroga ogni 3 secondi. Ogni chiamata è veloce (meno di 200ms) e stateless.


Passo 4: il componente client

Un semplice form, uno stato di caricamento con indicatore e una tela per il risultato. Il polling è impostato con setInterval e viene cancellato al raggiungimento dello stato terminale o allo smontaggio del 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="Un tramonto su una pineta nebbiosa, cinematografico, 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" ? "Generazione in corso…" : "Genera"}
      </button>

      {/* tela riservata — perché questo div con esatto aspect ratio è importante, vedi 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">In attesa dell'attività {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>
  );
}

Alcuni miglioramenti per la produzione meritano attenzione ora, per aggiungerli gradualmente in seguito:

  • Il backoff esponenziale (3s → 5s → 8s) riduce il numero di richieste durante le generazioni lente.
  • Un timeout di inattività (ad esempio, abbandonare dopo 240 secondi) previene il polling zombie su attività morte.
  • Mostrare testo di avanzamento parziale — "running" e "pending" — per rassicurare l'utente che qualcosa sta accadendo.

Passo 5: persistere i risultati — gli URL di output scadono

Con qualsiasi API di immagini moderna, l'errore più comune è salvare output[0].url in un database e credere che sia tutto a posto. Quegli URL sono firmati e di breve durata (di solito da pochi minuti a qualche ora): non appena l'utente aggiorna la pagina, si ottiene un 404.

La soluzione è convogliare i byte attraverso il proprio server verso un'archiviazione persistente. Lo stesso handler app/api/images/[id]/route.ts è il posto giusto: eseguilo solo alla prima risposta success, prima di restituire l'URL al client.

// da aggiungere ad app/api/images/[id]/route.ts dopo il controllo `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}`;
}

Nella route di stato, non appena lo stato diventa success, usa persist() e restituisci al client l'URL persistente, non l'URL firmato upstream. Il client non vede mai un URL che scade e, aggiornando domani, funziona ancora.

Se persisti nel tuo database (Postgres, Supabase, ecc.), memorizza l'URL persistente, non quello firmato.


Passo 6: eliminare il CLS

Il Cumulative Layout Shift è il killer silenzioso delle Core Web Vitals nelle app di immagini AI. Le immagini arrivano oltre 60 secondi dopo il caricamento della pagina. Se le renderizzi con <img> in un contenitore senza dimensioni definite, ogni risultato sposta tutto ciò che sta sotto.

Due tecniche affidabili:

Riservare il rapporto d'aspetto con CSS. È ciò che fa lo stile aspectRatio: "16/9" nel componente sopra. Il contenitore mantiene il suo spazio indipendentemente dal fatto che l'immagine sia caricata o meno.

Far coincidere il rapporto d'aspetto nella richiesta. Invia aspectRatio: "16:9" a hiapi, poi dichiara 16/9 sul contenitore. I due devono coincidere, altrimenti si ottengono bande nere o, peggio, una forma diversa da quella riservata.

Con il componente <Image> di Next.js vale lo stesso principio: passa gli attributi width e height corrispondenti al rapporto richiesto. Non usare fill a meno che il genitore non sia già un box di dimensioni fisse.

import Image from "next/image";

<Image
  src={persistedUrl}
  alt={prompt}
  width={1280}
  height={720}
  className="rounded"
  priority={false}
/>

Passo 7: scegliere il modello e stimare i costi

I modelli di immagini su hiapi condividono la stessa struttura di endpoint: cambia solo il campo model. Scelte comuni, con il prezzo per immagine alla risoluzione predefinita:

Modello Prezzo / immagine 1K Punti di forza
qwen-image-2.0 $0.025 Il più economico; 2K predefinito; forte rendering di testo cinese, giapponese e coreano
gpt-image-2 $0.03 Rapporti d'aspetto affidabili; forte capacità tipografica
Nano-Banana $0.05 Molto veloce (1–2 secondi); buona coerenza dei personaggi
flux-1.1-pro $0.05 Fotorealismo con stile artistico
Nano-Banana-2 $0.085 (1K) / $0.076 (2K) / $0.114 (4K) Qualità premium, 4K nativo

La scelta predefinita onesta dipende dal compito. Per mockup UI e titoli, gpt-image-2 è il più sicuro. Per il fotorealismo 4K nativo, scegli Nano-Banana-2. Per lavori batch in cui puoi permetterti di scartare ogni immagine, scegli qwen-image-2.0 o Nano-Banana.

Per un'app con 100.000 utenti, che generano un'immagine al giorno ciascuno:

  • qwen-image-2.0: $2.500/giorno
  • Nano-Banana-2 1K: $8.500/giorno

L'architettura sopra resta invariata. Cambia solo il campo model nel passo 2.


Passo 8: checklist per la produzione

Prima del lancio:

  1. Applica limiti di frequenza ai route handler: altrimenti un solo IP può esaurire il budget mensile in un pomeriggio. Upstash, Cloudflare Rate Limiting o un semplice contatore Redis vanno bene.
  2. Valida lunghezza e contenuto del prompt prima dell'inoltro lato server. La piattaforma hiapi rifiuta alcuni contenuti; non vuoi pagare la latenza di andata e ritorno per un errore 400.
  3. Imposta una quota giornaliera per utente: memorizza il numero di generazioni per userId e YYYY-MM-DD e rifiuta all'undicesima del giorno con un messaggio amichevole.
  4. Persisti prima di rispondere: non lasciare mai che l'URL del risultato raggiunga il client prima di essere passato per l'archiviazione persistente.
  5. Preimposta la dimensione del contenitore del risultato per corrispondere al rapporto d'aspetto richiesto. Saltare questo passo è la differenza tra un CLS di 0 e uno di 0.4.
  6. Sposta il passo di persistenza in background per rispondere al client in meno di un secondo: restituisci subito l'URL upstream, avvia un job in coda che copia su R2 e aggiorna la riga del database.

L'ultimo punto merita una parola in più: la maggior parte delle app non ne ha bisogno. La persistenza inline mantiene il codice semplice, e i 1–2 secondi extra avvengono dopo i 60 secondi di attesa, quando l'utente sta già guardando l'indicatore di caricamento.


Prossimi passi

Ora hai un'integrazione Next.js completa, sicura e a prova di CLS dell'API di immagini hiapi, in circa 150 righe di TypeScript. Da qui, lo stesso schema POST /v1/tasks si estende ai modelli video (Seedance 2.0 per materiale cinematografico), ai flussi image-to-image (basta aggiungere image_url dentro input) e alla modifica multi-turno: tutto senza cambiare l'architettura di polling.

Se vuoi un punto di partenza funzionante, la pagina dei modelli hiapi elenca tutti i modelli che rispondono a questo endpoint e fornisce la struttura di input di esempio per ciascuno. Inserisci uno qualsiasi di questi nel campo model qui sopra e lo stesso componente renderizzerà il risultato.

Inizia a creare con artefatti IA persistenti

Ottieni la tua chiave API e chiama modelli di immagini, video e audio tramite un'unica API basata sui task.

Leggi la documentazione API