March 6, 2026 · 15 min read · Architecture Guide

Building an AI Image Generator SaaS — Complete Stack Guide

AI image generators are one of the highest-converting SaaS categories right now. Tools like Midjourney, Leonardo.ai, and NightCafe have proven the market — but there's room for vertical, niche, and white-label solutions. This guide walks you through building your own, from architecture to deployment.

We'll use Next.js for the frontend, PixelAPI as the AI backend, and standard tools for auth, payments, and storage. No GPUs required.

Architecture Overview

┌─────────────────────────────────────────────────────┐ │ Your Users │ └──────────────────────┬──────────────────────────────┘ │ ┌────────────▼────────────┐ │ Next.js Frontend │ │ (Vercel / Cloudflare) │ │ │ │ • React UI │ │ • Prompt builder │ │ • Image gallery │ │ • User dashboard │ └────────────┬────────────┘ │ API Routes ┌────────────▼────────────┐ │ Next.js API Layer │ │ │ │ • Auth middleware │ │ • Credit deduction │ │ • Rate limiting │ │ • Request validation │ └─────┬──────────┬────────┘ │ │ ┌──────────▼──┐ ┌───▼──────────────┐ │ PixelAPI │ │ Your Database │ │ (AI) │ │ (Postgres) │ │ │ │ │ │ • Generate │ │ • Users │ │ • Upscale │ │ • Credits │ │ • Remove BG │ │ • Generations │ │ • Restore │ │ • Transactions │ └─────────────┘ └──────────────────┘ │ ┌─────────▼─────────┐ │ Object Storage │ │ (S3 / R2) │ │ │ │ • Generated imgs │ │ • User uploads │ └───────────────────┘
Why this works: Your server never touches a GPU. PixelAPI handles all AI inference. You focus on the product — UX, billing, and features your niche cares about.

The Tech Stack

🖥️ Frontend

Next.js 14+ (App Router), Tailwind CSS, React Query for data fetching

⚡ AI Backend

PixelAPI — generation, upscaling, background removal, face restoration

🔐 Auth

NextAuth.js (or Clerk) — Google, GitHub, email/password

💳 Payments

Stripe — subscriptions + one-time credit packs

🗄️ Database

PostgreSQL via Prisma ORM — users, credits, generation history

📦 Storage

Cloudflare R2 or AWS S3 — generated images, user uploads

Step 1: Project Setup

Terminal
npx create-next-app@latest ai-image-saas --typescript --tailwind --app
cd ai-image-saas
npm install pixelapi @prisma/client next-auth stripe
npm install -D prisma

Database Schema

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id            String       @id @default(cuid())
  email         String       @unique
  name          String?
  image         String?
  credits       Int          @default(50)   // free starter credits
  plan          String       @default("free")
  generations   Generation[]
  transactions  Transaction[]
  createdAt     DateTime     @default(now())
}

model Generation {
  id          String   @id @default(cuid())
  userId      String
  user        User     @relation(fields: [userId], references: [id])
  prompt      String
  model       String
  width       Int
  height      Int
  imageUrl    String
  thumbnailUrl String?
  creditCost  Int
  status      String   @default("completed")
  metadata    Json?
  createdAt   DateTime @default(now())

  @@index([userId, createdAt])
}

model Transaction {
  id        String   @id @default(cuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id])
  type      String   // "purchase", "usage", "bonus"
  amount    Int      // positive = credit, negative = debit
  details   String?
  createdAt DateTime @default(now())

  @@index([userId])
}

Step 2: The Generation API Route

This is the core of your app — the API route that takes a user's prompt, checks credits, calls PixelAPI, saves the result, and returns the image.

app/api/generate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { prisma } from '@/lib/prisma';
import PixelAPI from 'pixelapi';
import { uploadToR2 } from '@/lib/storage';

const pixel = new PixelAPI({ apiKey: process.env.PIXELAPI_KEY! });

// Credit costs per model
const CREDIT_COSTS: Record<string, number> = {
  'flux-schnell': 1,
  'sdxl': 2,
  'flux-schnell-hd': 3,  // with upscaling
};

export async function POST(req: NextRequest) {
  // 1. Authenticate
  const session = await getServerSession();
  if (!session?.user?.email) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // 2. Parse & validate request
  const { prompt, model = 'flux-schnell', width = 1024, height = 1024 } = await req.json();

  if (!prompt || prompt.length > 1000) {
    return NextResponse.json({ error: 'Invalid prompt' }, { status: 400 });
  }

  const creditCost = CREDIT_COSTS[model] || 1;

  // 3. Check & deduct credits (atomic)
  const user = await prisma.user.update({
    where: {
      email: session.user.email,
      credits: { gte: creditCost },  // only if enough credits
    },
    data: {
      credits: { decrement: creditCost },
    },
  }).catch(() => null);

  if (!user) {
    return NextResponse.json({ error: 'Insufficient credits' }, { status: 402 });
  }

  try {
    // 4. Generate image via PixelAPI
    const result = await pixel.image.generate({
      prompt,
      model,
      width,
      height,
      steps: model === 'flux-schnell' ? 4 : 30,
    });

    // 5. Upload to your storage
    const imageUrl = await uploadToR2(
      result.imageBytes,
      `generations/${user.id}/${Date.now()}.png`
    );

    // 6. Save generation record
    const generation = await prisma.generation.create({
      data: {
        userId: user.id,
        prompt,
        model,
        width,
        height,
        imageUrl,
        creditCost,
      },
    });

    // 7. Log transaction
    await prisma.transaction.create({
      data: {
        userId: user.id,
        type: 'usage',
        amount: -creditCost,
        details: `Generated image: ${model}`,
      },
    });

    return NextResponse.json({
      id: generation.id,
      imageUrl,
      creditsRemaining: user.credits - creditCost,
    });
  } catch (error) {
    // Refund credits on failure
    await prisma.user.update({
      where: { id: user.id },
      data: { credits: { increment: creditCost } },
    });
    console.error('Generation failed:', error);
    return NextResponse.json({ error: 'Generation failed' }, { status: 500 });
  }
}
Key detail: Credits are deducted before generation and refunded on failure. This prevents race conditions where a user spends credits they don't have by spamming the generate button.

Step 3: The Frontend

Image Generator Component

components/ImageGenerator.tsx
'use client';

import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';

export function ImageGenerator() {
  const [prompt, setPrompt] = useState('');
  const [model, setModel] = useState('flux-schnell');
  const queryClient = useQueryClient();

  const generate = useMutation({
    mutationFn: async () => {
      const res = await fetch('/api/generate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prompt, model, width: 1024, height: 1024 }),
      });
      if (!res.ok) {
        const err = await res.json();
        throw new Error(err.error);
      }
      return res.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['gallery'] });
      queryClient.invalidateQueries({ queryKey: ['credits'] });
    },
  });

  return (
    <div className="max-w-2xl mx-auto p-6">
      <textarea
        value={prompt}
        onChange={(e) => setPrompt(e.target.value)}
        placeholder="Describe the image you want to create..."
        className="w-full h-32 p-4 bg-gray-900 border border-gray-700
                   rounded-lg text-white resize-none focus:ring-2
                   focus:ring-purple-500 focus:border-transparent"
        maxLength={1000}
      />

      <div className="flex gap-3 mt-4">
        <select
          value={model}
          onChange={(e) => setModel(e.target.value)}
          className="px-4 py-2 bg-gray-900 border border-gray-700
                     rounded-lg text-white"
        >
          <option value="flux-schnell">FLUX Schnell (1 credit)</option>
          <option value="sdxl">SDXL (2 credits)</option>
        </select>

        <button
          onClick={() => generate.mutate()}
          disabled={!prompt.trim() || generate.isPending}
          className="px-6 py-2 bg-purple-600 hover:bg-purple-700
                     rounded-lg text-white font-semibold
                     disabled:opacity-50 disabled:cursor-not-allowed
                     transition-colors"
        >
          {generate.isPending ? 'Generating...' : 'Generate'}
        </button>
      </div>

      {generate.isError && (
        <p className="mt-3 text-red-400">{generate.error.message}</p>
      )}

      {generate.data && (
        <div className="mt-6">
          <img
            src={generate.data.imageUrl}
            alt={prompt}
            className="rounded-lg w-full"
          />
          <p className="mt-2 text-sm text-gray-400">
            Credits remaining: {generate.data.creditsRemaining}
          </p>
        </div>
      )}
    </div>
  );
}

Gallery Component

components/Gallery.tsx
'use client';

import { useQuery } from '@tanstack/react-query';

export function Gallery() {
  const { data: generations, isLoading } = useQuery({
    queryKey: ['gallery'],
    queryFn: async () => {
      const res = await fetch('/api/generations');
      return res.json();
    },
  });

  if (isLoading) return <div className="text-gray-400">Loading gallery...</div>;

  return (
    <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 p-6">
      {generations?.map((gen: any) => (
        <div key={gen.id} className="group relative">
          <img
            src={gen.imageUrl}
            alt={gen.prompt}
            className="rounded-lg w-full aspect-square object-cover
                       group-hover:ring-2 ring-purple-500 transition-all"
          />
          <div className="absolute inset-0 bg-black/60 opacity-0
                          group-hover:opacity-100 transition-opacity
                          rounded-lg p-3 flex flex-col justify-end">
            <p className="text-sm text-white line-clamp-3">{gen.prompt}</p>
            <p className="text-xs text-gray-400 mt-1">{gen.model}</p>
          </div>
        </div>
      ))}
    </div>
  );
}

Step 4: Credit System & Payments

The credit model is the most common monetization approach for AI image tools. Here's a clean implementation:

Plan Price Credits / Month Cost per Image
Free$050$0.00
Starter$9/mo500$0.018
Pro$29/mo2,000$0.0145
Business$79/mo8,000$0.0099
Margin analysis: At the Pro tier, you charge $0.0145/image. PixelAPI's FLUX Schnell costs $0.002/image. That's a ~86% gross margin — even after storage, bandwidth, and Stripe fees, you're looking at 70%+ net margins per generation.
app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/prisma';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

const PLAN_CREDITS: Record<string, number> = {
  'price_starter': 500,
  'price_pro': 2000,
  'price_business': 8000,
};

export async function POST(req: NextRequest) {
  const body = await req.text();
  const sig = req.headers.get('stripe-signature')!;
  const event = stripe.webhooks.constructEvent(body, sig, webhookSecret);

  if (event.type === 'invoice.payment_succeeded') {
    const invoice = event.data.object as Stripe.Invoice;
    const priceId = invoice.lines.data[0]?.price?.id;
    const customerId = invoice.customer as string;

    const credits = PLAN_CREDITS[priceId!] || 0;
    if (!credits) return NextResponse.json({ received: true });

    // Find user by Stripe customer ID and add credits
    const user = await prisma.user.findFirst({
      where: { stripeCustomerId: customerId },
    });

    if (user) {
      await prisma.$transaction([
        prisma.user.update({
          where: { id: user.id },
          data: { credits: { increment: credits } },
        }),
        prisma.transaction.create({
          data: {
            userId: user.id,
            type: 'purchase',
            amount: credits,
            details: `Subscription renewal: ${credits} credits`,
          },
        }),
      ]);
    }
  }

  return NextResponse.json({ received: true });
}

Step 5: Why API vs Self-Hosting GPUs

The build-vs-buy decision for AI infrastructure is more clear-cut than most:

Factor Self-Hosted GPUs PixelAPI
Upfront cost $5,000–$30,000+ per GPU $0
Monthly infra $500–$3,000+ (cloud GPU) Pay per image ($0.002+)
Time to launch Weeks–months Hours
Scaling Manual provisioning Automatic
Model updates You download, test, deploy Available instantly
Cold starts 30s–2min on serverless GPU None
Maintenance CUDA, drivers, OOM errors None
Break-even ~50,000+ images/month Always cost-effective below that
The hidden cost of self-hosting: It's not just the GPU. It's the engineer spending 20% of their time on CUDA driver updates, OOM debugging, model weight management, and queue infrastructure. At startup scale, that engineer's time is worth more building product features.

Self-hosting only makes financial sense when you're generating 50,000+ images per month consistently. Even then, you're trading capital cost for engineering complexity. Most successful AI SaaS companies start with APIs and only bring inference in-house after proving product-market fit.

Step 6: Deployment

The full deployment is straightforward:

  1. Frontend + API: Deploy to Vercel with vercel deploy. The API routes run as serverless functions.
  2. Database: Neon (serverless Postgres) or Supabase. Both have generous free tiers.
  3. Storage: Cloudflare R2 — S3-compatible, no egress fees. Perfect for serving images.
  4. Domain: Point your domain, set up SSL (automatic on Vercel).
  5. Monitoring: Sentry for errors, Vercel Analytics for performance.
Environment Variables
# .env.local
PIXELAPI_KEY=px_live_...
DATABASE_URL=postgresql://...
NEXTAUTH_SECRET=...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=your-bucket
R2_ENDPOINT=https://xxx.r2.cloudflarestorage.com

Launch Checklist

Further Reading

Start Building Your AI SaaS Today

Free API access, excellent margins, and zero GPU headaches. Ship your product this weekend.

Get Your API Key →