Skip to main content

Command Palette

Search for a command to run...

Building an AI Image App on Cloudflare Workers

Architecture lessons from turning an image-generation prototype into a production Next.js application.

Updated
6 min readView as Markdown

The first version of my coat-of-arms generator was straightforward:

  1. Accept a written description.
  2. Send it to an image-generation provider.
  3. Poll until the job finishes.
  4. 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.

T

The state-machine and credit-reservation parts are the right calls. Two things I'd check on the Workers side. Who actually polls the provider? If the client's status endpoint hits the provider on every poll, that's one upstream call per client tick per job. Moving the poll to a Queue consumer or a Durable Object alarm that polls once per interval and writes state to D1, with the client only reading D1, collapses that back to one poll per job.

Second, reservation leaks on infra failure. If the Worker times out after reserving credits but before the provider task is recorded, that reservation is stranded. Worth having one of the scheduled Workers sweep reservations older than your max generation time with no linked task and release them, otherwise balances slowly drift down.