# How I Built an AI Photo Restoration App with Next.js, Supabase, and Replicate

> Source: <https://dev.to/skyler_7abb6ba5f692ca05e6/how-i-built-an-ai-photo-restoration-app-with-nextjs-supabase-and-replicate-55lk>
> Published: 2026-09-03 04:36:10+00:00

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](https://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.
