Skip to content
← Back to notes

Multi-tenant subdomains on Vercel without losing your weekend

Hundreds of branded tenant subdomains from a single Next.js deployment. The four pieces that make it work, and the two mistakes we made the first time.

Next.jsSaaSEngineering

CombatScore runs hundreds of branded subdomains — crowfootbjj.combatscore.app,renzogracieokc.combatscore.app, and so on — from a single Next.js deployment. Same code, same deploy, hundreds of apparent tenants. This is a pattern Vercel makes surprisingly clean if you understand the four pieces. The first time we set it up it took a weekend; now it takes an hour.

The four pieces

  1. A wildcard domain on Vercel. *.combatscore.app is added once as a Vercel domain. Every subdomain under it is automatically routed to the same project, with valid TLS, with no per-tenant configuration.
  2. Middleware to extract the tenant. A Next.js middleware file inspects thehost header on every request, extracts the subdomain, and rewrites the request to a tenant-aware route — typically /[tenant]/.... The user's browser still seescrowfootbjj.combatscore.app/dashboard; the framework sees /crowfootbjj/dashboard.
  3. A tenant lookup keyed on subdomain.A row in Postgres (or Supabase, or wherever) keyed by subdomain. The tenant page reads it once, caches it, and renders the gym's logo, colors, and configuration.
  4. Row-level security in the database. The most important piece. Every row in the database that belongs to a tenant has a tenant_id column, and the database enforces — at the SQL level — that authenticated users can only see rows where tenant_idmatches their session's tenant. If your auth library lets you forget this, change libraries.

The middleware, in concrete terms

The middleware is short. Read request.headers.get("host"), strip the apex domain, match what's left against your tenant table (or just pass it through and let the page resolve it), rewrite the URL with NextResponse.rewrite. Don't redirect — the user-visible URL should stay the subdomain. Add the tenant slug to a request header so server components can read it without re-parsing.

The lookup, cached correctly

Tenant configuration changes rarely — a logo update once a quarter, a color change once a year. Cache aggressively. Next.js' built-in fetch caching with a long TTL is fine, orunstable_cachewith a tag you bump on tenant-config update. What you don't want is a database read on every request, multiplied by every server component that needs the tenant.

The mistakes we made the first time

Two mistakes worth flagging.

We did tenant routing in the page, not in middleware. Each route checked the host header in generateMetadataor in the layout. It worked. It also meant the routing logic was scattered across the codebase, and updating it meant grepping for "host" everywhere. Middleware is the right place because it runs once per request and produces a clean rewritten URL for everything downstream.

We trusted the application layer to enforce tenant scoping in queries. Every query had awhere tenant_id = Xclause. Eventually we wrote one that didn't. Postgres row-level security would have caught it; we didn't have RLS enabled. Now we do. The lesson: treat application scoping as the convenience, treat the database's RLS as the security boundary. Not the other way around.

What this lets you offer clients

The architecture is what makes "every gym gets its own branded subdomain" a one-line value proposition instead of a per-tenant integration project. Onboarding a new gym is inserting a row, not a deploy. Updating a logo is a tenant-config edit, not a build. The studio can offer something that looks like custom software at the cost of a SaaS row. That's the entire reason multi-tenant architectures exist; getting them right is mostly about respecting the four pieces above and not skipping the unsexy one (RLS).