Next.js 15 AI SaaS — API Key Leak to Five-Figure Invoice
A client-bundled OpenAI key caused overnight five-figure charges during a free beta.
- This guide builds a complete, deployable AI SaaS: Next.js 15 App Router, Supabase Auth + RLS, Stripe metered billing, Vercel AI SDK streaming via Route Handlers
- Build in this exact order: scaffold, env validation, database schema, auth middleware, rate limiting, AI Route Handler, Stripe billing, deployment
- Biggest risk: shipping AI features before rate limiting and billing — a single viral post can generate a five-figure API bill in hours
Most AI SaaS tutorials stop at a chatbot demo. They skip the infrastructure that turns a prototype into a product: authentication, billing, multi-tenancy, rate limiting, and usage tracking. Without these, you have a tech demo, not a business.
This guide builds a complete, deployable AI SaaS application from the first commit to production. By the end, you have a running application with: authenticated users scoped to their own data, AI chat with streaming responses, per-user token quotas enforced before every AI call, Stripe metered billing that converts token consumption into revenue, and a Vercel deployment with isolated environments.
The stack is deliberate: Next.js 15 App Router for the application layer, Supabase for auth and data, Stripe for metered billing, Vercel AI SDK for model orchestration, and Upstash Redis for rate limiting. Each choice optimizes for developer velocity without sacrificing production readiness.
One warning before we start: the most expensive mistake in AI SaaS is building AI features before solving rate limiting and billing. The production incident below happened to a real team. It shapes every architectural decision in this guide.
What You Will Build
Before writing any code, understand exactly what this guide produces. The finished application is a deployable AI chat SaaS with the following capabilities:
Authenticated users can sign up, log in via email or OAuth, and access only their own data. The database enforces this at the row level — no application code can accidentally leak one user's conversations to another.
Each user has a monthly token quota. Before every AI call, the application checks the remaining quota. If the user has exceeded their limit, they receive a clear upgrade prompt. If they are within limits, the AI call proceeds and the token consumption is recorded.
A streaming chat interface sends messages to an AI model and displays the response word-by-word as it is generated. The model is called server-side — the API key never reaches the browser.
Stripe metered billing tracks every token consumed and generates invoices automatically at the end of each billing period. Users who exceed the free tier are billed based on actual usage.
The application deploys to Vercel with three isolated environments: local development, preview (one per pull request), and production. Each environment has its own Supabase project and Stripe account.
The build order is non-negotiable: scaffold first, then schema, then auth, then rate limiting, then AI, then billing, then deployment. Each layer depends on the previous one. Skipping layers creates rework.
Prerequisites and Stack Overview
Before starting, verify the following accounts and tools are available.
Accounts required: Supabase (free tier is sufficient to start), Stripe (test mode for development), Vercel (Hobby plan supports this stack), Upstash (free tier supports the rate limiting patterns in this guide), and an OpenAI account with API access.
Local tools required: Node.js 20+ or Bun 1.1+, the Supabase CLI for migrations, the Stripe CLI for local webhook testing, and Git.
The stack choices are deliberate. Next.js 15 App Router provides Server Components, Route Handlers, and Server Actions in a single deployable unit — no separate backend service. Supabase bundles authentication, PostgreSQL with Row Level Security, and file storage behind one SDK. Stripe metered billing maps token consumption to revenue without custom invoice logic. Vercel AI SDK abstracts the AI model provider — swap between OpenAI and Anthropic without changing application code. Upstash provides serverless Redis with a per-request pricing model that suits Vercel's serverless execution model.
Project Scaffold and Environment Validation
The project structure sets conventions for the entire codebase. Every module has a predictable location. Every import path is explicit.
The directory structure separates concerns: lib contains all server-side integrations (Supabase, Stripe, AI), components contains the UI, and app contains routing. Within lib, each integration is an isolated module. This prevents circular imports and makes each module independently testable.
Environment variables are validated at startup using a Zod schema. Missing or malformed variables fail the build — not a customer request at 2am. The validation runs once at module load time on the server. Client-safe variables use the NEXT_PUBLIC_ prefix; server-only variables do not.
Database Schema and Row Level Security
The schema is designed for multi-tenancy from the first migration. Every table has a user_id column. Row Level Security policies enforce that users access only their own rows. No application-level authorization code is needed for basic data access — the database enforces it.
The schema has three domains. User management: profiles extends the Supabase auth.users table with application-specific fields including the Stripe customer ID and subscription status. Content: conversations and messages store the chat history scoped to each user. Usage tracking: usage_records stores the token count and cost of every AI call, which feeds directly into Stripe metered billing.
Indexes are placed on (user_id, created_at DESC) for all tenant-scoped tables. This composite index serves the dominant query pattern: fetch a user's recent items in reverse chronological order.
The service role key bypasses RLS. It is used only in webhook handlers and admin operations — never in user-facing request handlers.
Authentication and Middleware
Supabase Auth handles the entire authentication lifecycle. The application does not implement auth logic — it consumes auth state from Supabase.
The middleware pattern is critical and must be implemented exactly as shown. The middleware runs on every request, calls getUser() to validate and refresh the session token, and redirects unauthenticated users away from protected routes. Without it, session tokens expire and users are logged out silently — or worse, RLS policies see a null user ID and block all data access.
The key distinction: always use getUser() in middleware and server-side code, never getSession(). getSession() returns cached session data that may be stale. getUser() makes a network request to Supabase to validate the token and return fresh user data. This is the single most common Supabase Auth bug in Next.js applications.
Rate Limiting with Upstash Redis
Rate limiting must exist before any AI call can be made. This is not an optimization — it is a business requirement. Without it, a single user or a viral launch can generate an unbounded API bill.
Upstash provides serverless Redis with per-request billing, which fits Vercel's serverless execution model. The @upstash/ratelimit package provides multiple algorithm implementations. For AI SaaS, use sliding window — it provides smooth rate limiting that prevents burst abuse while allowing legitimate usage.
The rate limiter uses the authenticated user ID as the identifier, not the IP address. IP-based rate limiting is trivially bypassed with a VPN. User ID-based limiting requires authentication, which means every limited request is traceable to a specific account.
Two rate limits are enforced: a per-minute limit that prevents burst abuse (10 requests per minute per user) and a per-day limit that enforces the daily token budget (100 requests per day on the free tier). Both checks happen before the AI call is initiated.
AI Orchestration with Vercel AI SDK
AI streaming responses require a Route Handler — not a Server Action. This is a critical architectural distinction. Server Actions return serializable values; they cannot return streaming Response objects with ReadableStream bodies. The Vercel AI SDK's useChat hook expects to call an HTTP endpoint that responds with a streaming body. That pattern requires a Route Handler at app/api/chat/route.ts.
Server Actions are appropriate for non-streaming AI operations: generating titles, summarizing content, classifying text, or any operation where you wait for the complete response before returning. For streaming chat responses — the primary use case in this guide — use a Route Handler.
The Route Handler enforces the complete request lifecycle: authenticate the user, check rate limits, verify token quota, call the AI model, record usage on completion, and return the streaming response. Every step happens server-side. The API key is a server-only environment variable that never appears in client bundles.
Stripe Metered Billing
Stripe metered billing ties AI token consumption to revenue automatically. Each AI response records a meter event in Stripe. At the end of the billing period, Stripe aggregates all events and generates an invoice.
The integration has two paths. Subscription creation: a Checkout Session creates the customer, subscription, and payment method in one step. Usage recording: a meter event is created after each AI response, containing the token count for that response.
Webhooks handle the subscription lifecycle: payment success, payment failure, subscription cancellation, and trial expiry. The webhook handler must be idempotent — Stripe retries failed webhooks, and processing the same event twice can double-credit or double-charge a customer. The processed_webhook_events table (created in the schema migration) stores event IDs to prevent duplicate processing.
The webhook handler uses the raw request body for signature verification. Next.js App Router parses request bodies by default — use request.text() before stripe.webhooks.constructEvent() to get the unparsed body.
Deployment and Environment Isolation
The application deploys to Vercel with three isolated environments: local development, preview (one per pull request), and production. Isolation means each environment has its own Supabase project, its own Stripe account in test mode for preview and production mode for production, and its own set of environment variables.
Sharing a Supabase project or Stripe account between environments is a common mistake with expensive consequences. A migration that runs correctly in preview can corrupt production if the environments share the same database. A test webhook can flip a production user's subscription status.
The deployment checklist ensures nothing is missed across all three environments.
After initial deployment, reset the monthly token usage counter for all users at the start of each billing period. This runs as a scheduled Supabase Edge Function or a cron job via Vercel Cron.
| Concern | Route Handler (app/api/chat/route.ts) | Server Action | When to Use Server Action |
|---|---|---|---|
| Streaming responses | Supported — toDataStreamResponse() returns a streaming Response | Not supported — Server Actions return serializable values, not ReadableStream | Never for streaming — always use Route Handler |
| useChat hook compatibility | Full — useChat calls a Route Handler via HTTP POST | Not compatible — useChat expects an HTTP endpoint | Never for useChat |
| Secret management | Automatic — server-only environment variables | Automatic — server-only environment variables | Both are equally secure |
| Non-streaming AI (title generation, summarization) | Works but adds routing overhead | Preferred — no HTTP endpoint needed, direct TypeScript call | All non-streaming AI operations |
| Type safety | Manual — parse request.json() and validate | End-to-end — TypeScript types flow from client to server | Server Actions when type safety matters more than streaming |
| Rate limiting | Applied before model call in the handler | Applied at the top of the action function | Both support rate limiting equally |
| Error handling | Return NextResponse.json with status codes | Throw errors — caught by error boundaries or try/catch in the client | Server Actions when error boundary handling is preferred |
Key Takeaways
- Build in this exact order: scaffold, schema, auth, rate limiting, AI, billing, deployment. Each layer depends on the previous one — skipping any layer creates expensive rework.
- Use import 'server-only' in every file that handles API keys, the Stripe client, or the Supabase admin client. This is compile-time enforcement that prevents the most common AI SaaS security mistake.
- Streaming AI responses require a Route Handler — not a Server Action. Server Actions cannot return ReadableStream responses. Non-streaming AI operations belong in Server Actions.
- Rate limiting uses the authenticated user ID as the identifier, not the IP address. Both limits — per-minute burst and per-day budget — must be checked before every AI call.
- Stripe webhook handlers must be idempotent. Store the event ID before processing and check for duplicates on every request. A non-idempotent handler will eventually double-charge a customer.
- Set a hard spending cap in the OpenAI dashboard as the last line of defense. Rate limiting protects against abuse. The spending cap protects against bugs in your rate limiting code.
Common Mistakes to Avoid
- Building AI features before rate limiting and billing infrastructure
Symptom: AI features work perfectly in development. At launch, unexpected traffic drives API costs to five figures before anyone can respond. Adding rate limiting and billing retroactively requires refactoring every AI call site.
Fix: Follow the seven-step build order: scaffold, schema, auth, rate limiting, AI, billing, deployment. Rate limiting and billing are prerequisites for AI features, not additions to them. - Importing AI clients or API keys in files that become Client Components
Symptom: API keys are visible in browser DevTools under the Sources tab or in compiled JavaScript chunks in .next/static/. Attackers extract the key and make direct API calls, bypassing all rate limits and quotas.
Fix: Add import 'server-only' to every file that imports the AI client, Stripe client, or Supabase admin client. This causes a build error if the file is transitively imported by a Client Component. Run grep -rn 'sk-' .next/static/chunks/ after every build to verify no keys leaked. - Using getSession() instead of getUser() in middleware or server-side code
Symptom: Users appear authenticated but RLS policies fail with permission denied. Session tokens expire silently and users are not redirected to login. Data access fails intermittently depending on when the token was last refreshed.
Fix: Replace all getSession() calls in middleware and server-side code with getUser(). getUser() validates the token with Supabase servers and returns fresh user data. getSession() reads a local cookie that may be expired. The middleware must return supabaseResponse — notNextResponse.next(). - Using a Server Action for streaming AI responses
Symptom: The AI response appears all at once after a long delay instead of streaming word-by-word. The useChat hook from Vercel AI SDK does not work with Server Actions. toDataStreamResponse() causes a TypeScript error in a Server Action context.
Fix: Move the streaming AI call to a Route Handler at app/api/chat/route.ts. Server Actions are for non-streaming AI operations — title generation, summarization, classification. useChat requires an HTTP endpoint that returns a streaming Response. - Non-idempotent Stripe webhook handlers
Symptom: Users receive doubled credits after subscription payment. Subscription status toggles between active and cancelled. The issue is intermittent and difficult to reproduce because it depends on Stripe retry timing.
Fix: Store the Stripe event ID in the processed_webhook_events table before processing. Check for the event ID at the start of every webhook request. If found, return 200 immediately. Insert the event ID before processing begins so that retries during processing are handled correctly. - Sharing a Supabase project or Stripe account across environments
Symptom: A migration that passes in the preview environment corrupts the production database. A test payment webhook changes a real customer's subscription status. Test conversations appear in production user accounts.
Fix: Create separate Supabase projects and Stripe accounts for development, preview, and production. The cost of separate projects is trivial. The cost of corrupted production data or incorrect billing is not.
Interview Questions on This Topic
- QHow would you design the architecture for a multi-tenant AI SaaS that must not expose API keys to the client?SeniorReveal
- QExplain how you would implement rate limiting for an AI SaaS without blocking legitimate users.Mid-levelReveal
- QWhat is Row Level Security and how does it differ from application-level authorization?JuniorReveal
- QWhy use a Route Handler instead of a Server Action for streaming AI responses, and when would you use a Server Action?Mid-levelReveal
Frequently Asked Questions
Why Next.js 15 App Router instead of a separate Express or Fastify backend?
Next.js 15 App Router with Route Handlers and Server Actions eliminates the need for a separate API service. Route Handlers handle webhooks and streaming responses. Server Actions handle mutations. The framework manages routing, rendering, and deployment in a single Vercel project. A separate backend adds: a separate deployment pipeline, CORS configuration between frontend and backend, shared type definitions that must be kept in sync, and an additional service to monitor and scale. For most AI SaaS applications, none of these trade-offs are worth the added complexity. Migrate to a separate backend when you need to scale API and frontend independently, support non-HTTP protocols like WebSockets at scale, or have multiple client applications (web, mobile, CLI) that share an API.
Can I use a different database instead of Supabase?
Yes, with trade-offs. The architecture works with any PostgreSQL database that supports Row Level Security — Neon is a strong alternative with instant branch databases for development environments. For auth, you would add Clerk or Auth.js as a separate service. For storage, you would add an S3-compatible service. Supabase is chosen because it bundles PostgreSQL with RLS, auth, and storage behind a single SDK and dashboard — one billing relationship instead of three or four. The trade-off in switching is integration complexity: you gain flexibility in each component but lose the integrated auth-to-database session flow that makes Supabase's RLS with auth.uid() work automatically.
How do I handle AI model provider outages?
The getModel() function in src/lib/ai.ts supports multiple providers. For resilience, wrap the streamText call in a try/catch and retry with the fallback model if the primary returns a 503 or timeout. Store the model used in each conversation — when a user resumes a conversation, use the same model that started it for consistency. Monitor provider status pages and configure Sentry alerts for elevated AI error rates. For production applications with strict availability requirements, implement exponential backoff with jitter for retries and expose the current provider's status on your application's status page.
How do I reset monthly token usage at the start of each billing period?
Add a Vercel Cron Job that runs on the first day of each month and resets token_usage_current_month to 0 for all users. The cron job calls a protected API route that uses the Supabase admin client to run an UPDATE on the profiles table. Protect the cron route with a shared secret in the Authorization header to prevent unauthorized resets. Alternatively, use a Supabase Edge Function with the pg_cron extension to run the reset as a scheduled database job — this keeps the reset logic closer to the data and does not require an external HTTP call.
When should I migrate from this stack to something more complex?
Migrate specific components when you hit concrete limits — not anticipated ones. Migrate from Supabase to self-hosted Postgres when Supabase's pricing or connection limits become a real cost, not a theoretical concern. Migrate from Next.js Route Handlers to a separate backend when you have multiple client applications or need to scale API traffic independently of frontend traffic. Migrate from Stripe metered billing to a custom billing engine when Stripe's pricing model does not match your revenue structure. The stack in this guide handles hundreds of thousands of users and millions of AI calls per month. Most AI SaaS applications will not outgrow it. Premature architectural migration is the most common way to turn a two-week task into a two-month project.
That's React.js. Mark it forged?
7 min read · try the examples if you haven't