How to Build a SaaS App with Next.js and Supabase in 2026
Building a Software-as-a-Service application from scratch in 2026 is more accessible than ever — but it still requires careful architectural decisions. Get the foundation wrong and you will spend months untangling technical debt. In this guide, I walk you through the complete production-ready stack I use: Next.js 16 App Router, Supabase, Stripe, and Vercel.
This is based on real production experience — I built MediLab Pro, a medical laboratory management SaaS, using exactly this stack.
Why This Stack?
Before diving in, let me explain why these specific tools:
| Tool | Role | Why |
|---|---|---|
| Next.js 16 | Frontend + API | App Router, RSC, edge functions, built-in SEO |
| Supabase | Auth + Database + Storage | Postgres, row-level security, real-time |
| Stripe | Payments | Industry standard, excellent webhooks |
| Vercel | Deployment | Zero-config, edge network, preview deployments |
This stack covers 95% of SaaS requirements with minimal glue code.
1. Project Architecture
Good SaaS architecture separates concerns clearly from day one. Here is the folder structure I use:
/app
/(marketing) # Public pages — landing, pricing, blog
/(app) # Authenticated app routes
/dashboard
/settings
/billing
/api # API routes and webhooks
/components
/ui # Reusable UI primitives
/marketing # Landing page components
/app # App-specific components
/lib
/supabase.ts # Supabase server client
/stripe.ts # Stripe client
/auth.ts # Auth helpers
/types # TypeScript interfaces
This structure makes it immediately clear whether a file belongs to the public site or the authenticated app.
2. Database Design with Supabase
Supabase gives you a full Postgres database with Row Level Security (RLS). RLS is the killer feature — it enforces data isolation at the database level, not in your application code.
-- Organizations table (multi-tenancy)
CREATE TABLE organizations (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
name text NOT NULL,
slug text UNIQUE NOT NULL,
created_at timestamptz DEFAULT now()
);
-- Users belong to organizations
CREATE TABLE memberships (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid REFERENCES auth.users NOT NULL,
organization_id uuid REFERENCES organizations NOT NULL,
role text NOT NULL DEFAULT 'member', -- admin | member | viewer
created_at timestamptz DEFAULT now(),
UNIQUE(user_id, organization_id)
);
-- RLS: users can only see their organization's data
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Members can view their organization"
ON organizations FOR SELECT
USING (id IN (
SELECT organization_id FROM memberships WHERE user_id = auth.uid()
));
Key RLS principle: Write your policies to ask "does the current user have permission to see this row?" — the database enforces this automatically on every query.
3. Authentication with Supabase Auth
Supabase Auth handles the entire authentication flow out of the box — email/password, magic links, Google OAuth, and more.
// lib/supabase.ts — server-side client (Next.js App Router)
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cs) => cs.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
),
},
}
)
}
Protect routes using Next.js middleware:
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
export async function middleware(request) {
const supabase = createServerClient(/* ... */)
const { data: { user } } = await supabase.auth.getUser()
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
4. Multi-Tenancy and Organizations
Most SaaS apps need organizations (teams/workspaces). The pattern I use:
- User signs up and is redirected to an onboarding flow
- Onboarding creates an
organizationand amembershiprecord - All subsequent data is scoped to
organization_id - RLS policies enforce tenant isolation at the database level
This approach scales from 1 user to thousands of organizations without architectural changes.
5. Subscription Billing with Stripe
Stripe is the standard for SaaS billing. The integration has three parts:
Checkout — Create a Stripe Checkout session server-side:
// app/api/billing/create-checkout/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const { priceId, customerId } = await req.json()
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
})
return Response.json({ url: session.url })
}
Webhooks — Handle subscription events reliably:
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const sig = req.headers.get('stripe-signature')!
const body = await req.text()
const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
await updateSubscriptionStatus(event.data.object)
break
case 'customer.subscription.deleted':
await cancelSubscription(event.data.object)
break
}
return Response.json({ received: true })
}
Customer Portal — Let users manage their own subscriptions:
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_URL}/settings`,
})
redirect(session.url)
6. Feature Flags and Plan Limits
Every SaaS needs to enforce plan limits. Store plan features in your database and check them server-side:
// lib/limits.ts
const PLAN_LIMITS = {
free: { users: 1, storage_gb: 1, api_calls: 1000 },
pro: { users: 10, storage_gb: 20, api_calls: 50000 },
scale: { users: -1, storage_gb: -1, api_calls: -1 }, // unlimited
}
export async function checkLimit(orgId: string, feature: keyof typeof PLAN_LIMITS.free) {
const org = await getOrganizationWithPlan(orgId)
const limit = PLAN_LIMITS[org.plan][feature]
if (limit === -1) return true // unlimited
const current = await getCurrentUsage(orgId, feature)
return current < limit
}
7. Deployment and Environment Management
Deploy to Vercel with these environment variables:
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
NEXT_PUBLIC_URL=https://yourapp.com
Use Vercel's preview deployments for every pull request — this gives you a unique URL to test changes before merging.
Conclusion
This stack — Next.js, Supabase, Stripe, Vercel — gives you a production-ready SaaS foundation in days rather than months. Supabase's Row Level Security eliminates entire classes of data leakage bugs. Next.js App Router provides the perfect server/client component model for SaaS dashboards. Stripe handles billing complexity so you can focus on your product.
The most important advice: launch early, charge from day one, and let real user feedback guide your architecture decisions. The best SaaS architecture is the one that gets paying customers, not the most technically elegant one.