← Back to tutorials

Build a Full-Stack AI SaaS App with Next.js 16, Clerk, and Supabase 2026

Step-by-step guide to building a production-ready AI SaaS application with authentication, usage limits, subscription billing, and AI features

Build a Full-Stack AI SaaS App with Next.js 16, Clerk, and Supabase 2026

This tutorial builds a complete AI writing tool SaaS from scratch. You'll have a deployable app with authentication, usage limits, paid plans, and AI features by the end.

Architecture Overview


Next.js 16 (App Router)
├── Authentication: Clerk (SSO, magic links, org management)
├── Database: Supabase (Postgres + pgvector)
├── AI: OpenAI API (GPT-4o)
├── Billing: Stripe (subscriptions)
└── Deployment: Vercel

1. Project Setup

bash
npx create-next-app@latest ai-saas --typescript --tailwind --app
cd ai-saas
bun add @clerk/nextjs @supabase/supabase-js openai stripe @stripe/stripe-js ai @ai-sdk/openai

2. Authentication with Clerk

typescript
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

const isProtectedRoute = createRouteMatcher([ '/dashboard(.*)', '/api/ai(.*)', '/api/usage(.*)', ]);

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

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

typescript
// src/app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs';

export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); }

3. Supabase Database Schema

sql
-- Users table (synced from Clerk)
CREATE TABLE users (
  id TEXT PRIMARY KEY,  -- Clerk user ID
  email TEXT NOT NULL,
  name TEXT,
  plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'starter', 'pro')),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Usage tracking CREATE TABLE usage_logs ( id BIGSERIAL PRIMARY KEY, user_id TEXT REFERENCES users(id), action TEXT NOT NULL, -- 'generate', 'improve', 'translate' tokens_used INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW() );

-- Monthly usage view CREATE VIEW monthly_usage AS SELECT user_id, DATE_TRUNC('month', created_at) AS month, COUNT(*) AS request_count, SUM(tokens_used) AS total_tokens FROM usage_logs GROUP BY user_id, DATE_TRUNC('month', created_at);

-- Plan limits CREATE TABLE plan_limits ( plan TEXT PRIMARY KEY, monthly_requests INTEGER, monthly_tokens INTEGER );

INSERT INTO plan_limits VALUES ('free', 20, 50000), ('starter', 200, 500000), ('pro', 2000, 5000000);

4. Usage Metering

typescript
// src/lib/usage.ts
import { createClient } from '@/lib/supabase/server';
import { auth } from '@clerk/nextjs/server';

export async function checkUsageLimit(action: string): Promise { const { userId } = await auth(); if (!userId) return false;

const supabase = createClient();

// Get user's plan and current month usage const { data } = await supabase .from('users') .select(` plan, plan_limits!users_plan_fkey(monthly_requests) `) .eq('id', userId) .single();

const monthStart = new Date(); monthStart.setDate(1); monthStart.setHours(0, 0, 0, 0);

const { count } = await supabase .from('usage_logs') .select('*', { count: 'exact', head: true }) .eq('user_id', userId) .gte('created_at', monthStart.toISOString());

const limit = data?.plan_limits?.monthly_requests ?? 20; return (count ?? 0) < limit; }

export async function logUsage(action: string, tokensUsed: number) { const { userId } = await auth(); if (!userId) return;

const supabase = createClient(); await supabase.from('usage_logs').insert({ user_id: userId, action, tokens_used: tokensUsed, }); }

5. AI Features

typescript
// src/app/api/ai/generate/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { auth } from '@clerk/nextjs/server';
import { checkUsageLimit, logUsage } from '@/lib/usage';

export async function POST(req: Request) { const { userId } = await auth(); if (!userId) { return new Response('Unauthorized', { status: 401 }); }

// Check usage limits const canProceed = await checkUsageLimit('generate'); if (!canProceed) { return new Response( JSON.stringify({ error: 'Monthly limit reached. Please upgrade your plan.' }), { status: 429, headers: { 'Content-Type': 'application/json' } } ); }

const { prompt, type } = await req.json();

const systemPrompts: Record = { blog: 'You are an expert blog writer. Create engaging, SEO-optimized content.', email: 'You are a professional email copywriter. Write clear, effective emails.', social: 'You are a social media expert. Create engaging posts optimized for each platform.', };

let tokensUsed = 0;

const result = streamText({ model: openai('gpt-4o'), system: systemPrompts[type] ?? systemPrompts.blog, prompt, onFinish: async ({ usage }) => { tokensUsed = usage.totalTokens; await logUsage('generate', tokensUsed); }, });

return result.toDataStreamResponse(); }

6. Stripe Billing

typescript
// src/app/api/stripe/create-checkout/route.ts
import Stripe from 'stripe';
import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';

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

const PLANS = { starter: process.env.STRIPE_STARTER_PRICE_ID!, pro: process.env.STRIPE_PRO_PRICE_ID!, };

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

const { plan } = await req.json() as { plan: 'starter' | 'pro' };

const session = await stripe.checkout.sessions.create({ mode: 'subscription', payment_method_types: ['card'], line_items: [{ price: PLANS[plan], quantity: 1 }], success_url: ${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true, cancel_url: ${process.env.NEXT_PUBLIC_APP_URL}/pricing, customer_email: sessionClaims?.email as string, metadata: { userId, plan }, });

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

typescript
// src/app/api/stripe/webhook/route.ts
import Stripe from 'stripe';
import { createAdminClient } from '@/lib/supabase/admin';

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

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

let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!); } catch { return new Response('Webhook signature verification failed', { status: 400 }); }

const supabase = createAdminClient();

if (event.type === 'checkout.session.completed') { const session = event.data.object as Stripe.CheckoutSession; const { userId, plan } = session.metadata as { userId: string; plan: string };

// Update user's plan in database await supabase.from('users').upsert({ id: userId, plan }); }

if (event.type === 'customer.subscription.deleted') { // Downgrade to free on cancellation const subscription = event.data.object as Stripe.Subscription; const userId = subscription.metadata.userId; await supabase.from('users').update({ plan: 'free' }).eq('id', userId); }

return new Response('OK', { status: 200 }); }

7. Pricing Page

typescript
// src/app/pricing/page.tsx
import { auth } from '@clerk/nextjs/server';
import { PricingCards } from '@/components/pricing-cards';

export default async function PricingPage() { const { userId } = await auth();

const plans = [ { name: 'Free', price: '$0', period: 'forever', features: ['20 AI generations/month', 'Basic templates', 'Email support'], cta: userId ? 'Current Plan' : 'Get Started', disabled: true, }, { name: 'Starter', price: '$19', period: '/month', features: ['200 AI generations/month', 'All templates', 'Priority support', 'API access'], cta: 'Upgrade to Starter', plan: 'starter', highlighted: true, }, { name: 'Pro', price: '$49', period: '/month', features: ['2000 AI generations/month', 'Custom AI personas', 'Team collaboration', 'Analytics'], cta: 'Upgrade to Pro', plan: 'pro', }, ];

return (

Simple, transparent pricing

); }

8. Deployment Checklist

bash

Environment variables needed:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_... CLERK_SECRET_KEY=sk_... NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=... SUPABASE_SERVICE_ROLE_KEY=... OPENAI_API_KEY=sk-... STRIPE_SECRET_KEY=sk_... STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_STARTER_PRICE_ID=price_... STRIPE_PRO_PRICE_ID=price_... NEXT_PUBLIC_APP_URL=https://your-app.vercel.app

Conclusion

This architecture handles most AI SaaS requirements: authentication with Clerk, data persistence with Supabase, usage metering, subscription billing with Stripe, and AI features with the Vercel AI SDK. The total backend cost for under 100 users: ~$50/month (Supabase Pro + Vercel Pro + OpenAI usage). Revenue needed to be profitable: 3 Starter plan subscribers.

Also available in 中文.