What We're Building
A multi-tenant SaaS platform where each organization is an isolated tenant. Users belong to one or more organizations via memberships, each with a role. Inside an organization, teams manage projects, invite teammates, and pay via Stripe subscriptions. An internal admin panel lets your team manage customers without touching production data directly.
Core entities
- Users (auth via Supabase)
- Organizations (tenants)
- Memberships (user ↔ org + role)
- Invitations (pending members)
- Projects (org-scoped resources)
- Subscriptions (Stripe-linked billing)
Cross-cutting concerns
- Tenant context on every request
- Permission checks at middleware + RLS
- Subdomain or slug-based routing
- Server Actions for mutations
- Audit logs for admin actions
organization_id column on every tenant-scoped table, enforced by PostgreSQL Row Level Security. This is how Notion, Linear, and most modern SaaS products scale — one database, strict isolation at the query layer.┌─────────────────────────────────────────────────────────┐ │ Next.js App │ ├──────────────┬──────────────────┬─────────────────────────┤ │ Marketing │ App (tenant) │ Admin Panel │ │ (public) │ acme.app.com │ admin.app.com │ ├──────────────┴──────────────────┴─────────────────────────┤ │ Middleware → resolve tenant → check auth → permissions │ ├───────────────────────────────────────────────────────────┤ │ Server Actions / Server Components / Route Handlers │ ├───────────────────────────────────────────────────────────┤ │ Supabase (Postgres + Auth + RLS) │ Stripe Webhooks │ └───────────────────────────────────────────────────────────┘
1. Project Setup
Start with Next.js 15 App Router, TypeScript, Tailwind, and Supabase. Stripe handles billing. The stack mirrors what production SaaS teams actually ship.
npx create-next-app@latest saas-app --typescript --app --tailwind cd saas-app npm install @supabase/supabase-js @supabase/ssr stripe @stripe/stripe-js npm install -D @types/stripe
Environment variables:
# Supabase NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key SUPABASE_SERVICE_ROLE_KEY=your-service-role-key # Stripe STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... # App NEXT_PUBLIC_APP_URL=https://app.yourdomain.com NEXT_PUBLIC_ROOT_DOMAIN=yourdomain.com
Recommended folder structure:
app/
(marketing)/ # Public pages — landing, pricing
page.tsx
pricing/page.tsx
(auth)/ # Login, register, invite accept
login/page.tsx
invite/[token]/page.tsx
(app)/ # Tenant-scoped app
[orgSlug]/
layout.tsx # Org context + sidebar
projects/
page.tsx
[projectId]/page.tsx
settings/
members/page.tsx
billing/page.tsx
(admin)/ # Internal admin panel
admin/
organizations/page.tsx
users/page.tsx
api/
webhooks/stripe/route.ts
lib/
supabase/ # client, server, middleware helpers
stripe/ # Stripe client + helpers
permissions/ # Role → permission mapping
tenant/ # Tenant resolution utilities
middleware.ts
types/
database.ts # Generated Supabase types2. Database Design
The schema is the foundation. Every tenant-scoped table carries an organization_id foreign key. Roles live in a separate table so you can add custom roles later without schema migrations.
Core tables
-- Organizations (tenants) CREATE TABLE organizations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, -- acme → acme.yourdomain.com logo_url TEXT, stripe_customer_id TEXT UNIQUE, plan TEXT NOT NULL DEFAULT 'free', -- free | pro | enterprise created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); -- Profiles (extends Supabase auth.users) CREATE TABLE profiles ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, email TEXT NOT NULL, full_name TEXT, avatar_url TEXT, created_at TIMESTAMPTZ DEFAULT now() ); -- Memberships (user ↔ organization with role) CREATE TABLE memberships ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'member', -- owner | admin | member | viewer created_at TIMESTAMPTZ DEFAULT now(), UNIQUE (user_id, organization_id) ); -- Invitations (pending team members) CREATE TABLE invitations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, email TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', token TEXT NOT NULL UNIQUE, invited_by UUID NOT NULL REFERENCES profiles(id), expires_at TIMESTAMPTZ NOT NULL, accepted_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() ); -- Projects (org-scoped resources) CREATE TABLE projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, name TEXT NOT NULL, description TEXT, status TEXT NOT NULL DEFAULT 'active', created_by UUID NOT NULL REFERENCES profiles(id), created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); -- Subscriptions (Stripe sync) CREATE TABLE subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL UNIQUE REFERENCES organizations(id) ON DELETE CASCADE, stripe_subscription_id TEXT UNIQUE, stripe_price_id TEXT, status TEXT NOT NULL DEFAULT 'inactive', current_period_end TIMESTAMPTZ, cancel_at_period_end BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() );
Indexes for performance
CREATE INDEX idx_memberships_user ON memberships(user_id); CREATE INDEX idx_memberships_org ON memberships(organization_id); CREATE INDEX idx_projects_org ON projects(organization_id); CREATE INDEX idx_invitations_token ON invitations(token); CREATE INDEX idx_invitations_email ON invitations(email); CREATE INDEX idx_organizations_slug ON organizations(slug);
Keep tenant identity in one place: organizations. Every query filters by organization_id. Never pass org IDs from the client without verifying membership server-side.
3. Row Level Security
RLS is your last line of defense. Even if application code has a bug, Postgres refuses cross-tenant reads and writes. Enable RLS on every tenant-scoped table.
Helper function — get user's org IDs
CREATE OR REPLACE FUNCTION auth.user_org_ids() RETURNS SETOF UUID AS $$ SELECT organization_id FROM memberships WHERE user_id = auth.uid() $$ LANGUAGE sql STABLE SECURITY DEFINER;
RLS policies
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE invitations ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
-- Organizations: members can read their orgs
CREATE POLICY "Members read own orgs"
ON organizations FOR SELECT
USING (id IN (SELECT auth.user_org_ids()));
-- Organizations: owners/admins can update
CREATE POLICY "Admins update org"
ON organizations FOR UPDATE
USING (
id IN (
SELECT organization_id FROM memberships
WHERE user_id = auth.uid() AND role IN ('owner', 'admin')
)
);
-- Projects: members read org projects
CREATE POLICY "Members read projects"
ON projects FOR SELECT
USING (organization_id IN (SELECT auth.user_org_ids()));
-- Projects: members+ can insert
CREATE POLICY "Members create projects"
ON projects FOR INSERT
WITH CHECK (
organization_id IN (
SELECT organization_id FROM memberships
WHERE user_id = auth.uid() AND role IN ('owner', 'admin', 'member')
)
);
-- Invitations: admins manage
CREATE POLICY "Admins manage invitations"
ON invitations FOR ALL
USING (
organization_id IN (
SELECT organization_id FROM memberships
WHERE user_id = auth.uid() AND role IN ('owner', 'admin')
)
);4. Route Groups
Next.js route groups (folders wrapped in parentheses) let you organize layouts without affecting the URL. Each group gets its own layout, middleware matcher, and auth requirements.
| Route group | URL example | Layout purpose |
|---|---|---|
(marketing) | /pricing | Public navbar, no auth |
(auth) | /login | Minimal auth layout |
(app) | /acme/projects | Org sidebar, tenant context |
(admin) | /admin/organizations | Internal ops dashboard |
Org layout (app/(app)/[orgSlug]/layout.tsx)
import { createClient } from "@/lib/supabase/server";
import { redirect, notFound } from "next/navigation";
import { OrgSidebar } from "@/components/OrgSidebar";
export default async function OrgLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ orgSlug: string }>;
}) {
const { orgSlug } = await params;
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: org } = await supabase
.from("organizations")
.select("id, name, slug, plan, memberships!inner(role)")
.eq("slug", orgSlug)
.eq("memberships.user_id", user.id)
.single();
if (!org) notFound();
return (
<div className="flex min-h-screen">
<OrgSidebar org={org} role={org.memberships[0].role} />
<main className="flex-1 p-6">{children}</main>
</div>
);
}5. Middleware
Middleware runs before every request. It refreshes auth sessions, resolves the current tenant from subdomain or path slug, and redirects unauthenticated users. This is where Notion-style workspace routing begins.
import { NextResponse, type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN!;
const ADMIN_HOST = `admin.${ROOT_DOMAIN}`;
export async function middleware(request: NextRequest) {
const url = request.nextUrl;
const hostname = request.headers.get("host") ?? "";
const subdomain = hostname.replace(`.${ROOT_DOMAIN}`, "").replace(ROOT_DOMAIN, "");
// Refresh Supabase session
let response = await updateSession(request);
// Admin panel — separate subdomain
if (hostname.startsWith("admin.")) {
return handleAdminRoute(request, response);
}
// Tenant subdomain: acme.yourdomain.com → rewrite to /acme/...
if (subdomain && subdomain !== "www" && subdomain !== "app") {
const path = url.pathname === "/" ? "/projects" : url.pathname;
return NextResponse.rewrite(
new URL(`/${subdomain}${path}`, request.url)
);
}
return response;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|api/webhooks|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};For path-based routing (e.g. /acme/projects), skip subdomain rewriting and let the [orgSlug] dynamic segment handle tenant resolution in the layout instead.
6. Dynamic Subdomains
Subdomain routing (acme.yourdomain.com) gives each organization a branded workspace — how Slack and Notion feel. It requires DNS wildcard records and platform configuration.
DNS setup
- Add a wildcard CNAME:
*.yourdomain.com → your-app.vercel.app - Add the root domain and
admin.yourdomain.comseparately - On Vercel: add
*.yourdomain.comas a domain in project settings
Tenant resolution utility
// lib/tenant/resolve.ts
import { headers } from "next/headers";
export async function getTenantSlug(): Promise<string | null> {
const host = (await headers()).get("host") ?? "";
const root = process.env.NEXT_PUBLIC_ROOT_DOMAIN!;
// Subdomain: acme.yourdomain.com
if (host.endsWith(`.${root}`) && !host.startsWith("admin.")) {
return host.replace(`.${root}`, "");
}
return null; // Fall back to path-based [orgSlug]
}
export async function getOrgBySlug(slug: string) {
const supabase = await createClient();
return supabase
.from("organizations")
.select("*")
.eq("slug", slug)
.single();
}7. Workspaces & Organizations
When a user signs up, create their profile and first organization in one transaction. The signup flow is the entry point to your multi-tenant system.
Create organization Server Action
"use server";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 32);
}
export async function createOrganization(formData: FormData) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
const name = formData.get("name") as string;
const slug = slugify(name);
const { data: org, error } = await supabase
.from("organizations")
.insert({ name, slug })
.select("id, slug")
.single();
if (error) return { error: error.message };
await supabase.from("memberships").insert({
user_id: user.id,
organization_id: org.id,
role: "owner",
});
revalidatePath("/");
redirect(`/${org.slug}/projects`);
}Organization switcher
Users in multiple orgs need a switcher in the sidebar. Fetch all memberships server-side and render links to each org's workspace. Store the last-active org in a cookie for faster redirects after login.
// components/OrgSwitcher.tsx — Server Component
const { data: memberships } = await supabase
.from("memberships")
.select("role, organizations(id, name, slug, logo_url)")
.eq("user_id", user.id);
// Render dropdown with links to /{org.slug}/projects8. Roles & Permissions
Roles define what a member can do inside an organization. Keep permissions in code (not the database) for v1 — migrate to a permissions table when customers need custom roles.
// lib/permissions/index.ts
export const ROLES = ["owner", "admin", "member", "viewer"] as const;
export type Role = (typeof ROLES)[number];
export const PERMISSIONS = {
"org:update": ["owner", "admin"],
"org:delete": ["owner"],
"member:invite": ["owner", "admin"],
"member:remove": ["owner", "admin"],
"member:change_role":["owner"],
"project:create": ["owner", "admin", "member"],
"project:delete": ["owner", "admin"],
"project:read": ["owner", "admin", "member", "viewer"],
"billing:manage": ["owner"],
} as const;
export type Permission = keyof typeof PERMISSIONS;
export function hasPermission(role: Role, permission: Permission): boolean {
return (PERMISSIONS[permission] as readonly Role[]).includes(role);
}
export function requirePermission(role: Role, permission: Permission) {
if (!hasPermission(role, permission)) {
throw new Error(`Forbidden: requires ${permission}`);
}
}Use permissions in Server Actions before any mutation:
export async function deleteProject(projectId: string, orgSlug: string) {
const { role } = await getMembership(orgSlug);
requirePermission(role, "project:delete");
const supabase = await createClient();
await supabase.from("projects").delete().eq("id", projectId);
revalidatePath(`/${orgSlug}/projects`);
}| Role | Typical use | Billing |
|---|---|---|
| Owner | Founder, account creator | Full billing access |
| Admin | Team lead, IT admin | View only |
| Member | Day-to-day contributor | None |
| Viewer | Stakeholder, client | None |
9. Team Invitations
Invitations are the growth loop of every SaaS product. An admin sends an invite, the recipient clicks a link, and they land in the workspace — existing user or new signup.
Send invitation Server Action
"use server";
import { randomBytes } from "crypto";
import { sendInviteEmail } from "@/lib/email";
export async function inviteMember(
orgId: string,
email: string,
role: Role
) {
const { user, membership } = await getAuthContext(orgId);
requirePermission(membership.role, "member:invite");
const token = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const supabase = await createClient();
const { error } = await supabase.from("invitations").insert({
organization_id: orgId,
email: email.toLowerCase(),
role,
token,
invited_by: user.id,
expires_at: expiresAt.toISOString(),
});
if (error) return { error: error.message };
await sendInviteEmail({
to: email,
inviteUrl: `${process.env.NEXT_PUBLIC_APP_URL}/invite/${token}`,
orgName: membership.organization.name,
});
return { success: true };
}Accept invitation
export async function acceptInvitation(token: string) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect(`/login?redirect=/invite/${token}`);
const { data: invite } = await supabase
.from("invitations")
.select("*")
.eq("token", token)
.is("accepted_at", null)
.gt("expires_at", new Date().toISOString())
.single();
if (!invite) return { error: "Invalid or expired invitation" };
if (invite.email !== user.email) {
return { error: "This invitation was sent to a different email" };
}
await supabase.from("memberships").insert({
user_id: user.id,
organization_id: invite.organization_id,
role: invite.role,
});
await supabase
.from("invitations")
.update({ accepted_at: new Date().toISOString() })
.eq("id", invite.id);
const { data: org } = await supabase
.from("organizations")
.select("slug")
.eq("id", invite.organization_id)
.single();
redirect(`/${org!.slug}/projects`);
}10. Projects
Projects are the primary resource inside a workspace. Every project query includes organization_id — never fetch projects without scoping to the current tenant.
// app/(app)/[orgSlug]/projects/page.tsx
export default async function ProjectsPage({
params,
}: {
params: Promise<{ orgSlug: string }>;
}) {
const { orgSlug } = await params;
const supabase = await createClient();
const { data: org } = await supabase
.from("organizations")
.select("id")
.eq("slug", orgSlug)
.single();
const { data: projects } = await supabase
.from("projects")
.select("id, name, status, created_at, profiles(full_name)")
.eq("organization_id", org!.id)
.order("created_at", { ascending: false });
return (
<div>
<header className="flex justify-between items-center mb-6">
<h1>Projects</h1>
<CreateProjectButton orgSlug={orgSlug} />
</header>
<ProjectList projects={projects ?? []} orgSlug={orgSlug} />
</div>
);
}Plan limits gate project creation. Before inserting, check the org's subscription plan and current project count:
const PLAN_LIMITS = { free: 3, pro: 50, enterprise: Infinity };
async function canCreateProject(orgId: string, plan: string) {
const { count } = await supabase
.from("projects")
.select("*", { count: "exact", head: true })
.eq("organization_id", orgId);
return (count ?? 0) < PLAN_LIMITS[plan as keyof typeof PLAN_LIMITS];
}11. Billing, Subscriptions & Stripe
Stripe handles payment processing. Your app stores a reference to the Stripe customer and subscription, then syncs state via webhooks. Never trust client-side payment confirmation.
Create checkout session
"use server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createCheckoutSession(orgId: string, priceId: string) {
const { membership, org } = await getAuthContext(orgId);
requirePermission(membership.role, "billing:manage");
let customerId = org.stripe_customer_id;
if (!customerId) {
const customer = await stripe.customers.create({
email: membership.user.email,
metadata: { organization_id: orgId },
});
customerId = customer.id;
await supabase
.from("organizations")
.update({ stripe_customer_id: customerId })
.eq("id", orgId);
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/${org.slug}/settings/billing?success=1`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/${org.slug}/settings/billing`,
metadata: { organization_id: orgId },
});
redirect(session.url!);
}Stripe webhook handler
// app/api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request) {
const body = await request.text();
const sig = (await headers()).get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch {
return new Response("Invalid signature", { status: 400 });
}
const supabase = createServiceClient(); // service role — bypasses RLS
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const orgId = session.metadata!.organization_id!;
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await supabase.from("subscriptions").upsert({
organization_id: orgId,
stripe_subscription_id: subscription.id,
stripe_price_id: subscription.items.data[0].price.id,
status: subscription.status,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
});
await supabase
.from("organizations")
.update({ plan: "pro" })
.eq("id", orgId);
break;
}
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
const status = sub.status === "active" ? "pro" : "free";
await supabase.from("subscriptions").update({
status: sub.status,
cancel_at_period_end: sub.cancel_at_period_end,
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
}).eq("stripe_subscription_id", sub.id);
// Look up org and downgrade plan if cancelled
break;
}
}
return new Response("OK", { status: 200 });
}https://yourdomain.com/api/webhooks/stripe.12. Admin Panel
Your internal team needs tools to manage customers without SQL access. The admin panel lives on a separate subdomain (admin.yourdomain.com) with its own auth gate — only users with an is_super_admin flag on their profile can access it.
Organizations
- Search, view, suspend orgs
- Override plan limits
- View member list
Users
- Search by email
- View org memberships
- Force password reset
Billing
- Subscription status
- Manual plan changes
- Refund triggers
Audit log
- Every admin action logged
- Actor, target, timestamp
- Immutable append-only
// Admin uses service role client — NEVER expose to browser
import { createServiceClient } from "@/lib/supabase/admin";
export async function suspendOrganization(orgId: string, reason: string) {
await requireSuperAdmin();
const supabase = createServiceClient();
await supabase
.from("organizations")
.update({ status: "suspended" })
.eq("id", orgId);
await supabase.from("admin_audit_log").insert({
action: "org.suspend",
target_id: orgId,
reason,
actor_id: (await getAdminUser()).id,
});
}13. Server Actions Pattern
All mutations — create org, invite member, create project, manage billing — go through Server Actions. They run on the server, validate permissions, and revalidate cached pages.
// lib/actions/context.ts — shared auth helper
export async function getAuthContext(orgSlug: string) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: membership } = await supabase
.from("memberships")
.select("role, organizations(id, name, slug, plan, stripe_customer_id)")
.eq("organizations.slug", orgSlug)
.eq("user_id", user.id)
.single();
if (!membership) throw new Error("Not a member of this organization");
return {
user,
role: membership.role as Role,
org: membership.organizations,
membership,
};
}
// Every action follows this pattern:
// 1. getAuthContext(orgSlug)
// 2. requirePermission(role, "...")
// 3. Validate input (zod)
// 4. Mutate via Supabase (RLS as backup)
// 5. revalidatePath(...)
// 6. Return result or redirect14. Tenant Isolation
Tenant isolation is enforced at three layers. If any one layer fails, the others still protect customer data.
Layer 1: Application
Every Server Action and query includes organization_id from verified membership — never from client input alone.
Layer 2: Middleware
Tenant slug resolved server-side. Unauthenticated or non-member requests redirected before page render.
Layer 3: RLS
Postgres policies reject any query that crosses tenant boundaries, even if application code has a bug.
Isolation checklist
- Never use org ID from URL params without membership check
- Service role key only in webhooks and admin — never in client bundle
- Stripe metadata includes
organization_idfor webhook routing - File storage paths prefixed with
{org_id}/ - Background jobs scoped to single org per job
- Audit log on every cross-tenant admin action
15. Production Deployment
Multi-tenant SaaS has deployment requirements beyond a standard Next.js app. Plan for wildcard domains, webhook endpoints, and environment separation.
Vercel configuration
// vercel.json
{
"rewrites": [
{
"source": "/:path*",
"has": [{ "type": "host", "value": "(?<subdomain>.*)\.yourdomain\.com" }],
"destination": "/:subdomain/:path*"
}
]
}Production checklist
- Wildcard DNS and SSL for
*.yourdomain.com - Stripe webhook registered for production URL
- Supabase RLS enabled and tested on all tables
- Service role key in server-only env vars
- Email provider configured (Resend, SendGrid) for invitations
- Separate Supabase projects for staging and production
- Rate limiting on invitation and signup endpoints
- Error monitoring (Sentry) with org context tags
On Render, configure custom domains with wildcard support and set NEXT_PUBLIC_ROOT_DOMAIN in environment variables. Stripe webhooks and Supabase connections work identically to Vercel.
16. How Top SaaS Apps Structure Multi-Tenancy
The architecture in this guide mirrors patterns used by industry-leading products. Understanding their models helps you make better design decisions.
| Product | Tenant model | Key pattern |
|---|---|---|
| Notion | Workspace | Pages nested under workspace; guest access via email invite |
| Linear | Organization | Teams within org; strict role hierarchy; subdomain per org |
| Slack | Workspace | Subdomain routing (acme.slack.com); channels scoped to workspace |
| ClickUp | Workspace | Spaces → Folders → Lists hierarchy; plan limits per workspace |
| Trello | Organization | Boards belong to orgs; free vs paid at org level |
Common patterns across all of them:
- Organization is the billing entity — subscriptions attach to the workspace, not individual users
- Roles are org-scoped — being an admin in one workspace doesn't grant admin in another
- Invitations are email-based — with expiring tokens and role assignment at invite time
- Resources never cross tenants — enforced at DB, API, and UI layers
- Plan limits gate features — project count, seats, storage checked server-side before mutations
17. Common Pitfalls
1. Trusting client-supplied org IDs
Never accept organizationId from form data without verifying the current user is a member. Always resolve org from slug + membership check.
2. Skipping RLS because "we check in code"
Application bugs happen. RLS is insurance. Enable it on every tenant table from day one — retrofitting RLS on a live database is painful.
3. Billing state only in Stripe
Cache subscription status in your database via webhooks. Rendering the billing page should not require a Stripe API call on every page load.
4. No plan enforcement server-side
Client-side "upgrade" banners are fine, but project creation, seat limits, and feature gates must be enforced in Server Actions — not just hidden in the UI.
5. Subdomain routing without reserved slugs
Block slugs like admin, api, www, app, and billing or your routing will break.
6. Orphaned Stripe customers
Always store stripe_customer_id on the organization when checkout starts. Create the customer before the checkout session, not after.
Need help building your multi-tenant SaaS?
We architect and ship SaaS platforms for startups and enterprises — from database design and Stripe integration to admin panels and production deployment. Share your product vision and we'll help you build it right the first time.