← All blog posts
AI

How to Build an AI SaaS Product with Next.js, Stripe, and OpenAI

Building an AI feature is the easy part — this is the full build most tutorials skip: Clerk auth, Stripe subscription billing, OpenAI usage tracking, rate limiting, and an architecture that survives real traffic, all in Next.js.

Building an AI feature is the easy part.

Building an AI product people pay for — with auth, billing, usage limits, abuse prevention, and an architecture that does not collapse under real traffic — is the part almost every tutorial skips.

This article is everything they skip.

By the end you will have a complete AI SaaS architecture: Next.js App Router, Clerk for auth, Stripe for subscription billing, OpenAI for the AI layer, Prisma for your database, and Vercel for deployment. Every piece connects, and every decision is explained.

If you have followed this series — the LangChain agent, the RAG pipeline, the function calling guide, the streaming frontend, and the Python backend — this is where all of it becomes a product.

The AI SaaS Stack in 2026

Before writing a line of code, the architecture decision matters more than anything else.

The complete AI SaaS stack — each layer has one job, and every request flows top to bottom through all five.

Here is why each piece earns its place:

LayerChoiceWhy
FrontendNext.js App RouterServer components, streaming, Vercel-native
AuthClerkBest DX, webhooks for user sync, generous free tier
BillingStripeIndustry standard, most reliable webhooks
AIOpenAI GPT-4oBest tool calling and streaming support
DatabasePostgres + PrismaType-safe queries, migrations, scales cleanly
CacheUpstash RedisServerless Redis, free tier, rate limiting built in
EmailResendDeveloper-friendly, Next.js native
DeployVercelNext.js native, edge functions, zero config

Project Setup

npx create-next-app@latest ai-saas --typescript --tailwind --app
cd ai-saas

pnpm add @clerk/nextjs stripe @prisma/client prisma
pnpm add ai @ai-sdk/openai zod
pnpm add @upstash/redis @upstash/ratelimit
pnpm add resend
pnpm add -D prisma

Initialize Prisma:

npx prisma init

Set up your .env.local:

# Auth
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...
CLERK_SECRET_KEY=sk_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard

# Database
DATABASE_URL=postgresql://...

# Stripe
STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_...

# OpenAI
OPENAI_API_KEY=sk-...

# Upstash Redis
UPSTASH_REDIS_REST_URL=https://...
UPSTASH_REDIS_REST_TOKEN=...

# Email
RESEND_API_KEY=re_...

Step 1 — Database Schema with Prisma

Your database schema is the backbone of the entire SaaS. Get it right early — migrations are painful once real users exist.

Create prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
}

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

model User {
  id             String   @id @default(cuid())
  clerkId        String   @unique  // Maps to Clerk's user ID
  email          String   @unique
  name           String?
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  // Billing
  stripeCustomerId     String?  @unique
  stripeSubscriptionId String?  @unique
  stripePriceId        String?
  stripeCurrentPeriodEnd DateTime?

  // Usage tracking
  usageCredits   Int      @default(0)   // Credits remaining this period
  usageTotal     Int      @default(0)   // All-time usage count

  // Relations
  conversations  Conversation[]
  usageLogs      UsageLog[]
}

model Conversation {
  id        String    @id @default(cuid())
  userId    String
  title     String?
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt

  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  messages  Message[]
}

model Message {
  id             String       @id @default(cuid())
  conversationId String
  role           String       // "user" | "assistant" | "tool"
  content        String       @db.Text
  tokensUsed     Int          @default(0)
  createdAt      DateTime     @default(now())

  conversation   Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
}

model UsageLog {
  id          String   @id @default(cuid())
  userId      String
  action      String   // "chat", "rag_query", "agent_run"
  tokensUsed  Int
  model       String
  cost        Float    // In USD cents
  createdAt   DateTime @default(now())

  user        User     @relation(fields: [userId], references: [id])
}

Run the migration:

npx prisma migrate dev --name init
npx prisma generate

Step 2 — Auth with Clerk

Clerk is the fastest path to production auth in a Next.js SaaS — social login, magic links, and MFA all configured in a dashboard, not in your codebase.

Wrap your app in app/layout.tsx:

import { ClerkProvider } from "@clerk/nextjs";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>{children}</body>
      </html>
    </ClerkProvider>
  );
}

Create middleware.ts at the project root:

import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

// Define which routes require authentication
const isProtectedRoute = createRouteMatcher([
  "/dashboard(.*)",
  "/api/chat(.*)",
  "/api/agent(.*)",
]);

export default clerkMiddleware((auth, req) => {
  if (isProtectedRoute(req)) auth().protect();
});

export const config = {
  matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
};

Syncing Clerk Users to Your Database

When a user signs up via Clerk, you need a matching record in your Postgres database. Use Clerk webhooks for this.

Create app/api/webhooks/clerk/route.ts:

import { Webhook } from "svix";
import { headers } from "next/headers";
import { WebhookEvent } from "@clerk/nextjs/server";
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
  if (!WEBHOOK_SECRET) throw new Error("Missing CLERK_WEBHOOK_SECRET");

  // Verify the webhook signature
  const headerPayload = headers();
  const svix_id = headerPayload.get("svix-id");
  const svix_timestamp = headerPayload.get("svix-timestamp");
  const svix_signature = headerPayload.get("svix-signature");

  const body = await req.text();
  const wh = new Webhook(WEBHOOK_SECRET);

  let event: WebhookEvent;
  try {
    event = wh.verify(body, {
      "svix-id": svix_id!,
      "svix-timestamp": svix_timestamp!,
      "svix-signature": svix_signature!,
    }) as WebhookEvent;
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  // Handle user creation
  if (event.type === "user.created") {
    const { id, email_addresses, first_name, last_name } = event.data;

    await prisma.user.create({
      data: {
        clerkId: id,
        email: email_addresses[0].email_address,
        name: `${first_name ?? ""} ${last_name ?? ""}`.trim(),
        usageCredits: 10, // Free tier: 10 credits on signup
      },
    });
  }

  // Handle user deletion
  if (event.type === "user.deleted") {
    await prisma.user.delete({
      where: { clerkId: event.data.id! },
    });
  }

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

Step 3 — Stripe Subscription Billing

Every billing event flows through a Stripe webhook, never the client directly — the database only changes once Stripe confirms the charge.

Define Your Pricing Plans

Create lib/stripe/plans.ts:

export const PLANS = {
  FREE: {
    name: "Free",
    credits: 10,
    price: 0,
    priceId: null,
    features: [
      "10 AI credits/month",
      "Basic chat",
      "Community support",
    ],
  },
  PRO: {
    name: "Pro",
    credits: 500,
    price: 19,
    priceId: process.env.STRIPE_PRO_PRICE_ID!, // Create in Stripe dashboard
    features: [
      "500 AI credits/month",
      "RAG document upload",
      "AI Agent access",
      "Priority support",
    ],
  },
  ENTERPRISE: {
    name: "Enterprise",
    credits: 5000,
    price: 99,
    priceId: process.env.STRIPE_ENTERPRISE_PRICE_ID!,
    features: [
      "5,000 AI credits/month",
      "Custom AI agents",
      "API access",
      "Dedicated support",
    ],
  },
} as const;

export type PlanName = keyof typeof PLANS;

Stripe Client Setup

Create lib/stripe/client.ts:

import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
  typescript: true,
});

// Create a Stripe customer for a new user
export async function createStripeCustomer(email: string, name: string) {
  return stripe.customers.create({ email, name });
}

// Create a checkout session for subscription upgrade
export async function createCheckoutSession({
  userId,
  priceId,
  customerId,
  successUrl,
  cancelUrl,
}: {
  userId: string;
  priceId: string;
  customerId: string;
  successUrl: string;
  cancelUrl: string;
}) {
  return stripe.checkout.sessions.create({
    customer: customerId,
    mode: "subscription",
    payment_method_types: ["card"],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: successUrl,
    cancel_url: cancelUrl,
    metadata: { userId }, // Critical — links the session back to your user
    subscription_data: {
      metadata: { userId },
    },
  });
}

Checkout API Route

Create app/api/stripe/checkout/route.ts:

import { auth, currentUser } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import {
  stripe,
  createStripeCustomer,
  createCheckoutSession,
} from "@/lib/stripe/client";
import { PLANS } from "@/lib/stripe/plans";

export async function POST(req: NextRequest) {
  const { userId } = auth();
  if (!userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { planName } = await req.json();
  const plan = PLANS[planName as keyof typeof PLANS];

  if (!plan || !plan.priceId) {
    return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
  }

  // Get user from database
  let user = await prisma.user.findUnique({ where: { clerkId: userId } });
  if (!user) {
    return NextResponse.json({ error: "User not found" }, { status: 404 });
  }

  // Create Stripe customer if they don't have one
  if (!user.stripeCustomerId) {
    const clerkUser = await currentUser();
    const customer = await createStripeCustomer(
      user.email,
      clerkUser?.fullName ?? user.email
    );

    user = await prisma.user.update({
      where: { id: user.id },
      data: { stripeCustomerId: customer.id },
    });
  }

  // Create checkout session
  const session = await createCheckoutSession({
    userId: user.id,
    priceId: plan.priceId,
    customerId: user.stripeCustomerId!,
    successUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
    cancelUrl: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
  });

  return NextResponse.json({ url: session.url });
}

Stripe Webhook Handler

Create app/api/webhooks/stripe/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe/client";
import { prisma } from "@/lib/prisma";
import { PLANS } from "@/lib/stripe/plans";
import Stripe from "stripe";

export async function POST(req: NextRequest) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  switch (event.type) {
    // Subscription created or renewed
    case "invoice.payment_succeeded": {
      const invoice = event.data.object as Stripe.Invoice;
      const subscriptionId = invoice.subscription as string;
      const subscription = await stripe.subscriptions.retrieve(subscriptionId);
      const userId = subscription.metadata.userId;

      // Find the plan by price ID
      const priceId = subscription.items.data[0].price.id;
      const plan = Object.values(PLANS).find((p) => p.priceId === priceId);

      await prisma.user.update({
        where: { id: userId },
        data: {
          stripeSubscriptionId: subscriptionId,
          stripePriceId: priceId,
          stripeCurrentPeriodEnd: new Date(
            subscription.current_period_end * 1000
          ),
          // Reset credits on renewal
          usageCredits: plan?.credits ?? 0,
        },
      });
      break;
    }

    // Subscription cancelled
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      const userId = subscription.metadata.userId;

      await prisma.user.update({
        where: { id: userId },
        data: {
          stripeSubscriptionId: null,
          stripePriceId: null,
          stripeCurrentPeriodEnd: null,
          usageCredits: PLANS.FREE.credits, // Downgrade to free
        },
      });
      break;
    }
  }

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

// Required: disable body parsing so we get the raw body for signature verification
export const config = { api: { bodyParser: false } };

Step 4 — Usage Tracking with Token Counting

This is the part every AI SaaS tutorial skips — and the part that will bankrupt you if you get it wrong.

Create lib/usage.ts:

import { prisma } from "@/lib/prisma";

// Cost per 1000 tokens in USD cents (GPT-4o pricing)
const TOKEN_COSTS = {
  "gpt-4o": { input: 0.5, output: 1.5 },       // $0.005/$0.015 per 1K tokens
  "gpt-4o-mini": { input: 0.015, output: 0.06 }, // Much cheaper for simple tasks
} as const;

export async function checkAndDeductCredits(
  userId: string,
  creditsNeeded: number = 1
): Promise<{ allowed: boolean; remaining: number }> {
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: { usageCredits: true },
  });

  if (!user || user.usageCredits < creditsNeeded) {
    return { allowed: false, remaining: user?.usageCredits ?? 0 };
  }

  // Deduct credits atomically
  await prisma.user.update({
    where: { id: userId },
    data: {
      usageCredits: { decrement: creditsNeeded },
      usageTotal: { increment: creditsNeeded },
    },
  });

  return {
    allowed: true,
    remaining: user.usageCredits - creditsNeeded,
  };
}

export async function logUsage({
  userId,
  action,
  tokensUsed,
  model,
}: {
  userId: string;
  action: string;
  tokensUsed: number;
  model: keyof typeof TOKEN_COSTS;
}) {
  const costs = TOKEN_COSTS[model];
  // Simple cost estimate (production: split input/output tokens)
  const costCents = (tokensUsed / 1000) * costs.output;

  await prisma.usageLog.create({
    data: {
      userId,
      action,
      tokensUsed,
      model,
      cost: costCents,
    },
  });
}

Step 5 — The AI Chat Endpoint with Usage Control

This is where everything connects — auth, credits, rate limiting, AI, and usage logging, all in one route handler.

Every request passes through three gates before touching OpenAI — auth, rate limit, credits. Any one fails and the client gets a clear error instead of a wasted OpenAI call.

Create app/api/chat/route.ts:

import { auth } from "@clerk/nextjs/server";
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { NextRequest, NextResponse } from "next/server";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { prisma } from "@/lib/prisma";
import { checkAndDeductCredits, logUsage } from "@/lib/usage";

export const maxDuration = 30;

// Rate limiter — 20 requests per minute per user
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(20, "1 m"),
});

export async function POST(req: NextRequest) {
  // ── Gate 1: Authentication ──────────────────────────
  const { userId: clerkId } = auth();
  if (!clerkId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // ── Gate 2: Rate limiting ───────────────────────────
  const { success: rateLimitOk } = await ratelimit.limit(clerkId);
  if (!rateLimitOk) {
    return NextResponse.json(
      { error: "Too many requests. Please slow down." },
      { status: 429 }
    );
  }

  // ── Gate 3: Credit check ────────────────────────────
  const user = await prisma.user.findUnique({
    where: { clerkId },
    select: { id: true, usageCredits: true },
  });

  if (!user) {
    return NextResponse.json({ error: "User not found" }, { status: 404 });
  }

  const { allowed, remaining } = await checkAndDeductCredits(user.id, 1);
  if (!allowed) {
    return NextResponse.json(
      {
        error: "No credits remaining. Upgrade your plan to continue.",
        creditsRemaining: remaining,
        upgradeUrl: "/pricing",
      },
      { status: 402 } // 402 Payment Required
    );
  }

  // ── AI: Stream the response ─────────────────────────
  const { messages } = await req.json();

  const result = streamText({
    model: openai("gpt-4o"),
    system: `You are a helpful AI assistant. Be concise and accurate.`,
    messages,
    onFinish: async ({ usage }) => {
      // Log usage after stream completes
      await logUsage({
        userId: user.id,
        action: "chat",
        tokensUsed: usage.totalTokens,
        model: "gpt-4o",
      });
    },
  });

  // Add remaining credits to response headers
  // Frontend can read this to update the UI without an extra API call
  const response = result.toDataStreamResponse();
  response.headers.set("X-Credits-Remaining", String(remaining - 1));

  return response;
}

Step 6 — The Pricing Page

// app/pricing/page.tsx
"use client";

import { PLANS } from "@/lib/stripe/plans";
import { useRouter } from "next/navigation";
import { useState } from "react";

export default function PricingPage() {
  const router = useRouter();
  const [loading, setLoading] = useState<string | null>(null);

  async function handleUpgrade(planName: string) {
    setLoading(planName);
    try {
      const res = await fetch("/api/stripe/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ planName }),
      });

      const { url, error } = await res.json();
      if (error) throw new Error(error);
      if (url) router.push(url); // Redirect to Stripe checkout
    } catch (err) {
      console.error(err);
    } finally {
      setLoading(null);
    }
  }

  return (
    <div className="max-w-5xl mx-auto py-20 px-4">
      <h1 className="text-4xl font-bold text-center mb-4">
        Simple, transparent pricing
      </h1>
      <p className="text-center text-gray-500 mb-12">
        Start free. Upgrade when you need more.
      </p>

      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {Object.entries(PLANS).map(([key, plan]) => (
          <div
            key={key}
            className={`border rounded-2xl p-6 ${
              key === "PRO"
                ? "border-blue-500 ring-2 ring-blue-500"
                : "border-gray-200"
            }`}
          >
            {key === "PRO" && (
              <div className="text-xs font-semibold text-blue-500 mb-2 uppercase tracking-wide">
                Most Popular
              </div>
            )}

            <h2 className="text-xl font-bold mb-1">{plan.name}</h2>
            <div className="text-3xl font-bold mb-4">
              ${plan.price}
              <span className="text-base font-normal text-gray-500">/mo</span>
            </div>

            <ul className="space-y-2 mb-6">
              {plan.features.map((feature) => (
                <li key={feature} className="flex items-center gap-2 text-sm">
                  <span className="text-green-500">✓</span> {feature}
                </li>
              ))}
            </ul>

            <button
              onClick={() => plan.priceId && handleUpgrade(key)}
              disabled={!plan.priceId || loading === key}
              className={`w-full py-2 rounded-xl font-medium transition ${
                key === "PRO"
                  ? "bg-blue-600 text-white hover:bg-blue-700"
                  : "bg-gray-100 text-gray-800 hover:bg-gray-200"
              } disabled:opacity-50`}
            >
              {loading === key
                ? "Loading..."
                : plan.price === 0
                ? "Current Plan"
                : "Upgrade"}
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}

Step 7 — Preventing OpenAI API Abuse

Three layers of protection every AI SaaS needs before going live:

// lib/abuse-prevention.ts

// 1. Content moderation — check input before sending to OpenAI
export async function moderateContent(text: string): Promise<boolean> {
  const openaiClient = new OpenAI();
  const moderation = await openaiClient.moderations.create({ input: text });
  return moderation.results[0].flagged;
}

// 2. Input length limits — prevent context stuffing attacks
export function validateMessageLength(
  messages: Array<{ content: string }>
): boolean {
  const totalChars = messages.reduce((sum, m) => sum + m.content.length, 0);
  return totalChars <= 32000; // ~8K tokens — adjust per your plan
}

// 3. Prompt injection detection — basic pattern matching
export function detectPromptInjection(text: string): boolean {
  const injectionPatterns = [
    /ignore (all |previous |above )?instructions/i,
    /system prompt/i,
    /you are now/i,
    /disregard your/i,
  ];
  return injectionPatterns.some((pattern) => pattern.test(text));
}

Add these checks to your chat route before calling OpenAI:

// Inside your POST handler, after Gate 3:
const { messages } = await req.json();

// Validate input
if (!validateMessageLength(messages)) {
  return NextResponse.json({ error: "Message too long" }, { status: 400 });
}

const latestMessage = messages[messages.length - 1]?.content ?? "";

if (detectPromptInjection(latestMessage)) {
  return NextResponse.json(
    { error: "Invalid input detected" },
    { status: 400 }
  );
}

const isFlagged = await moderateContent(latestMessage);
if (isFlagged) {
  return NextResponse.json(
    { error: "Content policy violation" },
    { status: 400 }
  );
}

Real Cost Breakdown at Different Scales

Before you price your product, know your actual costs:

UsersRequests/dayTokens/reqGPT-4o costInfra costTotal/month
1003001,000~$4.50~$20~$25
1,0003,0001,000~$45~$50~$95
10,00030,0001,000~$450~$200~$650

The profitable pricing formula: calculate your cost per credit (tokens per request ÷ 1000 × GPT-4o price), add a 5× markup on the Pro plan to cover infra, support, and margin, and treat the free tier as nothing more than customer acquisition cost.

At $19/month Pro with 500 credits, your break-even is around $3.80/user. Everything above that is margin.

Deployment Checklist

Before going live, run through this:

Auth
☐ Clerk webhook configured and verified
☐ Protected routes tested (middleware working)
☐ Sign-in/sign-up pages styled

Billing
☐ Stripe webhook endpoint configured in dashboard
☐ Webhook signature verification enabled
☐ Test mode payments working end to end
☐ Subscription cancellation tested

AI
☐ Rate limiting enabled (Upstash configured)
☐ Credit deduction tested
☐ Content moderation enabled
☐ maxDuration set on all AI route handlers
☐ OpenAI billing alerts set up

Database
☐ Production database provisioned (Neon recommended)
☐ Migrations run on production
☐ Connection pooling enabled (PgBouncer)

Monitoring
☐ Error tracking (Sentry)
☐ Usage dashboard for yourself
☐ OpenAI spend alerts

What to Build Next

You now have a complete, billable AI SaaS — auth, billing, AI, usage tracking, and abuse prevention all connected. The natural next steps to turn this into a real product:

  • Add RAG document upload — let Pro users upload their own documents and chat with them, using the Pinecone pipeline from the RAG article.
  • Add AI agent access — give Enterprise users the full agent with tools, built in the LangChain agent article.
  • Add a usage dashboard — show users how many credits they have used and what is left.
  • Email on low credits — use Resend to notify users at 20% credits remaining so they upgrade before hitting the limit.

The full source code is on GitHub.

Frequently asked questions

How much does it cost to build an AI SaaS MVP?

Infrastructure costs for an MVP are near zero — Vercel free tier, Neon free Postgres, Clerk free tier (up to 10,000 MAU), Stripe (no monthly fee, 2.9% per transaction), Upstash free tier. Your real cost is OpenAI API usage, which scales with actual users. Expect $0–$30/month until you hit meaningful traffic.

How do I implement usage limits with Stripe metered billing?

The approach in this guide uses a credit system — simpler and more predictable than Stripe metered billing. For true pay-as-you-go, use Stripe's metered billing with usage records, but credits are far easier to reason about for both you and your users.

Should I use Clerk or NextAuth for AI SaaS?

Clerk for new projects in 2026 — the DX is significantly better, webhooks are more reliable, and the free tier is generous. NextAuth (now Auth.js) is the right choice when you need full control over auth flows or have a very specific database requirement Clerk cannot meet.

How do I prevent OpenAI API abuse?

Three layers: rate limiting per user (Upstash), a credit system so each user has a hard cap, and content moderation on input. The most important is the credit system — even if rate limiting fails, a user can only burn their own credits, not your entire OpenAI budget.

What is the best database for an AI SaaS?

Neon (serverless Postgres) for the main database — it scales to zero, has a generous free tier, and works perfectly with Prisma and Vercel. Add Upstash Redis for rate limiting, session caching, and response caching. If you need vector search, add pgvector to your Neon instance rather than a separate vector database at MVP stage.

Where to go next

This SaaS shell is built to absorb everything else in the series:

Auth, billing, AI, usage tracking, abuse prevention — that is not five separate systems, it is one contract enforced at every request. Get the contract right and the product pays for itself. Skip any one piece and you are one leaked API key away from a bill you cannot explain to anyone.

Related articles