How to Create a SaaS with Next.js and Supabase — The Stack That Ships
Every developer has a SaaS idea. Most never ship because the infrastructure decisions — auth, database, billing, deployment — take longer than the actual product.
Next.js and Supabase together eliminate most of that friction. Supabase handles auth, database, storage, and realtime out of the box. Next.js handles routing, server-side rendering, and API routes. Add Stripe for billing and Vercel for deployment and you have a production SaaS stack that a solo developer can actually maintain.
This guide walks through the complete build: project setup, authentication, database schema, protected routes, Stripe subscriptions, and deployment.
🎯 Quick Answer (30-Second Read)
- Stack: Next.js 14 (App Router) + Supabase + Stripe + Vercel
- What Supabase replaces: Auth system, PostgreSQL database, file storage, realtime subscriptions
- What Next.js handles: Routing, server components, API routes, middleware
- Time to MVP: 3–7 days for a focused solo developer
- Cost to start: ~$0/month (Supabase free tier + Vercel hobby + Stripe test mode)
- Best for: B2B SaaS, dev tools, productivity apps, internal dashboards
The Architecture Before You Write Code
Project Structure
my-saas/
├── src/
│ ├── app/
│ │ ├── (auth)/
│ │ │ ├── login/page.tsx
│ │ │ └── signup/page.tsx
│ │ ├── (dashboard)/
│ │ │ ├── layout.tsx
│ │ │ └── dashboard/page.tsx
│ │ ├── api/
│ │ │ └── webhooks/
│ │ │ └── stripe/route.ts
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── components/
│ ├── lib/
│ │ ├── supabase/
│ │ │ ├── client.ts
│ │ │ ├── server.ts
│ │ │ └── middleware.ts
│ │ ├── stripe.ts
│ │ └── db.ts
│ └── middleware.ts
├── supabase/
│ └── migrations/
├── .env.local
└── package.jsonStep-by-Step Build Guide
Step 1 — Initialize the Project
npx create-next-app@latest my-saas \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*"
cd my-saasInstall all dependencies upfront:
npm install @supabase/ssr @supabase/supabase-js \
stripe @stripe/stripe-js \
zod \
@radix-ui/react-dialog \
lucide-react \
clsx tailwind-mergeStep 2 — Set Up Supabase
Create a project at supabase.com. Copy your project URL and anon key from Settings → API.
Create your .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
STRIPE_SECRET_KEY=sk_test_your-stripe-key
STRIPE_WEBHOOK_SECRET=whsec_your-webhook-secret
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key
NEXT_PUBLIC_APP_URL=http://localhost:3000Create the Supabase client helpers:
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}// src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() { return cookieStore.getAll() },
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
}
}
}
)
}Step 3 — Set Up the Database Schema
Run this SQL in the Supabase SQL editor to create your core tables:
-- Users profile table (extends Supabase auth.users)
create table public.profiles (
id uuid references auth.users(id) on delete cascade primary key,
email text not null,
full_name text,
avatar_url text,
created_at timestamptz default now()
);
-- Subscriptions table
create table public.subscriptions (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
stripe_customer_id text unique,
stripe_subscription_id text unique,
plan text default 'free' check (plan in ('free', 'pro', 'enterprise')),
status text default 'active' check (status in ('active', 'canceled', 'past_due')),
current_period_end timestamptz,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Enable Row Level Security
alter table public.profiles enable row level security;
alter table public.subscriptions enable row level security;
-- RLS Policies
create policy "Users can view own profile"
on profiles for select using (auth.uid() = id);
create policy "Users can update own profile"
on profiles for update using (auth.uid() = id);
create policy "Users can view own subscription"
on subscriptions for select using (auth.uid() = user_id);
-- Auto-create profile on signup
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer as $$
begin
insert into public.profiles (id, email, full_name)
values (new.id, new.email, new.raw_user_meta_data->>'full_name');
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();Step 4 — Add Auth Middleware
This middleware protects all dashboard routes and redirects unauthenticated users to login:
// src/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() { return request.cookies.getAll() },
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
}
}
}
)
const { data: { user } } = await supabase.auth.getUser()
const isProtected = request.nextUrl.pathname.startsWith('/dashboard')
if (isProtected && !user) {
return NextResponse.redirect(new URL('/login', request.url))
}
return supabaseResponse
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*']
}Step 5 — Build Auth Pages
// src/app/(auth)/login/page.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
async function handleLogin() {
setLoading(true)
setError('')
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
setError(error.message)
setLoading(false)
} else {
router.push('/dashboard')
}
}
return (
Sign in
setEmail(e.target.value)}
className="w-full mb-3 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm outline-none focus:border-indigo-500"
/>
setPassword(e.target.value)}
className="w-full mb-4 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm outline-none focus:border-indigo-500"
/>
{error && {error}
}
)
}Step 6 — Build the Dashboard
// src/app/(dashboard)/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single()
const { data: subscription } = await supabase
.from('subscriptions')
.select('*')
.eq('user_id', user.id)
.single()
return (
Welcome back, {profile?.full_name ?? user.email}
Plan:
{subscription?.plan ?? 'free'}
{/* Your dashboard content here */}
)
}Step 7 — Add Stripe Billing
// src/lib/stripe.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-04-10'
})
export const PLANS = {
pro: {
name: 'Pro',
price: 19,
priceId: 'price_your_stripe_price_id',
features: ['Unlimited projects', 'Priority support', 'API access']
}
}// src/app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe'
import { createClient } from '@supabase/supabase-js'
import { headers } from 'next/headers'
import type Stripe from 'stripe'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
export async function POST(req: Request) {
const body = await req.text()
const headersList = await headers()
const signature = headersList.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body, signature, process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
return new Response('Invalid signature', { status: 400 })
}
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription
const customerId = sub.customer as string
const { data: profile } = await supabase
.from('profiles')
.select('id')
.eq('stripe_customer_id', customerId)
.single()
if (profile) {
await supabase.from('subscriptions').upsert({
user_id: profile.id,
stripe_customer_id: customerId,
stripe_subscription_id: sub.id,
plan: 'pro',
status: sub.status,
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
updated_at: new Date().toISOString()
})
}
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription
await supabase
.from('subscriptions')
.update({ plan: 'free', status: 'canceled' })
.eq('stripe_subscription_id', sub.id)
break
}
}
return new Response('OK', { status: 200 })
}Step 8 — Deploy to Vercel
# Install Vercel CLI
npm install -g vercel
# Deploy
vercel deploy
# Set environment variables
vercel env add NEXT_PUBLIC_SUPABASE_URL
vercel env add NEXT_PUBLIC_SUPABASE_ANON_KEY
vercel env add SUPABASE_SERVICE_ROLE_KEY
vercel env add STRIPE_SECRET_KEY
vercel env add STRIPE_WEBHOOK_SECRET
vercel env add NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
vercel env add NEXT_PUBLIC_APP_URL
# Deploy to production
vercel --prodAfter deploying, update your Stripe webhook endpoint in the Stripe dashboard to point to https://your-domain.vercel.app/api/webhooks/stripe.
Feature Comparison: Supabase vs Firebase for SaaS
| Feature | Supabase | Firebase |
|---|---|---|
| Database | PostgreSQL (SQL) | Firestore (NoSQL) |
| Auth | Built-in, 20+ providers | Built-in, 20+ providers |
| Realtime | ✅ Yes | ✅ Yes |
| Storage | ✅ Yes | ✅ Yes |
| Row Level Security | ✅ Powerful SQL-based | ⚠️ Rules-based |
| Self-hostable | ✅ Yes | ❌ No |
| Local dev | ✅ Supabase CLI | ⚠️ Emulator |
| Open source | ✅ Yes | ❌ No |
| Free tier | 500MB DB, 1GB storage | 1GB DB, 5GB storage |
| Best for | SQL-comfortable devs, SaaS | Mobile apps, rapid prototypes |
Common Mistakes and How to Avoid Them
Using the service role key on the client. The service role key bypasses Row Level Security entirely. Only use it in server-side Route Handlers or server actions — never in client components or the browser.
Forgetting to enable RLS. Supabase tables are unprotected by default. Always run alter table your_table enable row level security and write explicit policies before going to production.
Not handling Stripe webhook retries. Stripe retries failed webhooks. Make your webhook handler idempotent — use upsert instead of insert when updating subscription records to prevent duplicate errors.
Skipping middleware session refresh. The @supabase/ssr middleware must refresh the session on every request. Copying the middleware pattern from this guide exactly prevents silent auth failures on long sessions.
Hardcoding Stripe Price IDs. Store Price IDs in environment variables, not in source code. They change between test and production environments.
Real Developer Use Case
Anurag built ThoughtStream on this exact stack — Next.js App Router, Supabase for auth and database, Stripe for subscriptions, deployed on Vercel.
The Supabase trigger that auto-creates a profile on signup eliminated an entire category of user onboarding bugs. RLS policies replaced a custom authorization layer that would have taken days to build and test. Stripe webhooks handled subscription state with four switch cases.
The full backend infrastructure — auth, database, billing, sessions — was production-ready in under two days. The remaining time went entirely into product features.
Frequently Asked Questions
Do I need a backend server to build SaaS with Next.js and Supabase?
No. Next.js Route Handlers replace traditional backend API routes. Supabase handles the database and auth. You only need a separate server if you have workloads that cannot run in serverless functions — like long-running background jobs or WebSocket servers.
How do I handle user roles and permissions beyond free/pro?
Add a role column to your profiles table and write RLS policies that check it. For example: create policy "Admins can view all" on table_name for select using (get_user_role() = 'admin'). Store roles in the database, not in JWTs, to keep them revocable.
Is Supabase free tier enough to launch a SaaS?
Yes for early-stage. The free tier includes 500MB database, 1GB file storage, 50,000 monthly active users, and 2GB bandwidth. Most SaaS products stay within these limits through their first hundred paying customers. Upgrade to the Pro plan ($25/month) when you approach the limits.
How do I test Stripe webhooks locally?
Install the Stripe CLI and run stripe listen --forward-to localhost:3000/api/webhooks/stripe. This creates a local tunnel that forwards Stripe events to your dev server. Use stripe trigger customer.subscription.created to simulate events.
Should I use Supabase Auth or a third-party like Clerk?
Supabase Auth handles most SaaS needs — email/password, magic links, OAuth providers, and session management. Use Clerk if you need advanced org management, multi-tenancy with team invites, or a pre-built UI component library for auth. For most solo-built SaaS products, Supabase Auth is sufficient and keeps your stack simpler.
Conclusion
Next.js and Supabase is the most productive SaaS stack available to a solo developer or small team in 2025. Supabase eliminates the infrastructure decisions that kill momentum — database setup, auth system, session management, file storage. Next.js handles the product layer cleanly with App Router and server components.
Build on this stack if you are a developer who wants to ship a SaaS product without managing infrastructure, comfortable with SQL and TypeScript, and want a stack you can run locally and deploy globally in minutes.
The path from idea to production is shorter than it has ever been. Get the scaffold right, wire up auth and billing early, and spend the rest of your time on the product that only you can build.
Related reads: How Developers Use AI to Build Apps Faster · How to Build a Chrome Extension with AI · Claude Code Setup Guide · How to Create a CLAUDE.md Configuration File