How to add AI image generation to a Next.js app
Put the API key in a server-only module, add a route handler that submits the prompt to a text-to-image API and waits for the result, and hand the browser a short-lived signed URL for the image. Once video generation is live (coming soon), the same pattern turns that image into a 5 second video.

What you'll build
A Next.js App Router page where a signed-in user types a prompt, gets an image a few seconds later, and, once video is live, can click once more to animate it. The flow:
- The browser posts the prompt to your route, never to the image API.
- The route checks the user, submits an
image.generatejob with your key, and records which user owns the job. - Images usually finish within seconds, so the route asks the API to hold the response until the job is done. If it isn't done in time, the browser polls your status route.
- Your route returns a signed URL for each image, valid for 15 minutes, which goes straight into an
<img>tag. - Coming soon: animating submits a
video.generatejob with the image as its start frame. Video takes minutes, so it's polled or reported by webhook.
The API is Windpaint's. It runs Z-Image Turbo and FLUX.2 [klein] 4B for text to image, with Wan 2.2 image-to-video coming soon for video, and every request and response shape below comes from the Windpaint docs. There's no SDK to install: it's plain fetch. You need Next.js 15 or newer; the webhook step uses after(), which is stable from Next.js 15.1.
1. Get an image generation API key and keep it server-side
Create a key in the dashboard under Settings → API Keys. Keys start with aak_, are shown once, and can spend your organization's credits and read every asset in it. That last part is why the key must never reach a browser or a mobile app. Windpaint is in early access; approved accounts get 200 free credits that expire after 30 days, enough for about 2,500 images at 1k.
WINDPAINT_API_KEY=aak_...# Only needed for webhooks (step 5)WINDPAINT_WEBHOOK_SECRET=whsec_...WINDPAINT_WEBHOOK_URL=https://your-app.example.com/api/windpaint/webhook2. A server-only API helper
One module talks to the API. import "server-only" makes the build fail if a client component ever imports it (npm install server-only). The types follow the documented submit body, status object and media reference.
import "server-only";const API = "https://api.windpaint.ai/v1";export const TERMINAL = new Set(["completed", "failed", "nsfw", "canceled"]);export type MediaRef = { id: string; url: string; // API URL: needs your key, so never send it to the browser content_type: string; width: number | null; height: number | null; duration_s: number | null;};// 202 body from a submit (also what Prefer: wait returns if the job isn't done yet)export type Submitted = { status: "queued"; request_id: string; status_url: string; credits_estimate: string;};// GET /generation/requests/{id}/status, a 200 from Prefer: wait, and a webhook's dataexport type JobStatus = { status: "queued" | "in_progress" | "completed" | "failed" | "nsfw" | "canceled"; request_id: string; outputs: MediaRef[]; video: MediaRef | null; error: string | null; error_code: string | null;};export class WindpaintError extends Error { constructor(public status: number, public code: string | null, message: string) { super(message); }}export async function windpaint<T>(path: string, init: RequestInit = {}): Promise<T> { const res = await fetch(API + path, { ...init, cache: "no-store", headers: { Authorization: `Bearer ${process.env.WINDPAINT_API_KEY}`, "Content-Type": "application/json", ...init.headers, }, }); const body = await res.json().catch(() => null); if (!res.ok) { const error = body?.error; // 401/403 bodies are {"status": false, "error": "Unauthorized"}; others are {"error": {code, message}} throw typeof error === "object" && error ? new WindpaintError(res.status, error.code, error.message) : new WindpaintError(res.status, null, String(error ?? res.statusText)); } return body as T;}// A download link valid for 15 minutes. Mint a fresh one each time you show the asset.export async function signedUrl(assetId: string): Promise<string> { const { data } = await windpaint<{ data: { url: string } }>(`/generation/assets/${assetId}/url`); return data.url;}// What the browser gets: signed links only, no API URLs or project ids.export async function toClient(job: JobStatus) { const outputs = job.status === "completed" ? await Promise.all( job.outputs.map(async (o) => ({ assetId: o.id, url: await signedUrl(o.id), contentType: o.content_type, width: o.width, height: o.height, })), ) : []; return { id: job.request_id, status: job.status, outputs };}The status endpoint returns any job in your organization, so your app has to remember which user started which job. Swap these placeholders for your session and database:
import "server-only";// Replace with your auth: the signed-in user's id, or null.export async function currentUserId(): Promise<string | null> { return "demo-user";}// Replace these with tables in your database.const jobs = new Map<string, string>(); // request_id -> user idconst assets = new Map<string, string>(); // asset id -> user idconst deliveries = new Set<string>(); // webhook-id values already handledexport async function recordJob(requestId: string, userId: string) { jobs.set(requestId, userId);}export async function jobOwner(requestId: string) { return jobs.get(requestId) ?? null;}export async function recordAsset(assetId: string, userId: string) { assets.set(assetId, userId);}export async function ownsAsset(assetId: string, userId: string) { return assets.get(assetId) === userId;}export async function firstDelivery(webhookId: string) { if (deliveries.has(webhookId)) return false; deliveries.add(webhookId); return true;}3. The route that generates the image
Every generation is an async job: a submit returns 202 with a request_id, and the result comes later. For images there's a shortcut. Send Prefer: wait (or wait=N, up to 60 seconds) and the API holds the response until the job finishes. If it finished, you get 200 with the full status object, outputs included. If not, you get the normal 202 body and poll as usual. Either way the route below handles it, by checking whether status is terminal.
import { NextResponse } from "next/server";import { windpaint, toClient, TERMINAL, WindpaintError, type JobStatus, type Submitted } from "@/lib/windpaint";import { currentUserId, recordAsset, recordJob } from "@/lib/store";const ASPECT_RATIOS = new Set(["1:1", "4:5", "16:9", "9:16"]);export async function POST(request: Request) { const userId = await currentUserId(); if (!userId) return NextResponse.json({ error: "Sign in first" }, { status: 401 }); const { prompt, aspectRatio = "1:1", key } = await request.json(); if ( typeof prompt !== "string" || !prompt.trim() || prompt.length > 1000 || !ASPECT_RATIOS.has(aspectRatio) || typeof key !== "string" ) { return NextResponse.json({ error: "Invalid request" }, { status: 400 }); } let job: JobStatus | Submitted; try { job = await windpaint<JobStatus | Submitted>("/generation/capabilities/image.generate", { method: "POST", headers: { Prefer: "wait=30", // hold the response until the image is done, up to 30 s "Idempotency-Key": `${userId}:${key}`, // a repeated submit returns the same job }, body: JSON.stringify({ model: "z-image-turbo", prompt, resolution: "1k", aspect_ratio: aspectRatio, webhook_url: process.env.WINDPAINT_WEBHOOK_URL, // optional, see step 5 }), }); } catch (err) { if (err instanceof WindpaintError && err.code === "billing.insufficient_credits") { console.error("Windpaint balance too low"); // alert yourself; users get a generic error return NextResponse.json({ error: "Generation is unavailable right now" }, { status: 503 }); } if (err instanceof WindpaintError && err.status === 422) { return NextResponse.json({ error: "That prompt couldn't be used" }, { status: 400 }); } throw err; } await recordJob(job.request_id, userId); // Not done within the wait: the browser polls GET /api/jobs/{id}. if (!TERMINAL.has(job.status)) { return NextResponse.json({ id: job.request_id, status: job.status, outputs: [] }, { status: 202 }); } const done = job as JobStatus; for (const o of done.outputs) await recordAsset(o.id, userId); return NextResponse.json(await toClient(done));}A few choices in there are deliberate:
- The browser picks only a prompt and one of a few ratios. Model, resolution and anything else that changes the price stay on the server.
- `Idempotency-Key` makes a repeated submit with the same key and body return the original job instead of creating and billing a second one. Keys last 24 hours.
- A finished job can still be unsuccessful.
Prefer: waitreturns200forfailed,nsfwandcanceledtoo, so the route passes the status through and the page checks it. None of those are charged. - Prompts the content filter blocks are rejected at submit with
422moderation.input_blocked, which lands in the 422 branch. - A low balance is
402billing.insufficient_credits. Log it for yourself and show users a generic message.
4. A status route for polling
When the wait runs out, or for video, the browser polls your status route. It checks ownership, reads GET /v1/generation/requests/{id}/status, and returns signed URLs once the job completes. It serves both images and video.
import { NextResponse } from "next/server";import { windpaint, toClient, type JobStatus } from "@/lib/windpaint";import { currentUserId, jobOwner, recordAsset } from "@/lib/store";export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const userId = await currentUserId(); // The status endpoint returns any job in your organization, so check ownership here. if (!userId || (await jobOwner(id)) !== userId) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } const job = await windpaint<JobStatus>(`/generation/requests/${id}/status`); for (const o of job.outputs) await recordAsset(o.id, userId); return NextResponse.json(await toClient(job));}Poll a second or two after submit and back off, capping around 15 seconds; polling faster doesn't finish the job sooner and every poll counts toward your rate limit. Give video an overall timeout of at least 20 minutes.
5. Webhooks instead of polling
Pass webhook_url on a submit and Windpaint POSTs the job's status to it when the job reaches any terminal status (webhook_events can add started). The body is {type, timestamp, data}, where data is exactly what the status endpoint returns. Deliveries follow the Standard Webhooks spec, are signed with your organization's secret (whsec_..., from Settings → Webhooks → Request webhooks), and are retried for about six hours if you don't answer 2xx within 10 seconds.
import { after } from "next/server";import { Webhook } from "standardwebhooks";import type { JobStatus } from "@/lib/windpaint";import { firstDelivery, jobOwner, recordAsset } from "@/lib/store";import { copyOutput } from "@/lib/copy-output";const wh = new Webhook(process.env.WINDPAINT_WEBHOOK_SECRET!); // whsec_...type JobEvent = { type: string; timestamp: string; data: JobStatus };export async function POST(request: Request) { const raw = await request.text(); // the exact bytes the signature covers const headers = Object.fromEntries(request.headers.entries()); let event: JobEvent; try { // Checks the signature and rejects timestamps more than 5 minutes off. event = wh.verify(raw, headers) as JobEvent; } catch { return new Response("Bad signature", { status: 400 }); } // Retries reuse the same webhook-id; handle each delivery once. if (await firstDelivery(headers["webhook-id"])) { // Answer within 10 seconds; do the slow work after the response is sent. after(async () => { const job = event.data; const userId = await jobOwner(job.request_id); if (job.status !== "completed" || !userId) return; for (const o of job.outputs) { await recordAsset(o.id, userId); await copyOutput(o.id, o.content_type); } }); } return new Response(null, { status: 204 });}npm install standardwebhooks. Three details matter. Verify against the raw body (request.text()), not re-serialised JSON. De-duplicate on the webhook-id header, since a retry after a lost 2xx sends the same delivery again. And decide from data.status, not the order deliveries arrive in, because they can arrive out of order.
The URL must be public https, so use a tunnel for local testing. Keep the polling route as a fallback: if your endpoint is down for longer than the retry window, a slow poll for jobs that stay open too long catches them.
6. Storing and serving the images
Each output's url is an API URL that needs your key, so it's no use to a browser. You have three options:
| Option | How | Lifetime | Use it when |
|---|---|---|---|
| Signed URL | GET /v1/generation/assets/{id}/url | 15 minutes | Showing results in your UI; mint a fresh one on each page load |
| Proxy the bytes | Your route fetches the signed URL and streams it back | Until the asset expires | You want a stable URL on your domain, or browser code has to fetch() the file |
| Copy to your storage | Fetch once, upload to your bucket or CDN | As long as you keep it | Public sharing, emails, heavy traffic |
Store asset ids and request ids in your database, never signed URLs. Assets stay in your project until your retention period ends, 7 days by default, and after that the URL endpoints return 410 generation.asset_gone. Anything users keep longer belongs in your own storage. The webhook above calls this helper for every completed output:
import "server-only";import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";import { signedUrl } from "@/lib/windpaint";const s3 = new S3Client({}); // region, endpoint and credentials from your environmentexport async function copyOutput(assetId: string, contentType: string): Promise<string> { const res = await fetch(await signedUrl(assetId)); // no Authorization header to the storage host if (!res.ok) throw new Error(`download failed: ${res.status}`); const key = `generated/${assetId}`; // keyed by asset id, so a second copy overwrites await s3.send( new PutObjectCommand({ Bucket: process.env.MEDIA_BUCKET, Key: key, Body: Buffer.from(await res.arrayBuffer()), ContentType: contentType, }), ); return key; // store next to the asset id}Signed URLs work in <img src>, <video src> and download links. Browser JavaScript that reads the bytes with fetch(), for example to draw on a canvas, can be blocked by CORS on the storage host; proxy those through your server.
7. Animate the image with an image-to-video API (coming soon)
The image's asset id goes straight into a video job's start_frame: nothing is downloaded or re-uploaded. Send the same aspect_ratio you made the image at, because the clip's shape comes from that field, not from the frame. The output is one 5 second MP4 at 480p with no audio.
import { NextResponse } from "next/server";import { windpaint, type Submitted } from "@/lib/windpaint";import { currentUserId, ownsAsset, recordJob } from "@/lib/store";const ASPECT_RATIOS = new Set(["1:1", "4:5", "16:9", "9:16"]);export async function POST(request: Request) { const userId = await currentUserId(); if (!userId) return NextResponse.json({ error: "Sign in first" }, { status: 401 }); const { assetId, prompt, aspectRatio, key } = await request.json(); if (typeof assetId !== "string" || !(await ownsAsset(assetId, userId))) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } if ( typeof prompt !== "string" || !prompt.trim() || prompt.length > 500 || !ASPECT_RATIOS.has(aspectRatio) || typeof key !== "string" ) { return NextResponse.json({ error: "Invalid request" }, { status: 400 }); } // Wrap in the same try/catch as the image route for 402 and 422. const job = await windpaint<Submitted>("/generation/capabilities/video.generate", { method: "POST", headers: { "Idempotency-Key": `${userId}:${key}` }, body: JSON.stringify({ model: "wan-2.2-i2v", prompt, inputs: { start_frame: [assetId] }, // the image's asset id, no download or re-upload aspect_ratio: aspectRatio, // same ratio the image was made at webhook_url: process.env.WINDPAINT_WEBHOOK_URL, }), }); await recordJob(job.request_id, userId); return NextResponse.json({ id: job.request_id, status: job.status, outputs: [] }, { status: 202 });}Video takes several minutes, and Prefer: wait caps at 60 seconds, so this route returns 202 straight away and the page polls /api/jobs/{id} (or the webhook copies the clip when it lands). When it completes, the clip is in outputs[0] with content_type video/mp4.
The prompt for a video job works differently from an image prompt. The frame already fixes the scene, so describe what moves: one camera move, the subject's motion and some ambient motion. Restating the picture adds little. The Wan 2.2 image-to-video page covers that model's prompting and its camera presets, which you can send as "preset": "dolly-push-in" in place of a prompt.
8. The page
A minimal client component. It only ever calls your own routes; the Network tab shows /api/* calls and the signed storage URLs, never the key.
"use client";import { useState } from "react";type Output = { assetId: string; url: string; contentType: string; width: number | null; height: number | null };type Job = { id: string; status: string; outputs: Output[] };const TERMINAL = ["completed", "failed", "nsfw", "canceled"];async function submit(path: string, body: object): Promise<Job> { const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, // One key per click. If you add retries, resend the same key. body: JSON.stringify({ ...body, key: crypto.randomUUID() }), }); if (!res.ok) throw new Error((await res.json()).error ?? "Request failed"); let job: Job = await res.json(); let delay = 1500; while (!TERMINAL.includes(job.status)) { await new Promise((r) => setTimeout(r, delay)); job = await (await fetch(`/api/jobs/${job.id}`)).json(); delay = Math.min(delay * 1.5, 15000); } if (job.status !== "completed") throw new Error("That didn't work. Try a different prompt."); return job;}export default function Generate() { const [prompt, setPrompt] = useState(""); const [image, setImage] = useState<Output | null>(null); const [video, setVideo] = useState<Output | null>(null); const [busy, setBusy] = useState<"image" | "video" | null>(null); const [error, setError] = useState<string | null>(null); async function run(kind: "image" | "video", task: () => Promise<void>) { setBusy(kind); setError(null); try { await task(); } catch (e) { setError(e instanceof Error ? e.message : "Something went wrong"); } finally { setBusy(null); } } const generate = () => run("image", async () => { setVideo(null); const job = await submit("/api/images", { prompt, aspectRatio: "16:9" }); setImage(job.outputs[0]); }); const animate = (assetId: string) => run("video", async () => { const job = await submit("/api/videos", { assetId, prompt: "Slow push in. Gentle ambient motion in the scene.", aspectRatio: "16:9", }); setVideo(job.outputs[0]); }); return ( <main> <textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Describe an image" /> <button onClick={generate} disabled={!!busy || !prompt.trim()}> {busy === "image" ? "Generating..." : "Generate"} </button> {error && <p>{error}</p>} {image && <img src={image.url} width={image.width ?? undefined} height={image.height ?? undefined} alt={prompt} />} {image && ( <button onClick={() => animate(image.assetId)} disabled={!!busy}> {busy === "video" ? "Rendering clip (a few minutes)..." : "Animate"} </button> )} {video && <video src={video.url} controls autoPlay loop muted playsInline />} </main> );}If a user comes back later, call /api/jobs/{id} again for fresh signed URLs instead of reusing old ones. For video, show that it takes a few minutes and let users leave and come back.
What it costs and what to check before launch
| Capability | Model | Tier | Credits | USD |
|---|---|---|---|---|
image.generate | z-image-turbo | 1k | 0.08 per image | $0.004 |
image.generate | flux-2-klein-4b | 1k | 0.10 per image | $0.005 |
video.generate (coming soon) | wan-2.2-i2v | 480p, 5 s | 4 per clip | $0.20 |
The price is fixed and held at submit, and charged only if the job completes; failed, refused and canceled jobs cost nothing. To check a request without running it, POST /v1/generation/estimate with the same body returns the price and output size. More on pricing.
- The key lives only in server environment variables.
- Every status and asset route checks that the user owns the job.
- Users choose only the inputs you allow; cost-affecting settings stay on the server.
- Your database stores request ids and asset ids, not signed URLs.
- Media kept beyond your retention period is copied to your own storage, and routes handle
410for expired assets. - A low balance alerts you (set a threshold in Settings → Billing) and shows users a generic error.
For a full write-up of this pattern, including an Express version, see the media in your app recipe; for a standalone receiver, the webhook receiver recipe. If you'd rather have a coding agent wire this up, the image generation MCP guide connects the same API to Claude Code, Cursor or Codex.
Sources
Questions.
How do I make an AI image generator?
Use a text-to-image API rather than training a model. Your server sends the prompt to the API with your key, waits for the job or receives a webhook, and returns a link to the image. Your front end only ever talks to your own server.
Where do I put the API key in a Next.js app?
In a server-only environment variable such as WINDPAINT_API_KEY in .env.local, read from route handlers or server actions. Never use a NEXT_PUBLIC_ prefix, which ships the value to the browser.
How long does AI image generation take through an API?
On Windpaint, images usually finish within seconds of being picked up, so a submit with Prefer: wait normally returns the finished image. Video takes several minutes, so poll or use a webhook.
How much does a text to image API cost?
On Windpaint, Z-Image Turbo costs 0.08 credits ($0.004) per 1k image and FLUX.2 [klein] 4B costs 0.10 credits ($0.005). Failed jobs are not charged.
Can I turn a generated image into a video with an API?
Yes. Pass the image's asset id as the start_frame of a video.generate job with a prompt describing the motion. On Windpaint this is coming soon: it will return a 5 second 480p MP4 for 4 credits.