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.
@opennextjs/cloudflare
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.
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:
proxy.ts
only runs on the Node runtime,
but Workers only support Edge middleware. I had to keep the old middleware.ts
filename for the Supabase session-refresh logic to work on Workers.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:
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;
}
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.