Add image generation to a Next.js app
The two mistakes people make wiring a generation API into a React app are calling it from the browser with the key exposed, and awaiting the generation inside a request handler until it times out. This walks through the shape that avoids both.
- Key location
- Server only
- Request
- Returns a job id
- Delivery
- Webhook or poll
Never call the API from the browser
An API key in client-side code is a public API key. Anyone can read it out of the bundle and spend your balance. Every call has to go through your own server, which also gives you the place to enforce your own per-user limits.
Keep the key in an environment variable that is not prefixed for client exposure, and read it only inside a route handler or server action.
A route handler that submits the job
The handler validates the user, submits the job, and returns the id immediately. It never waits for the image.
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { prompt } = await req.json();
// authenticate the user and apply your own quota here
const res = await fetch("https://api.fastgencloud.com/v1/images/generations", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FASTGEN_API_KEY!}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
model: "general-image",
prompt,
size: "1024x1024",
webhook_url: `${process.env.PUBLIC_URL}/api/hooks/fastgen`,
}),
});
if (!res.ok) {
const err = await res.json();
return NextResponse.json({ error: err.error?.message ?? "generation failed" }, { status: 502 });
}
const job = await res.json(); // { id, status: "queued", … }
return NextResponse.json({ jobId: job.id });
}Receiving the result
The webhook handler needs the raw body to verify the signature, so read it as text before parsing. Deduplicate on the delivery header — retries reuse it.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(req: Request) {
const raw = await req.text(); // raw body, before JSON.parse
const header = req.headers.get("x-fastgen-signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const expected = createHmac("sha256", process.env.FASTGEN_WEBHOOK_SECRET!)
.update(`${parts.t}.${raw}`)
.digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (!fresh || expected.length !== given.length || !timingSafeEqual(expected, given)) {
return new Response("bad signature", { status: 400 });
}
const event = JSON.parse(raw);
// persist event.data — the output URLs expire after 7 days, so copy them to
// your own storage here rather than handing them straight to the client
return new Response("ok");
}Polling instead, if you have no public URL
In local development your machine has no public callback URL. Polling from your own server is the simple fallback — and it is the right choice for fast models, where the job is usually done in a couple of seconds.
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const res = await fetch(`https://api.fastgencloud.com/v1/jobs/${id}`, {
headers: { Authorization: `Bearer ${process.env.FASTGEN_API_KEY!}` },
cache: "no-store",
});
const job = await res.json();
return Response.json({ status: job.status, output: job.output ?? null });
}The client side
The browser talks only to your own routes. Submit, then poll your job route until it resolves.
"use client";
import { useState } from "react";
export function Generate() {
const [url, setUrl] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function run(prompt: string) {
setBusy(true);
const { jobId } = await fetch("/api/generate", {
method: "POST",
body: JSON.stringify({ prompt }),
}).then((r) => r.json());
// poll your own route; back off rather than hammering it
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 1500));
const job = await fetch(`/api/jobs/${jobId}`).then((r) => r.json());
if (job.status === "succeeded") { setUrl(job.output[0].url); break; }
if (job.status === "failed") break;
}
setBusy(false);
}
return ( /* your form, plus <img src={url} /> when it lands */ null );
}Things that will bite you
- Output URLs expire after 7 days — copy anything durable into your own storage on receipt
- A cold model adds a load delay to the first call; design the UI for a wait rather than a spinner that implies milliseconds
- Send an Idempotency-Key on every submit so a client retry replays the job instead of charging twice
- Apply your own per-user quota in the route handler — your prepaid balance is shared across all your users
- Do not proxy the generated image through a serverless function if you can avoid it; hand the client a URL
Frequently asked questions
- Can I call the API from a server action instead of a route handler?
- Yes — anything server-side works. The rule is only that the key never reaches the browser. Route handlers are shown here because the webhook receiver has to be a route handler anyway.
- How do I test webhooks locally?
- Either use a tunnel to expose your dev server and pass that as webhook_url, or skip webhooks in development and poll the job endpoint from your own server. Both paths return the same job object.
- Which model should I start with?
- general-image — it is the photoreal default and the cheapest text-to-image rate on the platform. Switch the model string to anime or cartoon when a request needs that style; nothing else about the call changes.
- Do you have an SDK?
- Not yet. The API is plain REST with a published OpenAPI document, so you can generate a typed client for your language today.
Related
Start generating
Create an account, add a prepaid balance, and call the API with a key from the console. No subscription, no minimum, no per-seat pricing.
Get an API key