A few months ago I dug out a box of family photos from the 80s. Most were faded,
scratched, or had those crease marks where they'd been folded for decades. I tried
fixing them in Photoshop, but I had dozens of them and gave up after the second one.
So I did what any developer would do: I built a tool for it.
This post is a technical walkthrough of what I learned building PixRestorer —
an AI photo restoration web app that repairs damaged, blurry, and black-and-white
photos in under 30 seconds. Hopefully the architecture decisions, the edge cases,
and the failure-handling patterns are useful if you're building something similar.
The stack
- Next.js 16 (App Router, TypeScript)
- Supabase — Postgres + Auth
- Cloudflare R2 — image storage
- Replicate — the AI models
-
Cloudflare Workers — deployment via
@opennextjs/cloudflare - Waffo / Creem — payments
The app itself is completely stateless: every dependency (database, auth, AI,
storage, payments) lives outside the app. That's the single most important
architecture decision, and it made deployment trivial.
Why serverless, and what it cost me
Initially the app ran on a VPS with PM2. Moving to Cloudflare Workers with OpenNext
removed the server entirely — there is no Node runtime to manage, no sharp image
processing on the origin (Cloudflare Image Resizing handles it), and rollbacks are
just a DNS change.
It wasn't free, though. Three things bit me:
-
Middleware naming. In Next 16,
proxy.tsonly runs on the Node runtime, but Workers only support Edge middleware. I had to keep the oldmiddleware.tsfilename for the Supabase session-refresh logic to work on Workers. - Request body limits. A 12MB image upload has to make it through the middleware untouched — worth testing early.
- Rate limiting. In-memory rate limiting is unreliable on Workers. I dropped it entirely: every image already costs credits via an atomic Postgres RPC, so the credit balance is the rate limit.
The restore pipeline
Here's what happens when a user uploads a photo. It looks simple, but each step
exists to solve a real problem I hit along the way:
1. Input validation, including SSRF protection
The API accepts either a data URL (base64 from the browser) or an HTTPS URL. The
URL case was the sneaky one: accepting arbitrary URLs from users is an SSRF hole.
The rule I enforce is that any URL must come from our own R2 bucket — a pre-
signed upload URL the client got from us moments earlier. Anything else is
rejected outright:
if (isHttpUrl) {
if (!hasConfiguredR2PublicUrl()) {
return NextResponse.json({ error: "R2 public URL is not configured." }, { status: 500 });
}
uploadedFileKey = extractKeyFromUrl(image);
if (!uploadedFileKey) {
return NextResponse.json({ error: "Invalid image source." }, { status: 400 });
}
}
2. Credits: the atomic deduction
Each user has a credit balance. Before running a model, the app deducts the cost atomically in the database — this is the part I'm most proud of. Instead of read-check-then-write (which races under concurrency), the deduction is a single SQL update that only succeeds if the balance is sufficient:
sql
UPDATE public.users
SET credits = credits - p_amount, updated_at = NOW()
WHERE id = p_user_id AND credits >= p_amount
RETURNING credits INTO v_remaining;
IF v_remaining IS NULL THEN
RETURN NULL; -- insufficient credits
END IF;
Retrying the RPC on transient errors (max 3 attempts, 100ms backoff) made the system resilient against flaky network calls, and NULL cleanly signals "insufficient credits" to the API.
3. Never charge for a failure
The model call happens after deduction. If the model errors, the app refunds the credit immediately — because the user paid for a restored image, not for an error message:
ts
} catch (modelError) {
await supabase.rpc("refund_credits", { p_user_id: user.id, p_amount: RESTORE_CREDIT_COST });
throw modelError;
}
- Clean up after yourself Uploaded originals are kept temporarily in R2, and the finally block deletes them regardless of what happened. Temp files that pile up are a slow, invisible cost leak.
Moderation before the model
AI image APIs will happily spend your money on anything. I put a moderation gate in front of the restore calls — the prompt and filename are checked before the model runs, so policy violations return a clean 4xx instead of a bill.
What this costs to run
The restore model runs on Replicate, and costs are per-call. The credit-pricing model (prepaid credits, deducted atomically, refunded on failure) keeps the economics honest: users never pay for a failed request, and I never eat the cost of a successful one.
Wrapping up
The interesting part of this project wasn't the AI — it was all the boring engineering around it: stateless serverless deployment, atomic money-like arithmetic, SSRF defense, and making sure failures never cost the user anything.
If you're curious, PixRestorer is live at pixrestorer.com.
You can upload one of your own faded photos and see the whole thing end-to-end. I'd love to hear what you think — and if you're building something similar, I'm happy to answer questions about the details I didn't cover here.
Top comments (0)