DEV Community

Konstantin
Konstantin

Posted on

How I built subdomain-based multi-tenancy with Next.js 14, Supabase RLS, and Cloudflare — for free

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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).*)'],
}
Enter fullscreen mode Exit fullscreen mode

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)
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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()
    )
  );
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

The onboarding flow

When a new business registers, they pick their slug:

What's your business URL?
[ salon-maya ] .trypronto.app
Enter fullscreen mode Exit fullscreen mode

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`)
Enter fullscreen mode Exit fullscreen mode

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'
})
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)