BTC·
ETH·
SOL·
BNB·
XRP·
ADA·
DOGE·
AVAX·
LINK·
MATIC·
DOT·
ATOM·
BTC·
ETH·
SOL·
BNB·
XRP·
ADA·
DOGE·
AVAX·
LINK·
MATIC·
DOT·
ATOM·
Back to blog
Next.jsSupabaseSaaSStripeWeb Development

Build a Full SaaS Application with Next.js 14, Supabase and Stripe in 2025

iL
Ismaël Ladjohounlou
December 10, 202520 min read

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 (


Login


{error &&

{error}

}
<input type="email" placeholder="Email" value={email}
onChange={e => setEmail(e.target.value)}
className="w-full border rounded px-3 py-2" required />
<input type="password" placeholder="Password" value={password}
onChange={e => setPassword(e.target.value)}
className="w-full border rounded px-3 py-2" required />



);
}
****`


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.

See my web development services → or contact me →