Building a Modern SaaS with Next.js and Supabase
Introduction
Building a modern SaaS application requires careful consideration of technology choices and architecture decisions. In this tutorial, we'll walk through building a production-ready SaaS using Next.js 15 and Supabase, incorporating best practices for authentication, database design, and subscription management.
Tech Stack Overview
- Frontend: Next.js 15 with App Router
- Backend: Supabase (Database, Auth, Storage)
- Styling: Tailwind CSS
- Payment: Stripe Integration
- Deployment: Vercel
Setting Up the Project
Initial Setup
# Create new Next.js project with App Router
pnpm create next-app modern-saas --typescript --tailwind --app
# Install dependencies
cd modern-saas
pnpm add @supabase/supabase-js @supabase/auth-helpers-nextjs stripe
Database Schema Design
-- Users table extension
create table public.profiles (
id uuid references auth.users on delete cascade,
full_name text,
avatar_url text,
billing_address jsonb,
created_at timestamp with time zone default timezone('utc'::text, now()),
primary key (id)
);
-- Teams table
create table public.teams (
id uuid default uuid_generate_v4(),
name text not null,
slug text unique not null,
logo_url text,
created_at timestamp with time zone default timezone('utc'::text, now()),
primary key (id)
);
-- Team members junction table
create table public.team_members (
team_id uuid references public.teams on delete cascade,
user_id uuid references auth.users on delete cascade,
role text not null check (role in ('owner', 'admin', 'member')),
created_at timestamp with time zone default timezone('utc'::text, now()),
primary key (team_id, user_id)
);
Authentication Implementation
Setting Up Supabase Auth
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export const createClient = () => {
return createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)
}
Auth Components
// components/auth/sign-in-form.tsx
'use client'
export function SignInForm() {
const [isLoading, setIsLoading] = useState(false)
const supabase = createClient()
const handleSignIn = async (data: FormData) => {
setIsLoading(true)
try {
const { error } = await supabase.auth.signInWithPassword({
email: data.get('email') as string,
password: data.get('password') as string
})
if (error) throw error
// Redirect to dashboard on success
router.push('/dashboard')
} catch (error) {
toast.error('Authentication failed')
} finally {
setIsLoading(false)
}
}
return <form action={handleSignIn}>{/* Form fields */}</form>
}
Team Management System
Creating Teams
// app/actions/teams.ts
'use server'
export async function createTeam(data: CreateTeamData) {
const supabase = createServerClient()
const session = await getSession()
if (!session) {
throw new Error('Unauthorized')
}
const { data: team, error } = await supabase
.from('teams')
.insert({
name: data.name,
slug: generateSlug(data.name)
})
.select()
.single()
if (error) throw error
// Add creator as team owner
await supabase.from('team_members').insert({
team_id: team.id,
user_id: session.user.id,
role: 'owner'
})
return team
}
Subscription Management
Stripe Integration
// lib/stripe.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
typescript: true
})
export async function createSubscription(teamId: string, priceId: string) {
const team = await getTeam(teamId)
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1
}
],
metadata: {
teamId
},
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings/billing?canceled=true`
})
return session
}
Webhook Handler
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe'
export async function POST(req: Request) {
const body = await req.text()
const signature = headers().get('stripe-signature')!
try {
const event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
switch (event.type) {
case 'customer.subscription.created':
// Handle subscription creation
break
case 'customer.subscription.updated':
// Handle subscription updates
break
case 'customer.subscription.deleted':
// Handle subscription cancellation
break
}
return new Response(null, { status: 200 })
} catch (error) {
console.error('Stripe webhook error:', error)
return new Response('Webhook Error', { status: 400 })
}
}
Performance Optimization
Implementing Edge Functions
// app/api/edge/usage/route.ts
export const runtime = 'edge'
export async function GET() {
const data = await getUsageStats()
return Response.json(data)
}
Caching Strategy
// lib/cache.ts
import { unstable_cache } from 'next/cache'
export const getTeamStats = unstable_cache(
async (teamId: string) => {
// Fetch team statistics
return stats
},
['team-stats'],
{
revalidate: 60, // Cache for 1 minute
tags: ['team-stats']
}
)
Best Practices and Tips
-
Error Handling
- Implement proper error boundaries
- Use toast notifications for user feedback
- Log errors to monitoring service
-
Security
- Implement Row Level Security (RLS) in Supabase
- Use environment variables for sensitive data
- Validate all user inputs
-
Performance
- Use Edge Functions for global deployment
- Implement proper caching strategies
- Optimize images and assets
-
Development Workflow
- Use TypeScript for type safety
- Implement proper testing
- Follow consistent coding standards
Conclusion
Building a modern SaaS application with Next.js and Supabase provides a solid foundation for scalability and maintainability. This stack offers:
- Excellent developer experience
- Built-in authentication and authorization
- Scalable database solutions
- Modern deployment options
Remember to always consider your specific use case and requirements when implementing these patterns.
Next Steps
- Implement more advanced features
- Add comprehensive testing
- Set up monitoring and analytics
- Plan for scaling