Multi-tenancy is one of those things that sounds simple until you're three hours deep into middleware, wildcard DNS, and Row Level Security policies wondering where it all went wrong.
I built it for Pronto — an open-source POS, CRM, and booking system for service businesses. Every business that signs up gets their own subdomain: salon-maya.trypronto.app. Fully isolated. One codebase.
Here's exactly how I did it, what broke, and what I'd do differently.
The architecture in one diagram
Client request: salon-maya.trypronto.app
↓
Cloudflare (wildcard *.trypronto.app → DigitalOcean)
↓
Next.js Middleware (extract slug from hostname)
↓
Supabase RLS (row-level isolation per business_id)
↓
Tenant data
Three layers. Each one solves a different problem.
Layer 1: Cloudflare wildcard DNS
This is the easiest part. In your Cloudflare dashboard, add one DNS record:
Type: A
Name: *
Content: your-server-ip
Proxy: ✅ (orange cloud)
One record handles every subdomain. salon-maya, barbershop-joe, cafe-lima — all routed to the same server automatically.
Then enable Universal SSL and make sure it covers wildcard *.yourdomain.com. Cloudflare's free plan does this.
That's it for DNS. Cost: $0.
Layer 2: Next.js Middleware — extracting the tenant
Every request needs to know which tenant it belongs to. Middleware runs before any page renders, which makes it the right place for this.
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || ''
const baseDomain = process.env.NEXT_PUBLIC_BASE_DOMAIN || 'trypronto.app'
// salon-maya.trypronto.app → slug = "salon-maya"
const slug = hostname.replace(`.${baseDomain}`, '')
// Skip if it's the root domain
if (slug === baseDomain || slug === 'www') {
return NextResponse.next()
}
// Pass slug to the request via header
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-tenant-slug', slug)
return NextResponse.next({
request: { headers: requestHeaders }
})
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
Then in any Server Component or API route, read it back:
import { headers } from 'next/headers'
export default async function Page() {
const headersList = headers()
const slug = headersList.get('x-tenant-slug')
const business = await getBusinessBySlug(slug)
// ...
}
Layer 3: Supabase RLS — the real isolation
Passing a slug through headers is fine for routing, but if your database queries don't enforce tenant isolation at the DB level, you have a security problem.
Supabase Row Level Security fixes this.
Every tenant table has a business_id column:
create table businesses (
id uuid primary key default gen_random_uuid(),
slug text unique not null,
name text not null,
owner_id uuid references auth.users(id)
);
create table appointments (
id uuid primary key default gen_random_uuid(),
business_id uuid references businesses(id) not null,
client_name text,
starts_at timestamptz
);
Enable RLS and add a policy:
alter table appointments enable row level security;
create policy "appointments_business_isolation"
on appointments
using (
business_id = (
select id from businesses
where owner_id = auth.uid()
)
);
Now it's physically impossible for one tenant to read another tenant's data, even if there's a bug in your application layer.
Migration files — keep it simple:
I use numbered SQL files (001_initial.sql, 002_add_rls.sql) and a Node script that applies them in order on startup. No ORM magic — just SQL tracked in git.
// scripts/migrate.js
const migrations = await fs.readdir('./supabase/migrations')
const sorted = migrations.sort() // 001_, 002_, etc.
for (const file of sorted) {
// Check if already applied, skip if so
// Execute if new
}
The onboarding flow
When a new business registers, they pick their slug:
What's your business URL?
[ salon-maya ] .trypronto.app
On submit:
// Validate slug is URL-safe and available
const slug = input.toLowerCase().replace(/[^a-z0-9-]/g, '')
const existing = await supabase
.from('businesses')
.select('id')
.eq('slug', slug)
.single()
if (existing.data) {
return { error: 'This URL is already taken' }
}
// Create the business record
const { data: business } = await supabase
.from('businesses')
.insert({ slug, name: businessName, owner_id: userId })
.select()
.single()
// Redirect to their new subdomain
redirect(`https://${slug}.trypronto.app/onboarding`)
No DNS configuration needed on the customer's side. The wildcard handles it instantly.
What broke (and how I fixed it)
Problem 1: Cookies don't cross subdomains by default.
salon-maya.trypronto.app and trypronto.app are different origins. A session cookie set on the root domain won't be readable on the subdomain.
Fix — set the cookie domain with a leading dot:
response.cookies.set('sb-token', token, {
domain: '.trypronto.app', // note the leading dot
httpOnly: true,
secure: true,
sameSite: 'lax'
})
Problem 2: Middleware fired on static assets.
Without the matcher config, middleware runs on every _next/static/... request and adds latency to every image and JS chunk. The matcher I showed above fixes this.
Problem 3: Double-booking at the application layer.
Two concurrent requests could both pass the "is this slot free?" check before either wrote to the database. Fixed with a PostgreSQL trigger:
create or replace function prevent_double_booking()
returns trigger as $$
begin
if exists (
select 1 from appointments
where business_id = NEW.business_id
and employee_id IS NOT DISTINCT FROM NEW.employee_id
and status != 'cancelled'
and tstzrange(starts_at, ends_at) && tstzrange(NEW.starts_at, NEW.ends_at)
and id != NEW.id
) then
raise exception 'SLOT_CONFLICT' using errcode = 'P0001';
end if;
return NEW;
end;
$$ language plpgsql;
The API returns HTTP 409 on conflict. No race condition possible.
Custom domains (bonus)
For customers who want booking.their-salon.com instead of a subdomain, Cloudflare for SaaS handles this. The first 100 custom domains are free.
The customer adds a CNAME pointing to your domain, you verify ownership, Cloudflare provisions the SSL certificate automatically. From your app's perspective, it's the same middleware pattern — just match on the full hostname instead of extracting a slug.
The full cost breakdown
| Layer | Tool | Cost |
|---|---|---|
| Wildcard DNS + SSL | Cloudflare | $0 |
| Subdomain routing | Next.js middleware | $0 |
| Tenant isolation | Supabase RLS | $0 |
| Custom domains | Cloudflare for SaaS | $0 (first 100) |
| Hosting | DigitalOcean | ~$20/mo |
The entire multi-tenancy infrastructure costs $20/month — just the server.
The code is open source
Pronto is MIT-licensed. If you're building something similar — a SaaS for service businesses, a booking system, anything with subdomain tenancy — the full implementation is there to read.
⭐ github.com/SGrappelli/pronto
Live demo: trypronto.app
Questions about any layer? Drop them in the comments.
Top comments (4)
We do same multi-tenancy shape (FastAPI + Supabase RLS) on Equip for multiple Bible schools sharing one instance. One thing that compounded: deny-by-default policies + a single shared "is_school_member(school_id)" function instead of inline EXISTS in every policy. Easier to audit when adding new tables. github.com/ArVaViT/equip for reference.
Thanks for sharing this — just checked the Equip repo, clean setup.
We're doing inline EXISTS (SELECT 1 FROM businesses WHERE id = business_id AND owner_id = auth.uid()) in most policies right now. Your point about auditability is spot on — when you add a new table, it's easy to miss a subtle difference in the inline check vs what you intended.
One question: do you hit any performance difference with the function vs inline EXISTS? Postgres should inline a STABLE function, but curious if you've seen it in practice at any meaningful scale.
Will look at extracting an is_tenant_member() equivalent before v1.5 — good timing since we're adding a few new tables then.
On perf: SQL-language functions marked STABLE inline at plan time. EXPLAIN comes out identical to inline EXISTS. As long as
is_tenant_member()islanguage sql stable, single SELECT, no SETOF, you get zero overhead.The actual footgun is PL/pgSQL. Those don't inline. Planner sees an opaque box, can't push predicates into the body, can't reorder joins.
Same logical query, way worse plan, and you only notice when the table grows past a few thousand rows.
Bigger RLS perf trap we hit before any function refactor: raw
auth.uid()vs(SELECT auth.uid()). The bare call gets re-evaluated perrow. The subquery gets hoisted to a single init plan. Our
rls_perf_cleanup_016migration was 76 policies rewritten to the subquery form.It's in the Supabase RLS perf guide but easy to miss when you're writing policies by hand.
@prontodev, if you go function-based for v1.5 keep it
language sql stableand you'll match inline EXISTS in EXPLAIN. The wins are on theauth.uid() side, not the helper-function side.
The auth.uid() vs (SELECT auth.uid()) thing is embarrassing to admit I didn't know — just grepped the migrations and yes, we have bare auth.uid() calls in several policies. Adding a cleanup migration to the v1.5 list now.
The PL/pgSQL footgun is a good warning for when we extract the helper function — language sql stable, single SELECT, noted.
76 policies rewritten in one migration sounds painful. Did you catch it from EXPLAIN output or did something in prod actually slow down first?