Media in your app.
Serve generated images and video to your users without exposing your key. Next.js and Express.

- Stack
- Next.js 15+ App Router, or Express on Node 18+
- Needs
- API key in server env only
- Uses
- backend proxy, signed URLs, assets
Your key never reaches the browser.
An API key spends your organization's credits and can read every asset in it. Windpaint has no publishable or browser-safe keys, so a web or mobile client can't call the API directly.
Instead the browser talks to your backend. Your backend submits the job with your key, remembers which user owns it, and hands back signed URLs that work in an <img> or <video> tag for 15 minutes.
How it fits together.
The recipe in order, from the first request to the saved file.
Submit from your server
POST /api/imageschecks the user, validates the prompt and aspect ratio, and submits with your key. Model and resolution stay on the server.Record ownership
Store the
request_idagainst the user. The status endpoint returns any job in your organization, so your backend does the access check.Poll your own route
The browser polls
/api/images/{id}, which reads the job's status with your key and returns only what the page needs.Hand out signed URLs
When the job completes, mint a URL per output with
GET /. It's valid for 15 minutes, so mint a fresh one each time.v1/ generation/ assets/ {id}/ url Copy what you keep
Store asset ids, not URLs. For media that must outlive your retention period, copy the bytes to your own bucket once.
The core of it.
An excerpt from the recipe. The full version runs as-is.
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const userId = await currentUserId(); if (!userId || !(await ownsJob(id, userId))) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } const job = await windpaint<Status>(`/generation/requests/${id}/status`); const outputs = job.status === "completed" ? await Promise.all(job.outputs.map(async (o) => ({ assetId: o.id, url: await signedUrl(o.id), // valid 15 minutes contentType: o.content_type, }))) : []; return NextResponse.json({ status: job.status, outputs });}The full recipe has the server-only API helper, the submit route, a byte proxy, the client page, an Express version, and a copy-to-S3 helper.
Whatever your users ask for.
Your backend passes their prompt through and hands back signed URLs. These are real Z-Image Turbo outputs, each with its prompt and seed.

smiling woman with short teal hair

bearded man with round glasses

teenager with braids and headphones

Clay avatarClay figurine style avatar of a smiling woman with a yellow raincoat, soft studio…

Anime avatarAnime style avatar of a young man with messy hair and headphones, clean…

Watercolor avatarWatercolor portrait avatar of an older woman with silver hair and round glasses…

Pixel avatarPixel art avatar of a cheerful astronaut with a mint visor, 32-bit style, plain…

Recipe cardOverhead photograph of a bowl of green curry with jasmine rice and fresh herbs…

Travel cardA white-washed village with blue domes above the sea at golden hour, travel…
Three ways to serve a file.
A job's own output URL needs your key, so it's no use to a browser. Pick one of these instead.
| Option | Lifetime | Use it when |
|---|---|---|
| Signed URL | 15 minutes | Showing results in your UI. GET /, minted fresh on each page load. |
| Proxy the bytes | Until the asset expires | You want a stable URL on your own domain, or browser JavaScript has to fetch() the file. |
| Copy to your storage | As long as you keep it | Public sharing, embedding in emails, heavy traffic, or media that must outlive retention. |
Questions.
Why not put the API key in a public environment variable?
Anything in a client bundle, including NEXT_PUBLIC variables, is readable by your users, and the key can spend your credits and read every asset in your organization. Keep it in server environment variables and call Windpaint from your backend.
How long do signed URLs last?
15 minutes. Store the asset id and mint a new URL when a page loads. Once the asset passes your retention period, 7 days by default, the URL endpoints return 410.
Can browser JavaScript fetch a signed URL?
Signed URLs work in img and video tags and in download links. A fetch() that reads the bytes can be blocked by CORS on the storage host, so proxy the bytes through your backend for that case.