Infrastructure
Custom domains for every tenant with Vercel for Platforms
How Bookatu lets each tenant point their own domain at their storefront: a flag-gated routing proxy, the Vercel Domains API behind a wrapper that never throws, and a TTL cache that keeps the common request free.
Bookatu is a multi-tenant booking platform. Every tenant gets a storefront at a slug like bookatu.com/aria-hair. That works, but a real business wants its own name on the door: ariahair.example.com, not someone else's domain with their slug bolted on the end. Pixieset does this for photographers, Shopify does it for shops, and our tenants kept asking for it. The catch is that the storefront is one Next.js app serving thousands of tenants. A per-tenant domain can't mean a per-tenant deploy.
The other constraint was blast radius. The routing change runs on every matched request across the whole app. Get it wrong and we don't break custom domains, we break booking for everyone. So the feature had to be inert until we deliberately turned it on, and it had to cost nothing on the 99.9% of traffic that isn't a custom domain. This post walks through how that's wired in the code: a flag-gated proxy, the Vercel Domains API behind a wrapper that never throws, and a resolver cache that keeps the common case free.
The gist
- One Next.js app serves every tenant. A proxy rewrites an inbound custom host onto that tenant's existing /{slug} storefront path. No per-tenant routes, no per-tenant deploys.
- The whole thing sits behind a feature flag. When off (the default), the first line of the proxy returns a pass-through before it even reads the Host header.
- The Vercel Domains API wrapper never throws and degrades to a not_configured status when the token is absent, so dev, CI and tests stay green.
- A cross-request TTL cache (positive and negative) means a custom-domain request hits the DB at most once a minute, and random Host headers can't amplify into DB load.
The routing trick: rewrite, don't re-route
The whole feature rests on one idea. The storefront already lives at app/[org]/(site)/**, served at /{slug}/.... A custom domain needs no new pages. It needs the inbound Host header turned into a slug, and the request rewritten onto the existing path. Next 16 renamed middleware.ts to proxy.ts, and that's where this happens. A rewrite, not a redirect, means the URL bar still shows the tenant's domain, and rewrites don't re-run the proxy, so there's no loop to guard against.
// Flag OFF -> pass-through. No Host read, no DB, zero routing change.
if (!customDomainsEnabled) return NextResponse.next();
// Platform / preview / local hosts -> fast-path, no DB cost.
const host = (req.headers.get("host") || "").split(":")[0].toLowerCase();
if (!host || isPlatformHost(host)) return NextResponse.next();
// Resolve the custom host to an org slug. Only 'active' domains resolve.
const slug = await getOrgByCustomDomain(host);
if (!slug) return NextResponse.next();
// (admin guard + double-prefix guard omitted here; see prose)
// Internal rewrite onto the existing storefront tree.
const path = pathname === "/" ? "" : pathname;
const url = req.nextUrl.clone();
url.pathname = `/${slug}${path}`;
return NextResponse.rewrite(url);The ordering matters. The flag check is the first statement, so when the feature is off the proxy reads nothing and queries nothing. The matcher excludes /api, Next internals and any path with a dot (static assets), and even on matched paths the in-body flag check makes it a no-op. Two guards sit between the resolve and the rewrite. Custom domains serve the public storefront only, so an /admin request arriving on a custom host is redirected to the platform origin rather than rewritten, and the admin panel never gets served under a tenant's domain. A second guard skips the rewrite when the path is already prefixed with the slug, so there's no chance of double-prefixing.
Why a flag, and why default off
The flag is defined once and imported everywhere it matters: the proxy, the admin section, and the connect actions. That single idiom is deliberate. When it's off, the proxy is a pass-through, the settings section is hidden, and the server actions refuse early. Until the feature is deliberately enabled it is fully inert. There's no half-on state where the UI shows up but routing doesn't work, or routing runs but nothing can be connected.
Shipping a high-blast-radius feature dark, then enabling it as a separate, deliberate step, means the risky code lands and gets reviewed in isolation, well before it starts handling live traffic. And if enabling it causes trouble, stepping back is just as contained.
Talking to Vercel without letting it break a request
Attaching a domain to the project, checking its status, verifying ownership, and detaching it all go through Vercel's Domains API. We call it with a raw fetch: no SDK, no new dependency. The wrapper reads a deploy token scoped to the project from the environment. If that config is absent, the helper returns null and every exported call short-circuits to a not_configured status instead of attempting a request. That's what keeps dev, CI and tests green without a Vercel token, and it means a missing token can never break a live request.
Every call also wraps its fetch in try/catch and returns a typed result object on failure. None of them throw. The status machine that decides whether a domain is servable is a pure function, unit-tested in isolation, and it combines two distinct Vercel concepts that are easy to conflate:
- verified is project-domain ownership: has the TXT challenge been satisfied? This is the apex or www the tenant proves they control.
- misconfigured is DNS and certificate: are the A/CNAME records pointing at us and is the cert issued?
- A domain is only active (and therefore served) once both are good. Anything else is pending, verifying or error, and the proxy never serves it.
export function customDomainStatusFor(input: {
verified: boolean;
misconfigured: boolean;
}): CustomDomainStatus {
if (!input.verified) return "pending";
if (input.misconfigured) return "verifying";
return "active";
}One detail worth flagging for anyone copying this: don't hard-code the DNS records you tell the tenant to add. Vercel returns the recommended A and CNAME values from its config endpoint, and the shape varies (a bare string, a string array, or an array of objects). We pull the first usable value out of whatever comes back and show that, with no IP or CNAME fallback baked in. Hard-coded DNS targets rot the moment the platform changes them.
Keeping the common case free
The resolver is the one piece on the hot path. isPlatformHost is a cheap string check that fast-paths bookatu.com, *.bookatu.com, *.vercel.app, localhost, 127.* and an empty host straight to next() with no DB cost. Only a genuinely unknown host reaches getOrgByCustomDomain, which looks the host up in the custom_domains table and returns the owning org's slug, but only when that domain's status is active.
React's cache() only dedupes within a single request, which is useless here because the proxy runs per request. So the resolver keeps a module-level TTL cache with a 60-second lifetime that stores misses as well as hits. The negative caching is the part that earns its keep. Without it, someone could spray random Host headers and turn each one into a DB query. With it, an unknown host is looked up once and then served from cache as a miss until the TTL expires. The lookup also never throws: any DB error resolves to null, and the proxy falls through to a normal response.
const RESOLVER_TTL_MS = 60_000;
const resolverCache = new Map<string, { slug: string | null; expires: number }>();
export async function getOrgByCustomDomain(host: string): Promise<string | null> {
const key = (host || "").trim().toLowerCase();
if (!key) return null;
const hit = resolverCache.get(key);
if (hit && hit.expires > Date.now()) return hit.slug; // hit OR cached miss
// ... DB lookup joining custom_domains -> organizations, status = 'active'
}When a tenant connects or removes a domain, the admin action invalidates that host in the cache, so a just-changed domain starts (or stops) being served without waiting out the TTL.
One tenant, many domains
The first cut stored a single custom_domain column on the organizations row. That column is now deprecated. Real businesses legitimately want a few hosts pointing at the same storefront: a .co.nz, a .com, and maybe a redirect alias. So domains moved to their own custom_domains table, with one org owning many rows, all resolving to the same storefront. There's a cap of five per org, generous for a small business without inviting abuse of the global domain namespace or unbounded attach calls to Vercel.
Two guards protect that table. A global, case-insensitive unique index on lower(domain) means a domain belongs to exactly one org across the whole platform, which is the real cross-org collision guard. The per-org cap is enforced under a per-org transaction advisory lock, so two concurrent adds can't both slip past it (the unique index only stops identical domains, not two distinct ones that would both push an org over the limit). If a concurrent add fills the last slot after we've already attached on Vercel, we detach to avoid an orphan attachment and report the cap. The lock is keyed to the org, so it never blocks other tenants.
Entitlement stays per-org and lives in one pure function. A custom domain is included free on Pro and Scale, available as a paid add-on on Studio, and never available on Solo. Like every paid extra on Bookatu, the add-on is opt-in and consent-based; we never auto-charge a tenant for it. The connect action re-checks entitlement and owner/admin access server-side, so the client is never trusted. Pricing here is the same as everywhere on the platform: it's about your plan and your active clients over the last 90 days, not per-seat and not a cut of bookings. Bookatu takes 0% booking commission, and deposits flow to the tenant's own Stripe account, not ours.
What's next
The honest status: the code is in, validated, and still behind its flag. There's a deprecated single column plus its index waiting on a cleanup migration once nothing reads them. If I were starting again I'd build the multi-domain table first instead of the single column. The one-to-one model felt simpler at design time and turned into a migration I now have to schedule.
The pattern travels well beyond booking. If you run one app for many tenants on Vercel, the same three pieces (a flag-gated rewrite proxy, a Vercel API wrapper that never throws, and a TTL-cached host resolver) get you custom domains without per-tenant infrastructure. The discipline that made it safe wasn't clever code. It was defaulting the flag off and making sure the unhappy path, a missing token or a flaky DB, always resolves to a normal response instead of an error.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs