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

返回教程列表
高级55 分钟

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

Complete tutorial for building a full-stack AI SaaS application using Next.js 16, Clerk for authentication, Supabase for database, and OpenAI for AI features. Covers user management, usage metering, stripe billing, and deploying to production.

saasnextjsclerksupabasestripeopenaitypescript

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.

相关工具

nextjsclerksupabaseopenaistripe