# What You'll Build#
This guide is an end-to-end blueprint for a production SaaS using the Next.js App Router and Supabase for Auth and Postgres, with Row Level Security for tenant isolation and Stripe subscriptions for billing.
You’ll get concrete architecture decisions, a folder structure you can copy, a tenant-ready data model, example RLS policies, and the pitfalls that most “starter kits” ignore.
If you want a deeper comparison of auth approaches in Next.js, see our Next.js authentication guide. For a broader multi-tenant strategy discussion, read our multi-tenant SaaS architecture guide. For billing implementation details, use our Stripe subscriptions guide.
# Architecture Overview: The Production Baseline#
A solid Next.js Supabase SaaS starter architecture has one primary job: prevent cross-tenant data access even when the UI and API are wrong. That’s why the baseline is:
- Supabase Auth for identity and session management
- Postgres + RLS for authorization and tenant isolation
- Next.js App Router for server-first rendering and server actions
- Stripe for billing, attached to the tenant entity (organization workspace)
- Webhooks as the source of truth for subscription status
High-level request flow#
| Scenario | Next.js layer | Supabase access | Security boundary |
|---|---|---|---|
| Public pages | Server components | No DB | None needed |
| Auth pages | Client components | Auth endpoints | Rate limiting, email verification rules |
| Tenant app pages | Server components | Server client using user session | RLS enforces tenant isolation |
| Mutations | Server actions or route handlers | Server client | RLS plus input validation |
| Stripe webhooks | Route handler | Service role for controlled writes | Verify Stripe signature, no user session |
🎯 Key Takeaway: Assume application code will fail at some point. RLS must still prevent any tenant data leak.
Why App Router matters here#
App Router encourages moving data fetching to the server and reduces the number of public APIs you need to expose. That directly reduces your attack surface, but only if you avoid the common trap of using a service role key anywhere user-facing.
# Prerequisites and Baseline Stack#
| Requirement | Version | Notes |
|---|---|---|
| Next.js | 15+ | App Router, server actions |
| Node.js | 20+ | LTS recommended |
| Supabase | Latest | Auth, Postgres, Storage, Edge Functions optional |
| Stripe | API 2024-xx | Use webhooks and a billing portal |
| Postgres | Supabase-managed | RLS enabled on tenant tables |
For analytics and email, keep them decoupled. Use PostHog, Segment, or Plausible for analytics and a provider like Resend for transactional email, but don’t let them drive authorization decisions.
# Repository and Folder Structure (Copy-Paste Blueprint)#
This structure splits concerns: UI, auth, tenant routing, data access, and Stripe billing.
| Path | Purpose | Notes |
|---|---|---|
app/(public)/ | Marketing pages | No auth required |
app/(auth)/ | Sign in, sign up, callback | Minimal UI logic |
app/(app)/[workspaceSlug]/ | Tenant app routes | Workspace resolved on server |
app/api/webhooks/stripe/route.ts | Stripe webhooks | Service role writes allowed |
app/api/health/route.ts | Health check | Useful for uptime monitoring |
components/ | Shared UI components | Keep them server-safe by default |
lib/supabase/ | Supabase clients | Separate browser vs server vs admin |
lib/auth/ | Session helpers | Avoid duplicating logic |
lib/billing/ | Stripe helpers | Centralize plan logic |
lib/tenancy/ | Workspace resolution | workspaceSlug to workspace_id |
db/migrations/ | SQL migrations | Treat as source of truth |
db/seed/ | Seed scripts | Only for local/staging |
types/ | Shared types | Consider generating from DB |
Minimal Supabase client setup#
Keep three clients: browser, server (user session), and admin (service role). Only admin can bypass RLS and must never be used in user request paths.
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
export function supabaseServerClient(cookiesStore: any) {
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookiesStore.getAll(),
setAll: (cookies: any) => cookies.forEach((c: any) => cookiesStore.set(c)),
},
}
);
}// lib/supabase/admin.ts
import { createClient } from "@supabase/supabase-js";
export const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);⚠️ Warning: Never instantiate the admin client in any file that can be imported by client components. A single accidental import can leak privileged access into the browser bundle.
# Multi-Tenancy Model: Choose Workspace-First#
For B2B SaaS, a user can belong to multiple workspaces. Billing and permissions typically attach to the workspace.
Recommended entities#
| Entity | Why it exists | Key columns |
|---|---|---|
workspaces | Tenant boundary | id, slug, name, created_by |
workspace_members | Membership + role | workspace_id, user_id, role |
profiles | User metadata | id, full_name, avatar_url |
subscriptions | Billing state | workspace_id, stripe_customer_id, stripe_subscription_id, status, price_id |
| Domain tables | Your product data | Must include workspace_id |
Data model decisions that save pain later#
- 1Always include
workspace_idon tenant data. Even if the table “belongs to a project” that belongs to a workspace, keepworkspace_idfor simpler RLS and faster queries. - 2Use UUID PKs everywhere. Supabase defaults are solid; avoid leaking sequential IDs.
- 3Use a
slugfor routing. Route byworkspaceSlug, resolve toworkspace_idserver-side. - 4Roles should be explicit. Use
owner,admin,member, and treat permissions as a function of role.
# Database Schema: SQL You Can Start With#
This is the core schema for a Next.js Supabase SaaS starter architecture with tenant isolation.
Core tables#
-- db/migrations/001_core.sql
create table public.workspaces (
id uuid primary key default gen_random_uuid(),
slug text unique not null,
name text not null,
created_by uuid not null references auth.users(id),
created_at timestamptz not null default now()
);
create table public.workspace_members (
workspace_id uuid not null references public.workspaces(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text not null check (role in ('owner', 'admin', 'member')),
created_at timestamptz not null default now(),
primary key (workspace_id, user_id)
);
create table public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
full_name text,
avatar_url text,
created_at timestamptz not null default now()
);
create table public.subscriptions (
workspace_id uuid primary key references public.workspaces(id) on delete cascade,
stripe_customer_id text unique,
stripe_subscription_id text unique,
status text not null default 'inactive',
price_id text,
current_period_end timestamptz,
updated_at timestamptz not null default now()
);A sample tenant-owned domain table#
create table public.projects (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null references public.workspaces(id) on delete cascade,
name text not null,
created_by uuid not null references auth.users(id),
created_at timestamptz not null default now()
);
create index projects_workspace_id_idx on public.projects(workspace_id);ℹ️ Note: That index on
workspace_idis not optional. Without it, your RLS-protected queries degrade fast as tenants grow, because every query must evaluate membership checks.
# RLS: The Policies That Actually Prevent Leaks#
RLS is not “nice to have”. It’s the mechanism that stops accidental data exposure from any client bug, misrouted request, or overly broad select.
Enable RLS and create a membership helper#
Use a stable function to centralize membership logic. Keep it security definer carefully and always scope it to auth.uid().
alter table public.workspaces enable row level security;
alter table public.workspace_members enable row level security;
alter table public.projects enable row level security;
alter table public.subscriptions enable row level security;
create or replace function public.is_workspace_member(w_id uuid)
returns boolean
language sql
stable
as $$
select exists (
select 1
from public.workspace_members wm
where wm.workspace_id = w_id
and wm.user_id = auth.uid()
);
$$;Workspace visibility policies#
create policy "workspaces_select_for_members"
on public.workspaces
for select
using (public.is_workspace_member(id));
create policy "workspaces_insert_for_authenticated"
on public.workspaces
for insert
with check (auth.uid() = created_by);Membership policies#
create policy "members_select_self_workspaces"
on public.workspace_members
for select
using (user_id = auth.uid());
create policy "members_insert_owner_only"
on public.workspace_members
for insert
with check (
exists (
select 1
from public.workspace_members wm
where wm.workspace_id = workspace_id
and wm.user_id = auth.uid()
and wm.role = 'owner'
)
);Tenant table policies (projects)#
create policy "projects_select_member"
on public.projects
for select
using (public.is_workspace_member(workspace_id));
create policy "projects_insert_member"
on public.projects
for insert
with check (
public.is_workspace_member(workspace_id)
and created_by = auth.uid()
);
create policy "projects_update_admin"
on public.projects
for update
using (
public.is_workspace_member(workspace_id)
)
with check (
exists (
select 1
from public.workspace_members wm
where wm.workspace_id = workspace_id
and wm.user_id = auth.uid()
and wm.role in ('owner', 'admin')
)
);Subscription table policies#
Most apps should allow members to read subscription status, but only owners should manage billing actions. Writes typically come from Stripe webhooks using the service role.
create policy "subscriptions_select_member"
on public.subscriptions
for select
using (public.is_workspace_member(workspace_id));Operationally: avoid letting the client write to subscriptions. Treat Stripe as source of truth.
💡 Tip: Start with fewer write policies and add them only when you have a clear product requirement. Most security incidents come from overly permissive insert and update policies “just to make it work”.
# Workspace Routing and Context in Next.js App Router#
Your tenant routes should look like:
/acme/dashboard/acme/projects/acme/settings/billing
Use [workspaceSlug] and resolve it server-side to a workspace row the user is allowed to access. If they’re not a member, return notFound().
Server-only workspace resolver#
// lib/tenancy/getWorkspace.ts
import { cookies } from "next/headers";
import { supabaseServerClient } from "@/lib/supabase/server";
export async function getWorkspaceBySlug(workspaceSlug: string) {
const supabase = supabaseServerClient(cookies());
const { data, error } = await supabase
.from("workspaces")
.select("id, slug, name")
.eq("slug", workspaceSlug)
.single();
if (error) return null;
return data;
}Layout gating for tenant routes#
// app/(app)/[workspaceSlug]/layout.tsx
import { notFound } from "next/navigation";
import { getWorkspaceBySlug } from "@/lib/tenancy/getWorkspace";
export default async function WorkspaceLayout(props: {
children: React.ReactNode;
params: Promise<{ workspaceSlug: string }>;
}) {
const { workspaceSlug } = await props.params;
const workspace = await getWorkspaceBySlug(workspaceSlug);
if (!workspace) notFound();
return props.children;
}Why this matters: you avoid duplicating access checks in every page. RLS still enforces it, but your UX is cleaner and you reduce wasted queries.
# Auth: Production Defaults That Avoid Edge Cases#
Supabase Auth is typically enough, but you need a few production rules:
- Decide whether your SaaS requires verified email to access tenant data.
- Store user profile metadata in
profilesand treatauth.usersas identity only. - Handle session refresh correctly in App Router.
For a broader decision framework and tradeoffs with NextAuth and Clerk, see Next.js authentication guide.
Post-signup bootstrap flow#
A reliable SaaS flow is:
- 1User signs up.
- 2Create
profilesrow. - 3Create a default
workspace. - 4Add the user as
ownerinworkspace_members. - 5Redirect to
/{workspaceSlug}/dashboard.
Do steps 2 to 4 on the server to avoid race conditions and partially created tenants.
# Billing: Stripe Attached to Workspace, Not User#
Your billing lifecycle is a set of state transitions driven by Stripe webhooks. In multi-tenancy, the subscription belongs to workspace_id.
What to store (minimum viable)#
| Column | Source | Why it matters |
|---|---|---|
stripe_customer_id | Stripe | Lookup customer quickly |
stripe_subscription_id | Stripe | Unique subscription identity |
status | Stripe | Gate access to paid features |
price_id | Stripe | Plan tier logic |
current_period_end | Stripe | Grace periods, renewal UI |
Avoid using the client to set status. If you do, you will eventually create a path where a malicious client can unlock features.
Stripe webhook route handler#
Keep it small: verify signature, parse event, upsert subscription state by workspace_id metadata.
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
import { supabaseAdmin } from "@/lib/supabase/admin";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-12-18" });
export async function POST(req: Request) {
const body = await req.text();
const sig = (await headers()).get("stripe-signature") as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
if (event.type === "customer.subscription.updated" || event.type === "customer.subscription.created") {
const sub = event.data.object as Stripe.Subscription;
const workspaceId = (sub.metadata?.workspace_id || null) as string | null;
if (workspaceId) {
await supabaseAdmin.from("subscriptions").upsert({
workspace_id: workspaceId,
stripe_customer_id: String(sub.customer),
stripe_subscription_id: sub.id,
status: sub.status,
price_id: sub.items.data[0]?.price?.id ?? null,
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
updated_at: new Date().toISOString(),
});
}
}
return new Response("ok", { status: 200 });
}To implement checkout session creation, billing portal, and handling upgrades and cancellations, follow our dedicated guide: Next.js Stripe subscriptions SaaS billing.
# Access Control in the App: Combine RLS With Feature Gating#
RLS controls data access. Feature gating controls product access, like “only paid workspaces can create more than 3 projects”.
Do both:
- RLS prevents data leakage.
- Server-side gating prevents abuse of resources and enforces plan limits.
Example: enforce plan limit in a server action#
Keep limits server-side to prevent client bypass. Use a single query for current usage and a single insert.
// lib/projects/createProject.ts
import { cookies } from "next/headers";
import { supabaseServerClient } from "@/lib/supabase/server";
export async function createProject(workspaceId: string, name: string) {
const supabase = supabaseServerClient(cookies());
const { count } = await supabase
.from("projects")
.select("id", { count: "exact", head: true })
.eq("workspace_id", workspaceId);
const limit = 3;
if ((count ?? 0) >= limit) {
return { ok: false, error: "Project limit reached. Upgrade to create more." };
}
const { error } = await supabase.from("projects").insert({
workspace_id: workspaceId,
name,
created_by: (await supabase.auth.getUser()).data.user?.id,
});
if (error) return { ok: false, error: error.message };
return { ok: true };
}This action still relies on RLS to ensure the user can only insert into a workspace they belong to.
# Common Pitfalls (and How to Avoid Them)#
These are the issues that most often break production SaaS apps using Next.js and Supabase.
Pitfall 1: Using service role in user request paths#
If your API route uses the service role key, you have effectively disabled RLS for that route. One bug becomes a full tenant data breach.
Fix: service role only in webhooks, background jobs, and admin-only maintenance.
Pitfall 2: Missing workspace_id on domain tables#
You can enforce tenant isolation through joins, but your RLS becomes complex and query performance suffers. Teams end up weakening policies to keep shipping.
Fix: include workspace_id on every tenant-owned table and index it.
Pitfall 3: RLS policies that work in dev but fail in production#
Common causes:
- Using
auth.uid()insidesecurity definerfunctions incorrectly - Forgetting
with checkfor inserts and updates - Testing only with the SQL editor, not with real user sessions
Fix: build a minimal policy test checklist for every table. At a minimum, confirm that a user from workspace A cannot read or write workspace B.
Pitfall 4: Workspace slug enumeration#
If your routing is /[workspaceSlug] and you return different error states for “exists but forbidden” vs “does not exist”, you create an enumeration side-channel.
Fix: return the same response shape. In Next.js, notFound() for both cases is usually fine.
Pitfall 5: Billing state drift#
If you set subscription status in your app after checkout, you’ll see mismatches when a payment fails, a card expires, or a dispute occurs. Stripe webhooks are the only reliable signal.
Fix: update subscriptions only from webhooks, and handle past_due and unpaid explicitly.
# Deployment and Operations Checklist#
This is the minimum you should put in place before calling it “production”.
| Area | Checklist item | Why it matters |
|---|---|---|
| Secrets | Separate env per environment | Prevent accidental prod writes |
| DB | Migrations in CI | Avoid schema drift |
| RLS | Enabled on all tenant tables | Prevent data leaks |
| Webhooks | Signature verification | Prevent forged events |
| Observability | Log webhook failures | Billing bugs become revenue loss |
| Backups | Point-in-time recovery | Reduce blast radius of mistakes |
A practical metric: IBM’s widely cited Cost of a Data Breach report pegs average breach cost in the millions of USD. You don’t need enterprise tooling to reduce risk, but you do need strict RLS and safe key handling.
# Key Takeaways#
- Model multi-tenancy around workspaces and attach billing to workspaces, not users.
- Put
workspace_idon every tenant-owned table, index it, and enforce isolation with RLS. - Use three Supabase clients and never use the service role in any user-facing code path.
- Resolve
workspaceSlugserver-side in App Router layouts and returnnotFound()when access fails. - Treat Stripe webhooks as the source of truth for subscription status and persist it in a dedicated
subscriptionstable.
# Conclusion#
A production-ready Next.js Supabase SaaS starter architecture is less about wiring up pages and more about enforcing tenant isolation, keeping billing state consistent, and preventing privileged access from leaking into user code.
If you want Samioda to help you implement this blueprint end-to-end, including RLS review, Stripe billing, and a scalable App Router codebase, contact us and we’ll turn your SaaS requirements into a secure, shippable foundation.
FAQ
More in Web Development
All →Feature Flags & A/B Testing in Next.js: Architecture, Tooling, and Safe Rollouts (2026 Guide)
A practical guide to implementing Next.js feature flags and A/B testing with server and client evaluation, Edge runtime considerations, analytics, and team rollout playbooks.
React Query vs SWR in Next.js App Router: When to Use Which (and How to Avoid Double Fetching)
A practical 2026 comparison of React Query and SWR inside Next.js App Router — caching models, SSR and RSC compatibility, mutations, optimistic updates, DX, and proven patterns to prevent double fetching.
Next.js File Uploads Done Right: Direct-to-S3 and Cloudflare R2 with Presigned URLs, Validation, and Security
A practical 2026 guide to building secure, reliable direct-to-object-storage uploads in Next.js App Router using presigned URLs, server-side validation, retry handling, and optional antivirus scanning.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Next.js Multitenant SaaS Architecture: Tenancy Models, Routing, Auth, and Data Isolation (2026 Guide)
A practical guide to Next.js multitenant SaaS architecture: tenancy models, tenant-aware routing with App Router and middleware, auth patterns, and hardening data isolation to prevent leaks.
Next.js Authentication in 2026: NextAuth vs Clerk vs Supabase (What We Use for Client Projects)
A practical comparison of Next.js authentication options in 2026 — NextAuth, Clerk, and Supabase — across UX, security, cost, setup time, and enterprise requirements, with decision matrices for SaaS, internal tools, and B2B portals.
Implementing Stripe Subscriptions in Next.js: Billing Portal, Webhooks, and Entitlements
A production-ready guide to Next.js Stripe subscriptions: plans and trials, Billing Portal, webhook verification, idempotent processing, entitlement mapping, and Stripe CLI testing.