Web Development
Next.jsSupabaseSaaSApp RouterPostgreSQLRLSStripeMulti-tenancyArchitecture

Next.js + Supabase SaaS Starter Architecture (App Router): Auth, RLS, Billing, and Multi-Tenancy

AO
Adrijan Omićević
·15 min read

# 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#

ScenarioNext.js layerSupabase accessSecurity boundary
Public pagesServer componentsNo DBNone needed
Auth pagesClient componentsAuth endpointsRate limiting, email verification rules
Tenant app pagesServer componentsServer client using user sessionRLS enforces tenant isolation
MutationsServer actions or route handlersServer clientRLS plus input validation
Stripe webhooksRoute handlerService role for controlled writesVerify 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#

RequirementVersionNotes
Next.js15+App Router, server actions
Node.js20+LTS recommended
SupabaseLatestAuth, Postgres, Storage, Edge Functions optional
StripeAPI 2024-xxUse webhooks and a billing portal
PostgresSupabase-managedRLS 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.

PathPurposeNotes
app/(public)/Marketing pagesNo auth required
app/(auth)/Sign in, sign up, callbackMinimal UI logic
app/(app)/[workspaceSlug]/Tenant app routesWorkspace resolved on server
app/api/webhooks/stripe/route.tsStripe webhooksService role writes allowed
app/api/health/route.tsHealth checkUseful for uptime monitoring
components/Shared UI componentsKeep them server-safe by default
lib/supabase/Supabase clientsSeparate browser vs server vs admin
lib/auth/Session helpersAvoid duplicating logic
lib/billing/Stripe helpersCentralize plan logic
lib/tenancy/Workspace resolutionworkspaceSlug to workspace_id
db/migrations/SQL migrationsTreat as source of truth
db/seed/Seed scriptsOnly for local/staging
types/Shared typesConsider 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.

TypeScript
// 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)),
      },
    }
  );
}
TypeScript
// 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.

EntityWhy it existsKey columns
workspacesTenant boundaryid, slug, name, created_by
workspace_membersMembership + roleworkspace_id, user_id, role
profilesUser metadataid, full_name, avatar_url
subscriptionsBilling stateworkspace_id, stripe_customer_id, stripe_subscription_id, status, price_id
Domain tablesYour product dataMust include workspace_id

Data model decisions that save pain later#

  1. 1
    Always include workspace_id on tenant data. Even if the table “belongs to a project” that belongs to a workspace, keep workspace_id for simpler RLS and faster queries.
  2. 2
    Use UUID PKs everywhere. Supabase defaults are solid; avoid leaking sequential IDs.
  3. 3
    Use a slug for routing. Route by workspaceSlug, resolve to workspace_id server-side.
  4. 4
    Roles 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#

SQL
-- 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#

SQL
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_id is 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().

SQL
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#

SQL
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#

SQL
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)#

SQL
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.

SQL
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#

TypeScript
// 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#

TypeScript
// 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 profiles and treat auth.users as 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:

  1. 1
    User signs up.
  2. 2
    Create profiles row.
  3. 3
    Create a default workspace.
  4. 4
    Add the user as owner in workspace_members.
  5. 5
    Redirect 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)#

ColumnSourceWhy it matters
stripe_customer_idStripeLookup customer quickly
stripe_subscription_idStripeUnique subscription identity
statusStripeGate access to paid features
price_idStripePlan tier logic
current_period_endStripeGrace 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.

TypeScript
// 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.

TypeScript
// 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() inside security definer functions incorrectly
  • Forgetting with check for 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”.

AreaChecklist itemWhy it matters
SecretsSeparate env per environmentPrevent accidental prod writes
DBMigrations in CIAvoid schema drift
RLSEnabled on all tenant tablesPrevent data leaks
WebhooksSignature verificationPrevent forged events
ObservabilityLog webhook failuresBilling bugs become revenue loss
BackupsPoint-in-time recoveryReduce 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_id on 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 workspaceSlug server-side in App Router layouts and return notFound() when access fails.
  • Treat Stripe webhooks as the source of truth for subscription status and persist it in a dedicated subscriptions table.

# 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

Share
A
Adrijan OmićevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.