Building a SaaS App with Next.js, Supabase, and Stripe
Learn how to build a SaaS app with Next.js, Supabase, and Stripe — authentication, database, subscription billing, and deployment on Vercel in one complete guide.
Get more content like this on Telegram!
Daily AI tips, notes & resources — free
Building a SaaS App with Next.js, Supabase, and Stripe
Two years ago, building a basic SaaS with authentication, a database, and subscription billing required wiring together dozens of services and writing hundreds of lines of boilerplate. Today, the combination of Next.js, Supabase, and Stripe reduces that to a weekend of work.
I built a SaaS using this exact stack that reached 150 paying customers in its first three months. The entire infrastructure cost was under $100/month. No DevOps team, no infrastructure engineers — just a lean stack that handles authentication, data, payments, and deployment without babysitting.
In this guide, you'll learn the architecture and key implementation details for each piece — from Supabase auth through Stripe subscriptions to webhook handling.
Architecture Overview
User's Browser (Next.js SSR/CSR)
↕ HTTPS
Vercel Edge Network
↕
Next.js App (App Router)
├── /app/(auth) — Login, signup pages
├── /app/dashboard — Protected routes (auth required)
├── /app/api/stripe — Stripe checkout and webhook handlers
└── Server Actions — Database mutations
↕ ↕
Supabase (PostgreSQL + Auth) Stripe (Subscriptions)
- User data - Products & prices
- Subscription records - Payment processing
- App data - Webhook events
Step 1: Supabase Setup
Create Project and Schema
- Create project at supabase.com
- Go to SQL Editor, run initial schema:
-- profiles table (extends Supabase auth.users)
CREATE TABLE profiles (
id UUID REFERENCES auth.users(id) PRIMARY KEY,
email TEXT NOT NULL,
full_name TEXT,
avatar_url TEXT,
stripe_customer_id TEXT UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- subscriptions table
CREATE TABLE subscriptions (
id TEXT PRIMARY KEY, -- Stripe subscription ID
user_id UUID REFERENCES profiles(id) NOT NULL,
status TEXT NOT NULL, -- active, past_due, canceled, trialing
price_id TEXT NOT NULL, -- Stripe price ID
current_period_end TIMESTAMPTZ,
cancel_at_period_end BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Row Level Security
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view their own profile"
ON profiles FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update their own profile"
ON profiles FOR UPDATE USING (auth.uid() = id);
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view their own subscriptions"
ON subscriptions FOR SELECT USING (auth.uid() = user_id);
Supabase in Next.js
npm install @supabase/supabase-js @supabase/ssr
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';
export const createClient = () =>
createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// lib/supabase/server.ts (for Server Components and Route Handlers)
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export const createServerSupabase = () => {
const cookieStore = cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
};
Step 2: Authentication with Supabase Auth
Supabase Auth provides email/password, magic links, and OAuth (GitHub, Google) out of the box:
// app/(auth)/login/page.tsx
'use client';
import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const router = useRouter();
const supabase = createClient();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
setError(error.message);
} else {
router.push('/dashboard');
router.refresh();
}
};
return (
<form onSubmit={handleLogin}>
<input value={email} onChange={e => setEmail(e.target.value)} type="email" placeholder="Email" required />
<input value={password} onChange={e => setPassword(e.target.value)} type="password" placeholder="Password" required />
{error && <p style={{color: 'red'}}>{error}</p>}
<button type="submit">Sign In</button>
</form>
);
}
Protected Route Middleware:
// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { getAll: () => request.cookies.getAll(), setAll: (cookiesToSet) => { cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options)); } } }
);
const { data: { user } } = await supabase.auth.getUser();
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return response;
}
export const config = { matcher: ['/dashboard/:path*'] };
Step 3: Stripe Subscription Billing
Create Stripe Products
In your Stripe dashboard, create:
- Product: "SaaS Pro Plan"
- Price: $29/month (recurring)
- Note the Price ID:
price_xxxxx
npm install stripe
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-04-10',
});
Checkout Session Creation
// app/api/stripe/create-checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { createServerSupabase } from '@/lib/supabase/server';
export async function POST(req: NextRequest) {
const supabase = createServerSupabase();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { data: profile } = await supabase
.from('profiles')
.select('stripe_customer_id')
.eq('id', user.id)
.single();
const session = await stripe.checkout.sessions.create({
customer: profile?.stripe_customer_id || undefined,
customer_email: !profile?.stripe_customer_id ? user.email : undefined,
mode: 'subscription',
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: { userId: user.id },
});
return NextResponse.json({ url: session.url });
}
Webhook Handler — The Critical Piece
// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { createClient } from '@supabase/supabase-js';
// Use service role key for admin database access
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature')!;
let 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) {
case 'checkout.session.completed': {
const session = event.data.object;
const userId = session.metadata?.userId;
if (!userId || !session.subscription) break;
// Store customer ID and subscription
await supabaseAdmin
.from('profiles')
.update({ stripe_customer_id: session.customer as string })
.eq('id', userId);
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
await supabaseAdmin.from('subscriptions').upsert({
id: subscription.id,
user_id: userId,
status: subscription.status,
price_id: subscription.items.data[0].price.id,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
});
break;
}
case 'invoice.payment_failed':
case 'customer.subscription.deleted': {
const subscription = event.data.object;
await supabaseAdmin
.from('subscriptions')
.update({ status: subscription.status })
.eq('id', subscription.id);
break;
}
}
return NextResponse.json({ received: true });
}
Step 4: Checking Subscription Status
// lib/subscription.ts
import { createServerSupabase } from './supabase/server';
export async function getUserSubscription() {
const supabase = createServerSupabase();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data } = await supabase
.from('subscriptions')
.select('*')
.eq('user_id', user.id)
.in('status', ['active', 'trialing'])
.single();
return data;
}
export async function isSubscribed(): Promise<boolean> {
const sub = await getUserSubscription();
return !!sub;
}
For deployment architecture and containerization, see our Docker tutorial. For the database fundamentals behind Supabase's PostgreSQL, see our SQL guide.
Frequently Asked Questions
What is the best tech stack for building a SaaS in 2025?
Next.js 14+ (App Router) + Supabase (PostgreSQL + Auth) + Stripe (billing) + Vercel (hosting). Used by hundreds of indie SaaS products. Total infrastructure cost under $100/month at early stage.
How difficult is Stripe subscription integration?
Moderately complex for the first integration. The checkout session creation is straightforward. Webhooks — handling Stripe's event callbacks to update your database — is the confusing part for most developers.
Should I use Supabase or PlanetScale?
Supabase in 2025. PlanetScale removed its free tier in 2024. Supabase provides PostgreSQL + Auth + Storage + Realtime in one platform with a generous free tier.
What is a Stripe webhook?
HTTP POST requests Stripe sends to your server when events happen — payment succeeded, subscription cancelled, etc. You must handle these to update user access in your database.
How much does it cost to run a Next.js + Supabase + Stripe SaaS?
Very low early on: Vercel Hobby (free), Supabase Free tier (free). At 100 paying customers ($2,000 MRR): ~$100–125/month in platform costs — about 6% overhead.
Frequently Asked Questions
AiTechWorlds Team
✓ Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
How I Built a Full-Stack App in 48 Hours Using AI Tools
Learn how to use AI tools to build a full-stack app fast — GitHub Copilot, Claude, and ChatGPT for planning, coding, debugging, and deploying a real web application in 48 hours.
The 2025 Full Stack Developer Roadmap: From Zero to Job-Ready
The complete full stack developer roadmap for 2025 — learn frontend, backend, databases, DevOps, and the exact learning path from beginner to job-ready in 12–18 months.
The Full Stack Developer Salary Guide for 2025 by Country
Full stack developer salary guide 2025 — average salaries by country, experience level, tech stack, and remote work, plus tips to negotiate a higher salary.
How to Get Your First Full-Stack Job Without a CS Degree
Full stack job no degree guide — how self-taught developers and bootcamp grads land their first software job with a portfolio, networking, and interview prep.