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 (0)