Topic 11.3
Image Generation UX
In one line
Making a picture takes many seconds and fails in more ways than text does. Reserve the space so the page does not jump, show honest progress, give a refusal a different screen from an error, keep the pictures that did succeed, and describe every generated image with alt text.
Think of it like this
Ordering a cake from a bakery. You are told it will be ready at four. While you wait, you are told it is in the oven. If they cannot make it (no more red icing), they say so and offer another colour. They do not hand you an empty box. A picture that takes a minute to make needs the same service: a promise, a status, and an honest no.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- generateImage
- The AI SDK function that asks an image model for pictures and returns them as files.
- Placeholder
- A box of the final size shown while the real picture is not ready.
- Layout shift
- Content jumping because something above it changed size. Reserving space avoids it.
- Job
- Work that continues on the server after the first request returns, identified by an id.
- 202 Accepted
- A status meaning the request was received and the work is not finished.
- Polling
- Asking the server for the state again and again until it is done.
- Refusal
- The model or safety system declining to create something. Not a technical failure, and not worth retrying unchanged.
- Promise.allSettled
- Waits for every promise and reports each one's success or failure, without throwing on the first.
- Alt text
- A text description of an image in the
altattribute, read by screen readers and shown if the image fails.
Step by step
01The shape of a request
Your route validates the prompt (an empty one is a 400), calls generateImage, and returns the image as JSON with its mediaType and base64. The page builds a data URL from them, or you return the bytes and use an object URL. Pictures are bigger than text, so decide early where they live (Topic 11.2 discussed data URLs against links).
import { generateImage } from "ai";
export async function POST(req: Request) {
const { prompt } = await req.json();
if (typeof prompt !== "string" || !prompt.trim()) {
return Response.json({ error: "Describe the picture first." }, { status: 400 });
}
const { image } = await generateImage({
model: provider.imageModel("your-image-model"), // the one line that changes in a real app
prompt,
size: "1024x1024",
});
return Response.json({ mediaType: image.mediaType, base64: image.base64 });
}02Reserve the space, then swap
In the first example the frame is 256 by 256 pixels from the start. While the stand-in model works, the frame holds the words "Drawing…". The <img> replaces them when it has loaded, and onLoad is the moment to switch the state to done. The console prints the frame size at both moments, then tries an empty prompt to show the 400 path.
03Long waits: start a job and poll
The second example starts two jobs. The first moves queued, running, running, done, and the page prints the words it would show at each poll. The second never finishes: after four polls the page stops asking and offers "keep waiting" or "cancel". A third request asks for a job that does not exist (the server restarted, or the job expired) and gets 404.
Poll slowly and back off: ask every second at first, then every few seconds. Stop polling when the user leaves the page.
04Four failures, four screens
The third example makes the route fail four ways and shows how the page differs. The route turns what the SDK throws into a small set of kinds: busy, refused, empty, failed. The page then switches on the kind. A refusal has no retry button.
05Three pictures, one of them refused
The fourth example asks for three variations with three separate requests and Promise.allSettled. The second is refused. The page shows two pictures with alt text, and one tile with its own message and a retry button. It then checks every <img> for an alt attribute and prints the result, which is the check you can run in a test.
Try it yourself
- 1
Remove the fixed size
In the first example delete
width: 256, aspectRatio: "1 / 1",from the frame's style and run it again.The "frame while waiting" line now shows a thin, wide strip (284x18 in the checker) and "frame now" a box over 250 pixels tall (284x274): the frame grew to fit the picture, and the numbers depend on the width of your preview. In a real page that jump moves the text below the picture while the person is reading it.
- 2
Poll more times
In the second example change
generate("a stuck teapot", 4)togenerate("a stuck teapot", 8).You get eight identical
runninglines. Compare that with the cost: eight requests for nothing. Real pages poll less often as time passes, and show "this is taking longer than usual" instead of more of the same words. - 3
Offer a retry for a refusal
In the third example change the
refusedscreen toretry: "now".The page now invites the user to send the same refused description again. It will be refused again each time, and a policy limit may count every attempt. The better choice is to send them back to edit the description.
Code & diagrams
The stand-in draws a square. The frame is 256 by 256 from the start, so nothing moves when the picture arrives. The empty description is refused by the route with a 400, and the error state starts the real request.
Preview
Console
Nothing logged yet.Expected console output
frame while waiting: 256x256
error shown: Describe the picture first.
frame while waiting: 256x256
image decoded: 256x256, frame now: 256x256Every poll is one request that answers at once, so no connection is held open. The page decides the words, the limit and what happens when the limit is reached.
Preview
Console
Nothing logged yet.Expected console output
started job_1 (status 202)
poll 1: queued -> In line…
poll 2: running -> Drawing your picture…
poll 3: running -> Drawing your picture…
poll 4: done -> Your picture is ready
started job_2 (status 202)
poll 1: running -> Drawing your picture…
poll 2: running -> Drawing your picture…
poll 3: running -> Drawing your picture…
poll 4: running -> Drawing your picture…
not done after 4 checks -> stop polling, ask: keep waiting, or cancel?
unknown job: 404 No such jobFour different things go wrong; the route names each kind, and the page picks the screen from the kind. The (server) line is the route's own log of an unexpected error, if any.
Preview
Console
Nothing logged yet.Expected console output
busy -> 429 busy
shows: "Many people are drawing right now. Try again in 12 seconds." | retry: later | edit description: false
refused -> 422 refused
shows: "We can't create that picture. Try describing something different." | retry: none | edit description: true
empty -> 502 empty
shows: "No picture came back. Please try again." | retry: now | edit description: false
broken -> 500 failed
shows: "Something went wrong on our side. Please try again." | retry: now | edit description: falseThree separate requests collected with Promise.allSettled. The prompt contains quotes and a tag on purpose: React sets alt as text, so it stays text.
Preview
Console
Nothing logged yet.Expected console output
pictures shown: 2, tiles with a message: 1
alt: Generated image 1 of 3: a red teapot on a wooden "table" <b>bold</b>
alt: Generated image 3 of 3: a red teapot on a wooden "table" <b>bold</b>
every image has an alt attribute: true
alt equals the text exactly (no HTML injected): true
retry buttons: 1Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use Promise.all instead of allSettled
In the fourth example, replace const results = await Promise.allSettled([1, 2, 3].map(load)); with const results = await Promise.all([1, 2, 3].map(load));, and replace the setTiles(...) line with setTiles(results);. Run it.
Myth vs fact
Myth
A refused image is an error, so retry it.
Fact
A refusal is a decision about the description. The same description is refused again. Send the user back to change it.
Myth
One long request is the simplest way to wait for an image.
Fact
Proxies, hosts and mobile networks close idle connections. A job with polling survives a reload and lets you show real status.
Myth
Alt text should describe the picture in detail, as the model sees it.
Fact
You do not know what the picture shows. Say what was requested, keep it short, and label the image as AI-generated.
Myth
generateImage({ n: 3 }) returns the pictures that worked.
Fact
It is all or nothing for the call: a failure rejects the whole call. Make separate requests if you want partial results.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Cancel on the server too. When the user closes the tile or starts a new description, abort the polling on the page and tell the server to stop the job (or let it finish and discard it). Orphaned jobs are cost you pay and nobody sees.
- ▸
Store the finished picture, not a data URL in the chat history. A 1024 by 1024 PNG as base64 can be over a megabyte of text, and saving it in
localStoragewith the conversation (Topic 4.4) will fill the quota quickly. Save a link or an id and fetch the image when needed. - ▸
Provider-side limits differ and change: sizes and aspect ratios offered, number of pictures per call, and the way a refusal is reported. Keep the provider-specific code in the route, map it to your own kinds, and the page does not need to change when you change provider.
- ▸
Mark the result as AI-generated near the picture, and keep that label when the image is downloaded or shared if your product allows it. Some places require it; at the least it is honest.
Remember this
- 1
The problem this solves: Text starts arriving in under a second and grows. A generated image usually arrives all at once, after many seconds, and can fail in ways text does not: the model refuses the description for safety reasons, returns nothing, or is busy. A page that shows a spinner and then either a picture or "Error" leaves the user guessing. This topic gives you the screens for every outcome.
- 2
In the AI SDK you make an image with
generateImage({ model, prompt, size }). It returnsresult.image(the first picture) andresult.images. Each is a file object withbase64,uint8Arrayand amediaTypethat the SDK works out from the bytes (image/pngfor a PNG). In a real app this runs in your route, with an image model from your provider (provider.imageModel("your-image-model"), the method every AI SDK provider has; many also offer a short form such asprovider.image(...), and the model names are in your provider's documentation). In the examples here a stand-in model draws a coloured square. - 3
Reserve the space. Give the picture's frame its final size before the picture exists (
widthplusaspect-ratioin CSS, or the requestedsize). Otherwise the page jumps when the image arrives, and everything below it moves under the user's finger. The first example prints the frame's size while waiting and after the image appears: they are the same. - 4
Long waits need a promise and a status. Under about 10 seconds, a placeholder with the words "Drawing your picture…" is enough. For longer, do not hold one HTTP request open: many hosts and proxies close idle connections. Start a job: the first request returns an id at once (status
202 Accepted), and the page asksGET /jobs/<id>every second or so (polling). The answer is a state:queued,running,doneorfailed. Show words for each state, give up politely after a limit ("still working, keep waiting?"), and let the user cancel. - 5
Failures are not all the same, and their screens differ. *Busy* (
429, maybe withretry-after): say when to try again. *Refused* (the safety system rejected the description): do not offer an identical retry, which fails again and may count against a policy limit; offer "change the description". *Empty* (the call succeeded and no picture came back; the SDK throwsNoImageGeneratedError): offer a retry. *Server error* (500): a retry. How a provider signals a refusal differs (an error code, a status, an empty result), so map it to your own small set of kinds on the server and let the page switch on that kind. - 6
Several pictures: one failure must not lose the rest.
generateImage({ n: 3 })is all or nothing. If you want to show the two that worked, make three separate requests and collect them withPromise.allSettled, which waits for all and reports each outcome. A failed tile gets its own message and its own retry button. - 7
Alt text. An image without an
altattribute is announced by screen readers by its file name, or skipped. For a generated image the best alt text is a short description of what was asked for:"Generated image: a red teapot on a wooden table". Do not use the model's invented caption as fact, shorten very long prompts, and use React ortextContentto set it (never string-building HTML, because a prompt with a quote mark would break out of the attribute). Mark the picture as AI-generated next to it, and give it a name to download. Topic 10.2 covers AI labels and why they matter for trust.
Explain it without notes
Why reserve the picture's space before it exists?
When should an image request become a job with polling?
How should a refusal differ from an error on screen?
How do you show partial results when you ask for several pictures?
What should the alt text of a generated image say?
Practice
Add a visible countdown label to the busy screen of the third example: "Try again in 12 seconds" counting down from the retry-after value, using an effect on a state that you decrement from a timer.
In the second example, make the polling interval grow: 15 ms, 30 ms, 60 ms, never above 120 ms. Print the intervals.
Write altFor(prompt, index, total) that returns the alt text for tile index of total, and returns a safe default for an empty prompt.
Trade-offs
- ↔
Polling is simple and works everywhere but costs requests and has a delay of up to one interval. Server-Sent Events or a WebSocket show progress instantly with less traffic, and need more infrastructure.
- ↔
Showing a fake progress bar looks comforting and is dishonest if the numbers are invented. Use real states (queued, running, done) and words, and show a bar only when the provider reports progress.
Done when you can
I can call
generateImagein a route and return the picture.I can reserve space so the page does not jump.
I can turn a long task into a job with a polled state.
I can tell a refusal from a retryable error and show a different screen for each.
I can keep the pictures that worked when one of several fails.
I can write safe alt text for a generated image.