Build a Full SaaS Application with Next.js 14, Supabase and Stripe in 2025
Complete walkthrough of building a production SaaS: authentication, database design, subscription billing with Stripe, protected routes, and deployment on Vercel.
What We're Building
A complete SaaS boilerplate with:
- Auth (signup, login, password reset) via Supabase
- Database with Row-Level Security
- Subscription billing (Stripe)
- Protected routes and dashboard
- Vercel deployment
By the end of this guide, you'll have a production-ready SaaS foundation.
Tech Stack
**** Frontend: Next.js 14 App Router + TypeScript Styling: Tailwind CSS Backend: Supabase (Postgres + Auth + Edge Functions) Payments: Stripe Deploy: Vercel ****
Step 1: Project Setup
****bash npx create-next-app@latest my-saas --typescript --tailwind --app cd my-saas npm install @supabase/ssr @supabase/supabase-js stripe @stripe/stripe-js ****
Step 2: Supabase Database Schema
****`sql
-- users table (extends Supabase auth.users)
create table public.profiles (
id uuid references auth.users primary key,
email text unique,
full_name text,
avatar_url text,
plan text default 'free' check (plan in ('free', 'pro', 'enterprise')),
stripe_customer_id text unique,
created_at timestamptz default now()
);
-- subscriptions table
create table public.subscriptions (
id text primary key, -- Stripe subscription ID
user_id uuid references public.profiles(id),
status text,
price_id text,
current_period_start timestamptz,
current_period_end timestamptz,
cancel_at_period_end boolean default false,
created_at timestamptz default now()
);
-- RLS: users can only see their own data
alter table public.profiles enable row level security;
alter table public.subscriptions enable row level security;
create policy "Users see own profile"
on public.profiles for all using (auth.uid() = id);
create policy "Users see own subscriptions"
on public.subscriptions for all 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, avatar_url)
values (
new.id,
new.email,
new.raw_user_meta_data->>'full_name',
new.raw_user_meta_data->>'avatar_url'
);
return new;
end $$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
****`
Step 3: Authentication
****`typescript
// app/auth/login/page.tsx
'use client';
import { createBrowserClient } from '@supabase/ssr';
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 router = useRouter();
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) { setError(error.message); return; }
router.push('/dashboard');
}
async function handleSignup(e: React.FormEvent) {
e.preventDefault();
const { error } = await supabase.auth.signUp({
email,
password,
options: { emailRedirectTo: ${window.location.origin}/auth/callback }
});
if (error) { setError(error.message); return; }
setError('Check your email to confirm your account');
}
return (
);
}
****`
Step 4: Protected Dashboard
****`typescript
// app/dashboard/page.tsx
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const cookieStore = cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { getAll: () => cookieStore.getAll(), setAll: (c) => c.forEach(({name, value, options}) => cookieStore.set(name, value, options)) } }
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect('/auth/login');
const { data: profile } = await supabase
.from('profiles')
.select(', subscriptions()')
.eq('id', user.id)
.single();
return (
Dashboard
Welcome back, {profile?.full_name || user.email}
<div className="mt-6 grid grid-cols-3 gap-4">
<div className="bg-white border rounded-lg p-4">
<p className="text-sm text-gray-500">Current Plan</p>
<p className="text-2xl font-bold capitalize">{profile?.plan}</p>
</div>
</div>
{profile?.plan === 'free' && (
<div className="mt-6 bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-lg p-6">
<h2 className="text-xl font-bold">Upgrade to Pro</h2>
<p className="mt-2 opacity-90">Get unlimited access to all features</p>
<a href="/pricing" className="mt-4 inline-block bg-white text-purple-600 px-6 py-2 rounded-full font-semibold">
View Plans →
</a>
</div>
)}
</div>
);
}
****`
Step 5: Stripe Integration
****`typescript
// app/api/create-checkout/route.ts
import Stripe from 'stripe';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { priceId } = await req.json();
const cookieStore = cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { getAll: () => cookieStore.getAll(), setAll: (c) => c.forEach(({name,value,options}) => cookieStore.set(name,value,options)) } }
);
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();
// Create or retrieve Stripe customer
let customerId = profile?.stripe_customer_id;
if (!customerId) {
const customer = await stripe.customers.create({ email: user.email! });
customerId = customer.id;
await supabase.from('profiles').update({ stripe_customer_id: customerId }).eq('id', user.id);
}
// Create checkout session
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
success_url: ${process.env.NEXT_PUBLIC_SITE_URL}/dashboard?success=true,
cancel_url: ${process.env.NEXT_PUBLIC_SITE_URL}/pricing,
metadata: { user_id: user.id },
});
return NextResponse.json({ url: session.url });
}
****`
Step 6: Stripe Webhook Handler
****`typescript
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';
import { NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // Service role for webhook
);
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 NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.CheckoutSession;
const userId = session.metadata?.user_id;
if (!userId) break;
// Update user plan
await supabase.from('profiles').update({ plan: 'pro' }).eq('id', userId);
// Save subscription
const sub = await stripe.subscriptions.retrieve(session.subscription as string);
await supabase.from('subscriptions').upsert({
id: sub.id, user_id: userId, status: sub.status,
price_id: sub.items.data[0].price.id,
current_period_start: new Date(sub.current_period_start * 1000).toISOString(),
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
});
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await supabase.from('subscriptions').update({ status: 'canceled' }).eq('id', sub.id);
const { data: subData } = await supabase.from('subscriptions').select('user_id').eq('id', sub.id).single();
if (subData) await supabase.from('profiles').update({ plan: 'free' }).eq('id', subData.user_id);
break;
}
}
return NextResponse.json({ received: true });
}
****`
Deployment on Vercel
****`bash
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_SITE_URL
Deploy
vercel --prod
****`
Then in Stripe Dashboard → Webhooks → Add endpoint → your Vercel URL + /api/webhooks/stripe.
Need a Custom SaaS Built?
I build complete SaaS applications in 3-6 weeks. Full stack: auth, database, payments, admin panel, analytics.