DEV Community

Jeason Li
Jeason Li

Posted on

How I Built an AI Image Editor with Next.js 16 + OpenRouter (Gemini) + Supabase Auth

I’ve been building Image Banana — a web-based AI image editor where you can upload a photo and describe edits in plain English (for example: “replace the background with a snowy mountain”, “keep the same character, change the outfit”, “make it golden hour”).

site:https://www.my-nano-banana.com/

This post walks through the architecture and a few implementation details that made the product feel “real” quickly:

  • Image-to-image editing and text-to-image generation
  • One-shot editing (one prompt -> final output)
  • Batch processing (multiple inputs) as a premium feature
  • A guest trial: 1 generation without signing up, then prompt Google sign-in
  • A simple credits + subscription model

If you want to try it live, replace the placeholders below with your domain and deploy it.


Product Decisions That Shaped the Code

Before writing code, I forced myself to answer:

1) What does the “happy path” look like in under 30 seconds?
2) What’s the minimum gating needed to prevent abuse, without killing the first-run experience?
3) Which features should be paid (and why)?

That led to:

  • Guest trial: let people generate once without login.
  • Batch mode: premium because it’s costlier and easier to abuse.
  • Clear error UX: show “why” (credits, subscription required, missing inputs) rather than “something went wrong”.

High-Level Architecture

The app is a standard Next.js App Router stack:

  • UI: components/image-editor.tsx (client component)
  • Generation API: app/api/generate/route.ts (Node runtime)
  • Auth:
    • Start OAuth: app/auth/login/route.ts
    • Callback: app/auth/callback/route.ts
  • Billing primitives: lib/billing/* (supports Supabase-backed billing, with a local JSON fallback for dev)

The core request flow:

1) Browser collects prompt + optional uploaded image(s)
2) POST to /api/generate
3) Server calls OpenRouter (Gemini image models) and extracts returned image URLs
4) UI renders returned image(s) and offers download / “edit again”


Image Generation via OpenRouter (Gemini)

At a high level, the server makes a chat/completions request with modalities ["image","text"].

Implementation notes:

  • Timeouts matter (don’t let requests hang forever).
  • Be strict with payload validation: missing prompt, missing reference image in image-to-image mode, etc.
  • When the model returns images, you typically get hosted URLs (not base64). Your UI needs to treat them as remote URLs.

Minimal (simplified) request shape:

const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
    "Content-Type": "application/json",
    "HTTP-Referer": process.env.OPENROUTER_HTTP_REFERER ?? "http://localhost:3000",
    "X-Title": process.env.OPENROUTER_X_TITLE ?? "image-banana",
  },
  body: JSON.stringify({
    model: "google/gemini-2.5-flash-image",
    modalities: ["image", "text"],
    messages: [
      {
        role: "user",
        content: [
          { type: "text", text: prompt },
          ...(imageUrl ? [{ type: "image_url", image_url: { url: imageUrl } }] : []),
        ],
      },
    ],
  }),
})
Enter fullscreen mode Exit fullscreen mode

Guest Trial: “One Free Generation” Without Login

The UX goal: let users try the product immediately.

The abuse-prevention goal: don’t give unlimited free generations.

I implemented guest trial as:

  • Client-side: a localStorage flag for fast UI gating (image-banana-guest-trial-used)
  • Server-side: an HTTP cookie (ib_guest_trial_generate_v1) so the API is the source of truth

This way, even if someone clears localStorage, the API still enforces the “one free generation” rule.

On the server (/api/generate), the logic looks like:

  • If not logged in:
    • if batch image-to-image -> 401 (auth required)
    • if trial cookie already set -> 401 (trial exhausted)
    • else allow exactly one request, then set the trial cookie

And the client reacts to 401 by opening a sign-in dialog or redirecting to /auth/login.


Batch Processing as a Premium Feature

Batch mode is great UX, but it’s also a cost multiplier.

I made it premium by checking entitlements:

  • Batch requires an active subscription
  • Single requests consume credits
  • In a batch, if some items fail, refund the credits for failed items (so you only pay for successes)

This “refund failed items” detail is small, but it prevents a lot of frustration.


Google Sign-In with Supabase Auth (Avoiding Open Redirects)

OAuth flows love to break in subtle ways. One place I’m careful: the next parameter.

If you accept an arbitrary next URL, attackers can create “open redirect” links.

So the auth routes sanitize next:

  • must start with /
  • must not start with //
  • if invalid -> fall back to /

Then the login route starts the Supabase Google flow and redirects back to:

/auth/callback?next=<safe_next>


Billing: Credits + Subscriptions (with a Local Dev Fallback)

I wanted billing logic I could run locally without setting up every external dependency immediately.

So the project supports:

  • Supabase-backed billing when SUPABASE_SERVICE_ROLE_KEY is present
  • Local billing otherwise (.local/billing.json)

That gives you a nice dev experience:

  • run the UI
  • test credits/subscriptions
  • only wire webhooks/payments when you’re ready

SEO Cold Start Checklist (So You Don’t Launch Invisible)

If you’re shipping a product site, SEO isn’t “later” — it’s launch-critical. I added:

  • robots.txt with sensible disallows and a sitemap pointer
  • a single sitemap source (/sitemap.xml)
  • page-specific title/description on key pages
  • hreflang alternates for locales
  • default noindex for mirrored locales (until translations are real)

Even a basic setup helps you avoid the most common cold-start traps.


What I’d Improve Next

  • Real translations for /zh and /ko (or remove them until ready)
  • Better prompt presets per use case (product shots, portraits, UGC, etc.)
  • A job queue for long-running generations + webhooks
  • Real background removal (current clone has a mocked tool page)

If You Want to Build Your Own Version

If you want to replicate the core stack, you’ll need:

  • Node.js >= 20.9
  • OpenRouter API key
  • Supabase project + Google OAuth enabled
  • A payment provider (this project uses Creem)

And then start from:

  • /generator (the main product surface)
  • /api/generate (the core API)

If you’re building something similar, I’d love to hear:

  • How you gate free usage without ruining first-run UX
  • Whether you prefer “credits” vs “subscription-only” for image tools
  • What you consider “must-have” editing features beyond background replacement

Top comments (0)