Building an AI Image App on Cloudflare Workers
Architecture lessons from turning an image-generation prototype into a production Next.js application.
The first version of my coat-of-arms generator was straightforward:
- Accept a written description.
- Send it to an image-generation provider.
- Poll until the job finishes.
- Show the resulting image.
That prototype worked, but turning it into a production application required much more engineering than the model call itself. The difficult parts were managing asynchronous state, protecting generated assets, reserving credits safely, handling provider failures, and moderating both inputs and outputs.
This article explains the architecture I ended up building with Next.js, React, and Cloudflare Workers.
Treating image generation as a state machine
An image-generation request can remain active for several minutes and fail at multiple stages. I therefore stopped treating it as one long HTTP request.
Every generation receives a task ID and moves through a small set of states:
generating → ready
generating → failed
generating → blocked
ready → expired
The database record also stores the owner, device identity, requested resolution, credit reservation, output keys, and revision lineage.
The request flow now looks like this:
validate input
→ moderate prompt
→ reserve quota or credits
→ create provider task
→ store task metadata
→ poll provider
→ moderate generated image
→ preserve original and preview
→ consume or release the reservation
Persisting the workflow means a browser refresh does not lose the job. It also gives the server one authoritative place to decide whether a user may view, refine, or download a result.
Reserving credits before calling the provider
Charging credits after a successful request sounds simple, but it creates race conditions. Two simultaneous requests could both see the same balance and proceed.
The application instead reserves credits before starting the external task. A reservation temporarily deducts credits from one or more credit lots and records the allocation in a ledger.
When generation succeeds, the reservation is consumed. When the provider or infrastructure fails, the reservation is released and the credits return to their original lots.
This model also makes webhook and retry handling easier because every financial mutation has an idempotency key. Repeated delivery of the same event does not grant or deduct credits twice.
The same reservation pattern is used for anonymous preview quotas.
Running Next.js on Cloudflare Workers
The application uses Next.js 16 and React 19 through OpenNext for Cloudflare. The deployed Worker has the nodejs_compat flag enabled, but I still avoid assuming that a traditional Node.js filesystem or long-running server process exists.
Cloudflare bindings provide the infrastructure:
- D1 stores accounts, sessions, generations, credit ledgers, orders, and moderation records.
- R2 stores private generated images and public sharing variants.
- Cloudflare Images produces resized and watermarked previews.
- Workers Assets serves the static application.
- Scheduled Workers perform maintenance and retention tasks.
One practical lesson was to keep environment access behind a small runtime helper. During local Next.js development, values come from process.env. In production, secrets and bindings come from the Cloudflare execution context.
Keeping that distinction out of the business logic made the same generation workflow usable in local development, preview deployments, and production.
Never exposing the provider's image URL
The generation provider returns a temporary image URL. I did not want that URL to become the product's permanent download mechanism.
When a task finishes, the server fetches the image itself. It validates the response status, MIME type, declared size, and final byte length before accepting it.
The image is then stored in a private R2 bucket under two keys:
private/generations/{taskId}/original.png
private/generations/{taskId}/preview-512.png
The original remains private. A separate authenticated route checks ownership or device identity before streaming any version to the browser.
For anonymous previews, Cloudflare Images creates a 512-pixel version and draws a watermark over it. Signed-in generations can expose an unlocked version according to the user's access record.
This architecture avoids relying on upstream retention and makes access control independent of the model provider.
Moderation as a layered system
A public image-generation tool needs more than a single keyword list.
Every prompt first passes through local rules. The rules identify prohibited sexual content, content involving minors, non-consensual material, bestiality, and graphic gore.
Moderation history produces a risk profile:
normal → watch → high → restricted
Most normal requests stay entirely within the local system. Higher-risk users receive an additional remote text check, and their reference images and generated outputs are checked before release.
This reduces external moderation cost while still escalating repeated or higher-risk behavior.
The remote moderation quota has a hard monthly limit. If a high-risk request cannot be reviewed safely, the workflow fails closed instead of exposing an unchecked result.
Sensitive evidence is encrypted, retained for a limited period, and only decryptable through an audited administrative workflow.
Making local development deterministic
External generation APIs make local development slow and unpredictable. They also make ordinary UI work consume real quota.
In development, the server creates a local task ID instead of calling the provider. The status endpoint simulates progress for several seconds and then returns a fixed test image.
Local D1, R2, and Images bindings run through the Cloudflare development environment. This makes it possible to test polling, authorization, preview delivery, credit settlement, and failure states without contacting production services.
I also added a local fallback for image transformation because Miniflare can time out when processing multi-megabyte PNG files. Production still uses the real Cloudflare Images pipeline.
Supporting revisions without losing history
Image refinement creates another asynchronous generation, but it should not become an unrelated task.
Each revision stores:
- The root task ID
- The parent task ID
- The revision instruction
- The associated project
- Any exact text that should be preserved
This produces a generation tree rather than a flat list. The UI can display earlier versions, and the backend can maintain authorization across the entire refinement history.
Publishing without exposing private source material
Users can optionally turn a finished image into a public story page.
The public page receives separate image variants for the page, embeds, social previews, and branded posters. The high-resolution original remains private.
The original prompt and uploaded reference image are not displayed publicly. Story pages are also noindex by default because publishing a creation does not automatically mean the user wants it included in search results.
What I learned
The model request became one of the smallest parts of the finished system.
The most useful architectural lessons were:
- Persist asynchronous work instead of tying it to one HTTP request.
- Reserve scarce resources before starting external side effects.
- Make every settlement and webhook operation idempotent.
- Copy provider outputs into storage you control.
- Treat generated media as private unless publication is explicit.
- Model moderation failures and provider failures as normal workflow states.
- Build deterministic local substitutes for slow external services.
The application is running at coatofarmsmaker.org.
