Skip to content

Building My Wife a Photography Site for Almost $0 a Month

My wife is starting a part-time photography business, so I built her a Next.js site on Cloudflare designed around $0 egress. Then a Next prefetch quietly tripped Cloudflare's CPU limit and took the gallery down.

· · 8 min read
Building My Wife a Photography Site for Almost $0 a Month

My wife is starting a photography business. It is a part-time thing for now, she has plenty else going on, and it may stay small for a while or it may grow. Either way she needed a site: somewhere to show her work, book sessions, and hand finished galleries to clients. What she did not need was a monthly bill that ate the income of a business that has barely started. So the brief I gave myself was simple. Make it good, and make it cost almost nothing to run.

This is the build log for brittnyann.com. It is the most involved of the client sites I have done, and the one where the most interesting bug was entirely my own architecture biting back.

What it does

Brittny drops photos into a storage bucket, one folder per gallery, and a gallery appears on the site. Public galleries are watermarked and view-only, the shopfront work. Private galleries are the ones she delivers to clients: each is reached by an unguessable link, and from there a client can download the full-resolution files. An admin area lets her set a title, a cover, and a date. The homepage is a slideshow of her best frames. That is the whole product.

How I worked with Claude on this one

This was a two-phase build. The first phase was chat: I talked through the architecture with Claude before writing anything, because the cost model was the hard constraint and I wanted to get it right on paper first. The $0-egress design, the folder-as-gallery idea, the share-code scheme for private galleries, those came out of that back-and-forth.

The second phase was Claude Code doing most of the actual typing: the Next.js app, the two Cloudflare workers, the admin. I directed and reviewed and made the calls. Claude wrote the bulk of the lines. That split is becoming my default for anything with real surface area: think it through in chat, then grind it out in Claude Code.

The stack, and why

Next.js 15 on Cloudflare, deployed with OpenNext. Photos in Cloudflare R2. Resizing through Cloudflare Images. Gallery metadata in Cloudflare D1. A contact form on EmailJS.

The reason for all of it is the same: cost. R2 charges no egress fees, which is the whole game for an image-heavy site, serving photos is normally where the bandwidth bill comes from. One choice I want to call out because Claude pushed it and I kept it: data access goes over Cloudflare’s HTTP APIs (the S3 API for R2, the D1 HTTP API) rather than runtime bindings. That adds a little latency, but it means the exact same code runs in plain Node, on any host, not just on Cloudflare. For a small site whose hosting I might want to move someday, that portability was worth more than the few milliseconds.

Where Claude surprised me: the whole no-egress shape

The piece I did not expect Claude to nail in chat was the image-serving architecture, and it is the part I am happiest with.

The bucket is private and has no public URL at all. A single worker is the only public read path for images. It checks access, pulls the original from R2, resizes it on the fly to the requested variant, edge-caches the result, and never lets a full-resolution file out. Private keys require a share code that actually owns the gallery:

// workers/images/src/index.ts
if (decodedKey.startsWith("private/")) {
  const code = url.searchParams.get("code") ?? "";
  const ok = await authorizePrivate(env, decodedKey, code);
  if (!ok) return new Response("Forbidden", { status: 403 });
}
const object = await env.BUCKET.get(decodedKey);
const result = await env.IMAGES.input(object.body)
  .transform({ width })
  .output({ format: "image/webp", quality });

The matching piece is the “drop a folder, get a gallery” model. A scheduled worker reconciles the bucket against the database: new folders become galleries, and private ones get an unguessable share code minted on the spot. This was Claude’s suggestion and it is the thing that makes the site usable by a non-engineer:

// workers/r2-sync/src/index.ts
function makeShareCode(slug: string): string {
  const initials = slug.split(/[-_]/).map((w) => w[0]).join("").toUpperCase().slice(0, 5);
  // 12 hex chars, ~48 bits of entropy; the initials prefix is cosmetic.
  const rand = crypto.randomUUID().replace(/-/g, "").slice(0, 12).toUpperCase();
  return `${initials || "G"}-${rand}`;
}

There is no upload pipeline for her to learn. She moves files into a folder, and the gallery, the share code, and the resized variants all happen on their own.

The hard part: watermarking on the Images binding

The single most painful stretch was watermarking the public gallery photos. It should have been a one-liner: draw a transparent PNG in the bottom-right corner. It was not.

The Cloudflare Images binding kept emitting the watermark at its native size, ignoring the width I set on the draw overlay, so the mark overflowed the photo and clipped. I lost an evening to it, and the git history is a wall of debug(watermark) commits to prove it. The fix, which Claude and I only landed after a lot of wrong turns, was to resize the watermark in a completely separate pass with fit: "scale-down", then composite the already-sized result:

// A chained .transform() on the draw overlay is ignored, so pre-size the mark
// in its own pass first, then draw it.
const markSized = await env.IMAGES.input(mark.body)
  .transform({ width: markWidth, fit: "scale-down" })
  .output({ format: "image/png" });
const markBytes = await markSized.response().arrayBuffer();
pipeline = env.IMAGES.input(baseBody)
  .transform({ width: outW })
  .draw(env.IMAGES.input(new Response(markBytes).body), { bottom: offset, right: offset, opacity: 0.5 });

This was a genuinely-together fix. Neither Claude nor I guessed the separate-pass trick up front; we got there by instrumenting the worker, reading the actual output widths back in a debug header, and ruling things out one at a time.

Where Claude fell short: the bug that took the page down

The worst bug was not in the watermark. It was in the “Download all” button, and it is the most useful thing in this whole post.

Claude built that button as a Next <Link> to a Worker route that streamed a zip of every full-resolution original, CRC32-ing gigabytes as it went. Reasonable on its face. Except Next prefetches links the moment they scroll into view. So a client simply opening a private gallery silently kicked off a multi-gigabyte zip build inside the Worker, with no click, and tripped Cloudflare’s CPU-time limit (error 1102). The gallery page went down on load.

This is a great example of how Claude can write something locally correct that is globally wrong. The Worker route was fine. The Link was fine. The interaction between Next’s prefetch and a Worker’s CPU budget is the kind of cross-cutting failure that does not show up in any single file.

The fix flips the whole thing around. The Worker now signs one URL per original and ships zero image bytes. The browser fetches each presigned URL straight from R2 and builds the zip client-side, streaming to disk:

// app/api/g/[code]/downloads/route.ts: sign, don't stream.
const url = await presignGet(originalKey(p.r2Key), name, 3600);
return { name, url };

And the button is now a plain <button>, never a <Link>, specifically so Next can never prefetch it. The comment in the component says it plainly: a button never prefetches, and the Worker never touches an image byte. Signing URLs is microseconds of CPU; streaming gigabytes is not.

What else went wrong

Cloudflare’s platform errors are opaque. A 1101 or 1102 comes back as a bare number with no stack, and when you are deep in an image pipeline that is a miserable way to debug. A chunk of the watermark commits exist only because I had to add my own debug headers to surface what the binding was actually doing, since the platform told me nothing. If I had built that instrumentation first instead of last, I would have saved hours.

Where it is now

Live at brittnyann.com, and Brittny is running her sessions and client deliveries on it. The running cost is close to nothing while the business is small, and the architecture has room to grow without a rebuild.

What I would do differently

Think about the framework’s behavior, not just my code. The download bug was not a logic error, it was Next’s prefetch meeting a Worker’s CPU ceiling. I now ask, for anything expensive behind a link: what happens when the framework touches this without a user? That one question would have caught it.

Instrument the unfamiliar platform first. The watermark fight was long mostly because Cloudflare Images told me nothing when it failed. The debug headers that finally cracked it should have been step one on a binding I had never used, not step ten.

Keep the “non-engineer can run it” test in front. The best decisions on this build, folder-as-gallery, auto share codes, on-the-fly resizing, all came from asking whether Brittny could operate it without me. That constraint produced a better architecture than “what is easiest to code” would have.

Frequently Asked Questions

How do you run a photo gallery site for almost no money?
Store originals in Cloudflare R2, which has no egress fees, keep the bucket private, and put a single worker in front that resizes images on the fly with Cloudflare Images and edge-caches the results. Full-resolution files never leave storage during browsing; downloads use short-lived presigned URLs. For a low-traffic, part-time business this keeps the monthly cost close to zero.
How did you and Claude split the work?
We designed the architecture together in chat, the $0-egress idea, the folder-as-gallery model, the share-code scheme, then I built it with Claude Code doing most of the typing across the Next.js app and the two Cloudflare workers. I made the calls and reviewed; Claude wrote the bulk of the code.
What was the worst bug?
The 'Download all (full-res)' button was a Next Link pointing at a Worker route that streamed and CRC32'd every full-resolution original into a zip. Next prefetches links when they scroll into view, so simply opening a private gallery silently kicked off a multi-gigabyte zip inside the Worker and tripped Cloudflare's CPU-time limit (error 1102), taking the page down with no click at all. The fix was to presign one URL per original and build the zip in the browser, so the Worker never touches an image byte.
How are private client galleries secured?
Private galleries are never indexed or linked publicly and are reached only by an unguessable share code with about 48 bits of entropy. The image worker refuses to serve a private photo unless the request carries a valid, unexpired share code that owns that gallery, and full-resolution downloads use short-lived presigned URLs straight from R2.
Photo of Caden Sorenson
Caden Sorenson

Founder of Vient and senior staff engineer

Caden Sorenson runs Vient, an independent studio building iOS apps, web tools, and client websites, including Travel Vient, a travel research site with everything cited to primary sources. He's a senior staff engineer with 15+ years of experience building iOS apps, web platforms, and developer tools, and a Computer Science graduate from Utah State University. Based in Logan, Utah.

Built as part of

View the project →