A production-grade guide to building multi-tenant SaaS with Antigravity IDE. Learn tenant isolation with Supabase Row Level Security, usage-based billing with Stripe Metered Billing, and fine-grained access control with RBAC — with full implementation code.
Setup and context: Why Multi-Tenant Architecture Is Hard to Get Right
When launching a SaaS product as an indie developer, multi-tenant architecture is often the first real technical wall you hit. A "tenant" is the unit of organization or company that uses your SaaS — each tenant needs an isolated data space while sharing the same underlying infrastructure.
Get tenant isolation wrong and you risk serious security incidents: data from one tenant leaking into another. Build fully isolated infrastructure per tenant and costs balloon out of control.
This guide covers how to build production-grade multi-tenant SaaS using Antigravity IDE, built on three pillars:
Supabase RLS — tenant isolation at the database layer
Stripe Metered Billing — usage-based pricing tied to API consumption
RBAC — role-based access control within organizations
This article targets developers who already understand Next.js and Supabase fundamentals and are ready to ship a real SaaS product.
Three Patterns for Multi-Tenant Architecture
Before writing a single line of code, it's worth understanding the three main patterns for multi-tenancy.
Pattern 1: Shared Database, Shared Schema (used in this guide)
All tenants share the same tables, differentiated by a tenant_id column. This is the most cost-effective approach and works beautifully with Supabase's Row Level Security. The vast majority of SaaS products don't need anything more than this.
Pattern 2: Shared Database, Separate Schemas
Each tenant gets its own PostgreSQL schema. Provides stronger data isolation but makes migrations significantly more complex.
Pattern 3: Separate Databases
Each tenant has a fully independent database. Offers the highest security guarantees but comes with substantial cost and operational overhead.
In this guide, we use Pattern 1 and achieve complete tenant isolation through RLS policies.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A production-ready design pattern that unifies tenant isolation, usage-based billing, and RBAC in a single schema
✦The RLS `auth.uid()` re-evaluation trap, and how wrapping it in a subquery cut a query from 470ms to 8ms
✦An idempotency-key design that stops Stripe metered double-counting, and why monthly resets should key off the billing period, not the invoice
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Open a new project in Antigravity and start with schema design. Hand the agent this prompt:
Design a database schema for a multi-tenant SaaS application.
Requirements:
- organizations table (the tenant entity)
- Many-to-many between users and organizations (membership)
- Roles: owner / admin / member
- All resources (projects, etc.) are linked via organization_id
- Optimized for Supabase RLS
The agent will generate a base schema like this:
-- organizations table (the tenant)CREATE TABLE organizations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, -- URL-friendly slug (e.g. "acme-corp") plan TEXT NOT NULL DEFAULT 'free', -- free / starter / pro / enterprise stripe_customer_id TEXT UNIQUE, stripe_subscription_id TEXT, api_usage_this_month INTEGER DEFAULT 0, -- monthly API call counter created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW());-- organization_members (links users to tenants)CREATE TABLE organization_members ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(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')), invited_by UUID REFERENCES auth.users(id), accepted_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(organization_id, user_id));-- projects table (example tenant-scoped resource)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, created_by UUID NOT NULL REFERENCES auth.users(id), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW());-- api_usage_logs (for Stripe metered billing)CREATE TABLE api_usage_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id), endpoint TEXT NOT NULL, tokens_used INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW());
Index Design
Don't skip indexing — these are essential for RLS performance:
-- Index every column used as an RLS filterCREATE INDEX idx_projects_org_id ON projects(organization_id);CREATE INDEX idx_api_usage_logs_org_id ON api_usage_logs(organization_id);CREATE INDEX idx_organization_members_user_id ON organization_members(user_id);CREATE INDEX idx_organization_members_org_id ON organization_members(organization_id);
Step 2: Tenant Isolation with Supabase RLS
Row Level Security is the backbone of tenant isolation. PostgreSQL's RLS automatically appends WHERE clauses to queries — meaning even if your application code has a bug, data cannot leak across tenants.
Prompt for Antigravity's agent to configure RLS:
Set up Supabase RLS policies for the following tables:
- organizations: visible only to members
- projects: full CRUD only for members of the owning tenant
- api_usage_logs: readable by tenant members, INSERT by any authenticated user (own records only)
- Tenant membership is determined via the organization_members table
Define Helper Functions First
Write DRY RLS policies by defining helper functions upfront:
-- Returns true if the current user is a member of the given orgCREATE OR REPLACE FUNCTION auth.is_org_member(org_id UUID)RETURNS BOOLEANLANGUAGE plpgsql SECURITY DEFINERAS $$BEGIN RETURN EXISTS ( SELECT 1 FROM organization_members WHERE organization_id = org_id AND user_id = auth.uid() AND accepted_at IS NOT NULL );END;$$;-- Returns the current user's role within a given orgCREATE OR REPLACE FUNCTION auth.get_org_role(org_id UUID)RETURNS TEXTLANGUAGE plpgsql SECURITY DEFINERAS $$DECLARE member_role TEXT;BEGIN SELECT role INTO member_role FROM organization_members WHERE organization_id = org_id AND user_id = auth.uid() AND accepted_at IS NOT NULL; RETURN member_role;END;$$;
RLS Policies for the projects Table
-- Enable RLSALTER TABLE projects ENABLE ROW LEVEL SECURITY;-- SELECT: tenant members onlyCREATE POLICY "projects_select_policy" ON projects FOR SELECT USING (auth.is_org_member(organization_id));-- INSERT: any tenant member (must set created_by to own UID)CREATE POLICY "projects_insert_policy" ON projects FOR INSERT WITH CHECK ( auth.is_org_member(organization_id) AND created_by = auth.uid() );-- UPDATE: admins and above onlyCREATE POLICY "projects_update_policy" ON projects FOR UPDATE USING ( auth.get_org_role(organization_id) IN ('owner', 'admin') );-- DELETE: owners onlyCREATE POLICY "projects_delete_policy" ON projects FOR DELETE USING ( auth.get_org_role(organization_id) = 'owner' );
Optimizing RLS Performance
Subqueries inside RLS policies run on every query, which can degrade performance under load. Use SECURITY DEFINER STABLE functions to benefit from PostgreSQL's intra-query caching:
-- Returns all org IDs the current user belongs to (cached within query)CREATE OR REPLACE FUNCTION auth.get_user_orgs()RETURNS UUID[]LANGUAGE plpgsql SECURITY DEFINER STABLEAS $$BEGIN RETURN ARRAY( SELECT organization_id FROM organization_members WHERE user_id = auth.uid() AND accepted_at IS NOT NULL );END;$$;
Step 3: Type-Safe RBAC Implementation
At the Next.js application layer, implement RBAC with full TypeScript type safety. Have Antigravity's agent generate this type definition file:
Usage-based pricing is a powerful differentiator for SaaS products. Stripe's Metered Billing lets you charge customers based on actual API call volume or AI token consumption each month.
Prompt for Antigravity's agent:
Help me implement Stripe Metered Billing.
Requirements:
- Usage-based plan: $2 per 1,000 API calls
- Monthly limits per plan (Free: 1,000 calls, Starter: 10,000 calls)
- Real-time usage tracking
- Automated monthly billing
Setting Up Stripe Products and Prices
// scripts/setup-stripe-products.tsimport Stripe from 'stripe';const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);async function setupMeteredBilling() { // Create the product const product = await stripe.products.create({ name: 'SaaS API Usage', description: 'Pay per API call', }); // Create the metered price const price = await stripe.prices.create({ product: product.id, currency: 'usd', recurring: { interval: 'month', usage_type: 'metered', // key: metered billing aggregate_usage: 'sum', // sum usage across the month }, unit_amount: 2, // $0.02 per unit transform_quantity: { divide_by: 1000, // 1,000 calls = 1 unit ($0.02) round: 'up', }, }); console.log('Price ID:', price.id); // set this in .env}
Using Antigravity's Planning Mode for Schema Migrations
As your product grows and schema changes become necessary, leverage Antigravity's Planning Mode:
Antigravity Planning Mode prompt:
I want to add a permissions field (JSONB) to organization_members
to support custom per-user permissions within a tenant.
Scope of impact:
1. Database migration
2. Updated RLS policies
3. Permission check logic in auth-guard.ts
4. Type definitions
5. Frontend UI
Please create a phased implementation plan.
Step 8: Team Invitation Flow
A complete multi-tenant SaaS needs a seamless way for owners and admins to invite new members. Here's a production-ready invitation system using Supabase and email notifications.
Invitation Table Schema
-- invitations table (tracks pending invites)CREATE TABLE organization_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 CHECK (role IN ('admin', 'member')), invited_by UUID NOT NULL REFERENCES auth.users(id), token TEXT NOT NULL UNIQUE DEFAULT encode(gen_random_bytes(32), 'hex'), expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '7 days'), accepted_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW());CREATE INDEX idx_invitations_token ON organization_invitations(token);CREATE INDEX idx_invitations_email ON organization_invitations(email);ALTER TABLE organization_invitations ENABLE ROW LEVEL SECURITY;-- Only org admins and owners can see and create invitationsCREATE POLICY "invitations_select_policy" ON organization_invitations FOR SELECT USING (auth.get_org_role(organization_id) IN ('owner', 'admin'));CREATE POLICY "invitations_insert_policy" ON organization_invitations FOR INSERT WITH CHECK ( auth.get_org_role(organization_id) IN ('owner', 'admin') AND invited_by = auth.uid() );
Sending Invitations (Server Action)
// src/app/actions/invite.ts'use server';import { withOrgAuth } from '@/lib/auth-guard';import { Resend } from 'resend';const resend = new Resend(process.env.RESEND_API_KEY!);export async function inviteMember( orgId: string, orgName: string, email: string, role: 'admin' | 'member') { return withOrgAuth(orgId, 'member', 'invite', async ({ userId }) => { const supabase = /* server client */; // Check for existing active invite const { data: existingInvite } = await supabase .from('organization_invitations') .select('id, expires_at') .eq('organization_id', orgId) .eq('email', email) .is('accepted_at', null) .gt('expires_at', new Date().toISOString()) .single(); if (existingInvite) { return { error: 'An active invitation already exists for this email.' }; } // Create the invitation record const { data: invitation, error } = await supabase .from('organization_invitations') .insert({ organization_id: orgId, email, role, invited_by: userId, }) .select('token') .single(); if (error) throw error; // Send invitation email via Resend const inviteUrl = `${process.env.NEXT_PUBLIC_APP_URL}/invite/${invitation.token}`; await resend.emails.send({ from: 'noreply@yoursaas.com', to: email, subject: `You've been invited to join ${orgName}`, html: ` <p>You've been invited to join <strong>${orgName}</strong> as a ${role}.</p> <p><a href="${inviteUrl}">Accept Invitation</a></p> <p>This invitation expires in 7 days.</p> `, }); return { success: true }; });}
Accepting Invitations
// src/app/invite/[token]/route.tsimport { NextRequest, NextResponse } from 'next/server';import { createServerClient } from '@supabase/ssr';import { cookies } from 'next/headers';import { redirect } from 'next/navigation';export async function GET( req: NextRequest, { params }: { params: { token: string } }) { const cookieStore = await cookies(); const supabase = createServerClient(/* ... */); // Validate the token const { data: invitation, error } = await supabase .from('organization_invitations') .select(` *, organizations(slug, name) `) .eq('token', params.token) .is('accepted_at', null) .gt('expires_at', new Date().toISOString()) .single(); if (error || !invitation) { redirect('/invite/expired'); } const { data: { user } } = await supabase.auth.getUser(); // If the user isn't signed in, redirect to sign up with the token preserved if (!user) { redirect(`/signup?invite=${params.token}`); } // Add user to the organization const { error: memberError } = await supabase .from('organization_members') .insert({ organization_id: invitation.organization_id, user_id: user.id, role: invitation.role, invited_by: invitation.invited_by, accepted_at: new Date().toISOString(), }); if (memberError) { // Handle case where user is already a member if (memberError.code === '23505') { redirect(`/dashboard/${invitation.organizations.slug}`); } redirect('/invite/error'); } // Mark invitation as accepted await supabase .from('organization_invitations') .update({ accepted_at: new Date().toISOString() }) .eq('token', params.token); redirect(`/dashboard/${invitation.organizations.slug}?joined=true`);}
Step 9: Per-Tenant API Key Management
Many SaaS products need to issue API keys to tenants so they can integrate with your service programmatically. Here's a secure implementation:
-- api_keys tableCREATE TABLE api_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, name TEXT NOT NULL, key_prefix TEXT NOT NULL, -- first 8 chars, for display (e.g. "sk_live_") key_hash TEXT NOT NULL UNIQUE, -- bcrypt hash of the full key created_by UUID NOT NULL REFERENCES auth.users(id), last_used_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, -- NULL = never expires created_at TIMESTAMPTZ DEFAULT NOW());ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;CREATE POLICY "api_keys_select" ON api_keys FOR SELECT USING (auth.is_org_member(organization_id));CREATE POLICY "api_keys_insert" ON api_keys FOR INSERT WITH CHECK ( auth.get_org_role(organization_id) IN ('owner', 'admin') AND created_by = auth.uid() );CREATE POLICY "api_keys_delete" ON api_keys FOR DELETE USING ( auth.get_org_role(organization_id) IN ('owner', 'admin') );
// src/app/actions/api-keys.ts'use server';import { withOrgAuth } from '@/lib/auth-guard';import { hash } from 'bcryptjs';import { randomBytes } from 'crypto';function generateApiKey(): { fullKey: string; prefix: string } { const secret = randomBytes(32).toString('base64url'); const fullKey = `sk_live_${secret}`; return { fullKey, prefix: fullKey.substring(0, 12) };}export async function createApiKey( orgId: string, name: string, expiresInDays?: number) { return withOrgAuth(orgId, 'api_key', 'create', async ({ userId }) => { const supabase = /* server client */; const { fullKey, prefix } = generateApiKey(); // Hash the key before storing — never store raw keys const keyHash = await hash(fullKey, 12); const expiresAt = expiresInDays ? new Date(Date.now() + expiresInDays * 86400 * 1000).toISOString() : null; const { data, error } = await supabase .from('api_keys') .insert({ organization_id: orgId, name, key_prefix: prefix, key_hash: keyHash, created_by: userId, expires_at: expiresAt, }) .select('id, key_prefix, name, created_at, expires_at') .single(); if (error) throw error; // Return the full key ONCE — it can never be retrieved again return { ...data, fullKey }; });}
The critical security principle here: store only the hash, return the plaintext key only once at creation time. This mirrors how services like GitHub and Stripe handle API keys.
What the Docs Don't Tell You: Lessons From Production
Everything above is code that works. This section is about the problems that only surface once real billing traffic flows through it. I learned these by running Stripe memberships (a monthly Pro plan and a lifetime Premium license) across four sites in Dolice Labs — issues invisible at design time that show up only in production.
Pitfall 1: auth.uid() inside an RLS policy is re-evaluated per row
The RLS policies from Step 2 are correct, but once a table grew past tens of thousands of rows the dashboard suddenly slowed to a crawl. The cause: auth.uid() inside the policy is re-evaluated for every row. PostgreSQL's planner doesn't treat it as stable, so it calls the function on each row scan.
The fix is to wrap the call in a subquery so it evaluates once.
-- ❌ Before: auth.uid() evaluated per row (measured 470ms / 50k rows)CREATE POLICY "members can read projects"ON projects FOR SELECTUSING ( organization_id IN ( SELECT organization_id FROM organization_members WHERE user_id = auth.uid() ));-- ✅ After: (SELECT auth.uid()) becomes an initplan, evaluated once (measured 8ms, same data)CREATE POLICY "members can read projects"ON projects FOR SELECTUSING ( organization_id IN ( SELECT organization_id FROM organization_members WHERE user_id = (SELECT auth.uid()) ));
Just wrapping it as (SELECT auth.uid()) took the same tenant filter on 50k rows from 470ms down to 8ms — roughly a 60x improvement. Supabase later added this to their performance guide, but the introductory RLS tutorials don't mention it. I lost a whole evening to it, chasing a missing index that wasn't the problem.
The usage-recording API from Step 4 can send the same usage to Stripe twice — on retries, double-clicks, or cross-region replays on Vercel. Because metered billing increments (action: 'increment'), a duplicate send inflates the bill directly, and you won't notice unless you read the logs.
The fix is to issue an idempotency key per usage event and reject duplicates on both Stripe's side and your own DB.
// Attach a stable idempotency key to each usage eventconst idempotencyKey = `usage_${orgId}_${endpoint}_${requestId}`;// 1) Try recording in your own DB first; a UNIQUE constraint rejects duplicatesconst { error: dupErr } = await supabase .from('api_usage_logs') .insert({ organization_id: orgId, request_id: requestId, /* ... */ });if (dupErr?.code === '23505') { // Already recorded. Don't send to Stripe; succeed idempotently. return NextResponse.json({ success: true, deduped: true });}// 2) Pass the same idempotencyKey to Stripe (defense in depth)await stripe.subscriptionItems.createUsageRecord( subscriptionItem.id, { quantity: Math.ceil(tokensUsed / 100), action: 'increment' }, { idempotencyKey });
Add a UNIQUE constraint on request_id in api_usage_logs, and pass idempotencyKey as the third argument to the Stripe SDK. After this two-layer guard, my monthly billing discrepancy dropped to zero. Stripe's idempotency keys expire after 24 hours, so the DB UNIQUE constraint is your last line of defense.
Pitfall 3: Tying the monthly reset to invoice.payment_succeeded drifts by a day
The Step 4 webhook resets api_usage_this_month on invoice.payment_succeeded, but that depends on when payment settles. Payment retries or bank-side delays shifted the reset hours — sometimes a full day — past the billing boundary. To the user, it looks like "it's the first of the month but last month's counter is still there."
In practice, base the usage decision itself on the subscription's current_period_start. Instead of relying on a reset event, aggregate api_usage_logs from current_period_start onward and payment delays no longer matter. I eventually settled on a two-layer model: the counter is a cache for fast UI rendering, while the billing decision is always the period-based aggregate as the source of truth.
Production checklist
Before going live, confirm at least these.
Have you replaced every auth.uid() in RLS policies with (SELECT auth.uid())?
Are there indexes on organization_id and every RLS filter column?
Does the usage-recording API have both an idempotency key and a UNIQUE constraint?
Is the billing decision a period-based aggregate (not dependent on a counter reset)?
Does customer.subscription.deleted immediately downgrade the plan and gate features?
Have you scoped exactly where the service-role webhook bypasses RLS?
Items 1 and 3 are the kind of bugs that "work, but break by quiet creep." Running an app business with 50M+ cumulative downloads taught me how long a small billing oversight lingers. Usage-based SaaS billing is, I think, a domain where that fragility only intensifies.
Summary
Building multi-tenant SaaS from scratch is genuinely complex, but Antigravity's agent capabilities dramatically cut the time spent on trial and error — from schema design to RLS policy generation to Stripe integration code.
The three pillars of this guide:
Supabase RLS — automatic tenant isolation at the database layer; protects against application-layer bugs
Stripe Metered Billing — charge based on actual usage, making revenue grow naturally with your customers
RBAC — type-safe authorization that protects both Server Actions and UI from unauthorized access
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.