Complete Guide to Building SaaS with Next.js and Supabase
Master full-stack SaaS development with Next.js 15 and Supabase. From database design to deployment, learn everything you need to build production-ready...
Complete Guide to Building SaaS with Next.js and Supabase#
Building a Software-as-a-Service (SaaS) application requires mastering multiple technologies and architectural patterns. This comprehensive guide walks you through building production-ready SaaS applications using Next.js 15 and Supabase—a powerful combination that enables indie developers to ship fast without sacrificing quality.
Why Next.js + Supabase for SaaS?#
Next.js 15 provides:
- Server-side rendering and static generation
- API routes for backend logic
- Optimized performance out of the box
- Edge runtime support
- Built-in TypeScript support
Supabase offers:
- PostgreSQL database with real-time capabilities
- Built-in authentication
- Row-level security (RLS)
- Storage for files and media
- Auto-generated APIs
Together, they form a complete stack that lets you focus on building features instead of infrastructure.
1. Project Setup and Configuration#
Initial Setup#
## Create Next.js app
npx create-next-app@latest my-saas-app --typescript --tailwind --app
## Install Supabase
npm install @supabase/supabase-js @supabase/ssr
Environment Configuration#
## .env.local
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
Supabase Client Setup#
// 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!
)
}
2. Database Design for SaaS#
Multi-Tenant Architecture#
Design your database schema to support multiple customers (tenants):
-- Organizations table
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Users table
CREATE TABLE users (
id UUID PRIMARY KEY REFERENCES auth.users,
email TEXT NOT NULL,
full_name TEXT,
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Organization members
CREATE TABLE organization_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(organization_id, user_id)
);
Row-Level Security (RLS)#
Protect your data with RLS policies:
-- Enable RLS
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE organization_members ENABLE ROW LEVEL SECURITY;
-- Users can only see organizations they're members of
CREATE POLICY "Users can view their organizations"
ON organizations FOR SELECT
USING (
id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid()
)
);
Related: Supabase Database Schema Design for SaaS Applications, Implement Soft Deletes in Supabase PostgreSQL
3. Authentication and Authorization#
Email/Password Authentication#
// app/auth/login/page.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useState } from 'react'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const supabase = createClient()
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
console.error('Login error:', error.message)
return
}
// Redirect to dashboard
window.location.href = '/dashboard'
}
return (
<form onSubmit={handleLogin}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
<button type="submit">Sign In</button>
</form>
)
}
Social Authentication#
// Sign in with Google
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
Protected Routes#
// 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: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: any) {
response.cookies.set({ name, value, ...options })
},
remove(name: string, options: any) {
response.cookies.set({ name, value: '', ...options })
},
},
}
)
const { data: { session } } = await supabase.auth.getSession()
if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/auth/login', request.url))
}
return response
}
export const config = {
matcher: ['/dashboard/:path*'],
}
Related: Implement Social Auth with Supabase Next.js Complete Guide, Build Protected Routes in Next.js 15 with Supabase
4. API Development#
REST API with Route Handlers#
// app/api/organizations/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET() {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('organizations')
.select(`
*,
organization_members!inner(role)
`)
.eq('organization_members.user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(request: Request) {
const supabase = createClient()
const { name, slug } = await request.json()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Create organization
const { data: org, error: orgError } = await supabase
.from('organizations')
.insert({ name, slug })
.select()
.single()
if (orgError) {
return NextResponse.json({ error: orgError.message }, { status: 500 })
}
// Add user as owner
const { error: memberError } = await supabase
.from('organization_members')
.insert({
organization_id: org.id,
user_id: user.id,
role: 'owner',
})
if (memberError) {
return NextResponse.json({ error: memberError.message }, { status: 500 })
}
return NextResponse.json({ data: org }, { status: 201 })
}
Server Actions#
// app/actions/organizations.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function createOrganization(formData: FormData) {
const supabase = createClient()
const name = formData.get('name') as string
const slug = formData.get('slug') as string
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { error: 'Unauthorized' }
}
const { data, error } = await supabase
.from('organizations')
.insert({ name, slug })
.select()
.single()
if (error) {
return { error: error.message }
}
revalidatePath('/dashboard/organizations')
return { data }
}
Related: Build REST API with Next.js 15 Route Handlers, Implement API Rate Limiting in Next.js Applications
5. Real-time Features#
Real-time Subscriptions#
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
export function RealtimeMessages({ channelId }: { channelId: string }) {
const [messages, setMessages] = useState<any[]>([])
const supabase = createClient()
useEffect(() => {
// Fetch initial messages
supabase
.from('messages')
.select('*')
.eq('channel_id', channelId)
.order('created_at', { ascending: true })
.then(({ data }) => setMessages(data || []))
// Subscribe to new messages
const channel = supabase
.channel(`messages:${channelId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `channel_id=eq.${channelId}`,
},
(payload) => {
setMessages((current) => [...current, payload.new])
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [channelId])
return (
<div>
{messages.map((message) => (
<div key={message.id}>{message.content}</div>
))}
</div>
)
}
Related: Implement Supabase Realtime Subscriptions in Next.js 15, Build Real-Time Collaboration Features with Supabase
6. File Storage#
Upload Files to Supabase Storage#
'use client'
import { createClient } from '@/lib/supabase/client'
import { useState } from 'react'
export function FileUpload() {
const [uploading, setUploading] = useState(false)
const supabase = createClient()
async function uploadFile(event: React.ChangeEvent<HTMLInputElement>) {
try {
setUploading(true)
if (!event.target.files || event.target.files.length === 0) {
throw new Error('You must select a file to upload.')
}
const file = event.target.files[0]
const fileExt = file.name.split('.').pop()
const fileName = `${Math.random()}.${fileExt}`
const filePath = `${fileName}`
const { error: uploadError } = await supabase.storage
.from('uploads')
.upload(filePath, file)
if (uploadError) {
throw uploadError
}
// Get public URL
const { data } = supabase.storage
.from('uploads')
.getPublicUrl(filePath)
console.log('File uploaded:', data.publicUrl)
} catch (error) {
alert('Error uploading file!')
console.log(error)
} finally {
setUploading(false)
}
}
return (
<div>
<input
type="file"
onChange={uploadFile}
disabled={uploading}
/>
</div>
)
}
Related: Build File Upload with Progress Bar Supabase Next.js, Supabase Storage CDN Configuration for Next.js
7. Deployment and Scaling#
Deploy to Vercel#
## Install Vercel CLI
npm i -g vercel
## Deploy
vercel --prod
Environment Variables#
Set these in Vercel dashboard:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEY
Database Migrations#
## Install Supabase CLI
npm install supabase --save-dev
## Create migration
npx supabase migration new add_organizations
## Apply migrations
npx supabase db push
Related: Deploy Next.js Supabase App to Vercel Production, Handle Database Migrations in Production Supabase
8. Monitoring and Optimization#
Error Tracking with Sentry#
// sentry.client.config.ts
import * as Sentry from "@sentry/nextjs"
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
})
Performance Monitoring#
// lib/analytics.ts
export function trackEvent(name: string, properties?: Record<string, any>) {
if (typeof window !== 'undefined') {
// Track with your analytics provider
console.log('Event:', name, properties)
}
}
Related: Monitor Next.js Application Performance in Production, Set Up Error Tracking with Sentry in Next.js
9. Common Pitfalls and Solutions#
Session Management Issues#
Problem: Auth session not persisting after refresh
Solution: Use server-side session management with cookies
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export function createClient() {
const cookieStore = cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
},
}
)
}
RLS Policy Errors#
Problem: Users can't access their own data
Solution: Check RLS policies and ensure auth.uid() is used correctly
-- Debug RLS policies
SELECT * FROM pg_policies WHERE tablename = 'organizations';
-- Test as specific user
SET request.jwt.claim.sub = 'user-uuid-here';
SELECT * FROM organizations;
Performance Issues#
Problem: Slow database queries
Solution: Add indexes and optimize queries
-- Add index on foreign keys
CREATE INDEX idx_organization_members_user_id
ON organization_members(user_id);
CREATE INDEX idx_organization_members_org_id
ON organization_members(organization_id);
10. Next Steps#
Now that you have a solid foundation, explore these advanced topics:
- Implement Optimistic UI Updates in Next.js with Supabase
- Add Full-Text Search to Next.js App with Supabase
- Build Advanced Filtering System with Supabase
- Implement Vector Search with Supabase pgvector
- Add AI-Powered Features to Your SaaS
Frequently Asked Questions (FAQ)#
What is the best tech stack for building a SaaS application?#
Next.js 15 with Supabase is an excellent choice for SaaS applications. Next.js provides server-side rendering, API routes, and optimized performance, while Supabase offers PostgreSQL database, authentication, real-time capabilities, and storage—all without managing infrastructure.
How do I implement multi-tenancy in Supabase?#
Create an organizations table and organization_members junction table. Use Row Level Security (RLS) policies to ensure users only access data from organizations they belong to. Filter queries using organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid()).
Should I use Server Components or Client Components for my SaaS?#
Use Server Components by default for better performance and smaller bundle sizes. Only use Client Components when you need interactivity (forms, buttons), React hooks (useState, useEffect), or browser APIs. This can reduce your JavaScript bundle by 50%+.
How do I handle user authentication in a SaaS application?#
Use Supabase Auth with email/password or social OAuth providers. Implement middleware to protect routes, create server and client Supabase clients for different contexts, and use RLS policies to secure data access at the database level.
What's the best way to structure my database for SaaS?#
Design for multi-tenancy from the start with organizations, users, and organization_members tables. Enable RLS on all tables, create proper indexes on foreign keys, and use database functions for complex operations that need transactions.
How do I implement role-based access control (RBAC)?#
Create a roles table with permissions stored as JSONB, a user_roles junction table, and database functions to check permissions. Use these functions in RLS policies: CREATE POLICY ... USING (has_permission('resource.action')).
Should I use API routes or Server Actions?#
Use Server Actions for form submissions and mutations—they're simpler and more performant. Use API routes when you need RESTful endpoints for external integrations, webhooks, or when you need fine-grained control over HTTP responses.
How do I handle file uploads in my SaaS?#
Use Supabase Storage with the Next.js file upload API. Create storage buckets with RLS policies, implement upload progress tracking, and use Supabase's image transformation API for optimized delivery. Store file metadata in your database.
What's the best deployment platform for Next.js + Supabase?#
Vercel is the recommended platform—it's built by the Next.js team, offers zero-config deployment, automatic HTTPS, preview deployments, and edge network distribution. The free tier is generous for indie developers and startups.
How do I implement real-time features?#
Use Supabase Realtime to subscribe to database changes, presence tracking for online users, and broadcast channels for ephemeral data like typing indicators. Always clean up subscriptions when components unmount to prevent memory leaks.
Should I use TypeScript for my SaaS application?#
Yes, TypeScript catches errors at compile time, provides better IDE support, and makes refactoring safer. Generate types from your Supabase database with npx supabase gen types typescript to keep your types in sync with your schema.
How do I handle database migrations in production?#
Use Supabase CLI to create and manage migrations. Test migrations in staging first, use transactions for data migrations, and always create rollback migrations. Never modify the database schema directly in production.
Related Articles#
- Next.js Performance Optimization for Indie Developers
- Supabase Authentication & Authorization Patterns
- Deploying Next.js + Supabase to Production
- AI Integration for Next.js + Supabase Applications
Conclusion#
Building a SaaS application with Next.js and Supabase gives you a powerful, scalable foundation. Focus on shipping features quickly, iterate based on user feedback, and scale as you grow.
The combination of Next.js's performance and Supabase's backend capabilities means you can build production-ready applications without managing infrastructure—perfect for indie developers and small teams.
Start building today, and remember: ship fast, iterate faster.