Architecture
One codebase, thousands of branded sites: our multi-tenant model
How Bookatu serves every tenant's branded storefront and back office from one Next.js app: a slug in the path, a flag-gated host proxy, and org_id on every query.
Bookatu is a booking platform that takes 0% commission. A hair studio in Wellington, a physio clinic, a photographer running private galleries: each one gets a public storefront where clients book, plus a back office where the business runs. Each wants it to look like their site, in their colours, eventually on their own domain. We run all of them from a single Next.js app and a single Postgres database.
That is the tension worth writing about. One deploy has to feel like thousands of separate sites, and a query for one tenant's customers must never return another tenant's. This post walks through how we actually do it: a slug in the URL, a thin host proxy for custom domains, and org scoping written into every read and write. No service mesh, no per-tenant deploy, no database per customer.
The gist
- The URL slug is the tenant key. app/[org]/... carries it, and one cached helper turns it into an organisation row.
- Branding is data, not a build. Colours come out of the DB row and become CSS variables at the layout level.
- Custom domains are a flag-gated proxy that rewrites an inbound host to /{slug}. The rest of the app never knows.
- Isolation is org_id on every domain table, plus the discipline of putting it in every WHERE clause.
The slug is the tenant
Routing starts with one dynamic segment. The whole tenant surface lives under app/[org], where [org] is the slug. Under it sit two route groups: (site), the public storefront (booking page, account, gift cards, waitlist), and admin, the back office. Same segment, two very different audiences, no duplicated tenant plumbing.
Turning that slug into a real organisation is the job of one small tenant module. The core lookup is wrapped in React's cache() so a page and its layout, which both need the org on the same request, only hit the database once.
export const getOrgBySlug = cache(
async (slug: string): Promise<Organization | null> => {
if (!slug) return null;
const rows = await db
.select()
.from(organizations)
.where(eq(organizations.slug, slug.toLowerCase()))
.limit(1);
return rows[0] ?? null;
},
);Around that sit a few rules that matter in production. requireOrg returns the org or renders a 404, but first it checks a small alias map: when a tenant renames its public URL, the old slug redirects to the new one so existing links and printed QR codes keep working. There is also a public variant of the lookup that only ever exposes published storefronts, so the public API and AI agents never see anything that is not a live business.
Branding is a row, not a build
A common trap with white-label products is treating each tenant's look as a build artefact or a theme bundle. We treat it as data. The organisations row carries the branding: a primary colour, a logo URL, a hero image, a tagline. The org layout reads the row and turns the colour into CSS custom properties on a wrapper that contains both the storefront and the admin shell.
const { org: slug } = await params;
const org = await requireOrg(slug);
// primaryColor from the DB becomes CSS variables for this subtree
return <div style={brandStyle(org.primaryColor)}>{children}</div>;brandStyle does a little colour maths with color-mix in oklab to derive a hover shade, a soft tint, and a focus ring from the one stored colour, so a tenant picks a single value and the whole UI stays coherent. Because it is just CSS variables on a div, there is no per-tenant CSS to ship and no theme cache to invalidate. A colour change is one column update that takes effect on the next render. Components reference var(--color-primary) and never know which tenant they are rendering for.
Custom domains: one proxy, zero new routes
Most tenants live at bookatu.com/{slug}. Some want their own domain pointing at their storefront, like a studio on ariahair.example.com. The constraint we set ourselves: this should add exactly one routing change and nothing else. The app/[org] tree should not learn a single new thing about domains.
That one change is proxy.ts (Next 16 renamed middleware.ts to proxy.ts). It resolves the inbound Host header to the owning org and does an internal rewrite to that org's storefront path. The storefront tree serves the request exactly as it would for a /{slug} URL, because as far as it is concerned, that is the URL.
// Flag OFF -> pure pass-through. No Host read, no DB, zero routing change.
if (!customDomainsEnabled) return NextResponse.next();
// Platform / preview / local hosts skip the DB in the common case.
const host = (req.headers.get("host") || "").split(":")[0].toLowerCase();
if (!host || isPlatformHost(host)) return NextResponse.next();
// Only an 'active' custom domain resolves to a slug; unknown hosts fall through.
const slug = await getOrgByCustomDomain(host);
if (!slug) return NextResponse.next();
const path = req.nextUrl.pathname === "/" ? "" : req.nextUrl.pathname;
const url = req.nextUrl.clone();
url.pathname = `/${slug}${path}`;
return NextResponse.rewrite(url);This code runs on every matched request, so the ordering is deliberate. The feature is flag-gated, and when the flag is off the first line returns before the host is even read, a byte-for-byte pass-through. When it is on, platform, preview and local hosts (bookatu.com, *.vercel.app, localhost) fast-path out with no database cost, so normal traffic never pays for the lookup. Only a genuinely unknown host reaches the resolver.
The resolver is the part that keeps this cheap and safe. It is backed by a module-level TTL cache that stores misses as well as hits. React's cache() only dedupes within one request, but the proxy runs per request, so a cross-request cache is what actually stops a database hit on every visit. Caching the negative result matters just as much: a flood of requests with random Host headers can't turn into a flood of lookups. The resolver also never throws. Any database error resolves to null, and the proxy falls through to a normal response, so a wobble in the domains table can't take the storefront down.
One more rule lives here: custom domains serve the public storefront only. An /admin request arriving on a custom domain is redirected to the equivalent path on the platform host rather than rewritten, so the back office has exactly one home.
Mapping a host to an org, and one org to many domains
Behind the proxy is a custom_domains table rather than a column on the org. A small business legitimately wants an apex plus a couple of aliases (the .co.nz, the .com, a redirect host), so one org owns up to five domains, all pointing at the same storefront. The resolver joins the table to organisations purely to fetch the slug, and only matches rows whose status is active, so a domain still pending DNS or settling its certificate is never served.
const rows = await db
.select({ slug: organizations.slug })
.from(customDomains)
.innerJoin(organizations, eq(organizations.id, customDomains.orgId))
.where(and(
sql`lower(${customDomains.domain}) = ${key}`,
eq(customDomains.status, "active"),
))
.limit(1);A global unique index on lower(domain) is the real guard against two accounts claiming the same host. The per-org cap of five is enforced twice: a cheap pre-check, then a re-count under a per-org advisory lock inside the insert transaction, so two concurrent adds can't both slip past the limit. The lock is keyed to the org, so it never blocks other tenants. Attaching the domain on the hosting side and writing the row are kept in step, and if a concurrent add fills the last slot, we detach the orphan rather than leave it dangling. None of this leaks into the app; it all sits behind the same resolver the proxy already calls.
Isolation: org_id everywhere, every query
Branding and routing are the visible half. The half that has to be right is data isolation, and ours is deliberately boring. Every domain table carries the tenant's id, and every query that touches tenant data filters by it. There is no clever ORM magic that auto-injects the scope; the scope is written out, on purpose, so it is visible in review.
// list: scope the whole result set to this org
.where(eq(customers.orgId, org.id))
// single row: id AND org, so you can't read another tenant's row by guessing an id
.where(and(eq(customers.id, id), eq(customers.orgId, org.id)))That second pattern catches the sharp edge. A bare eq(customers.id, id) would happily return any tenant's row if someone guessed or enumerated an id. Pairing the id with org.id means a lookup that doesn't belong to the current tenant simply returns nothing. The admin side gets org from requireOrgAccess, which checks the signed-in user's membership of that org before any data is read, so the org.id you scope to has already been authorised against the user.
The safest scope is the one you can see in the diff. We chose explicit org_id filters over invisible magic precisely because someone has to be able to spot a missing one in review.
What this buys us, and where it bites
The payoff is real. One deploy ships every tenant at once. A new storefront is a row, not a provisioning job. Branding is a column update. A tenant's own Stripe account, their bank, their funds (we never touch them, which is what 0% commission means in practice) hangs off the same org row. Pricing by active clients over the last ninety days, rather than per seat, is just another query against the same scoped data, with every feature available on every plan.
The cost is discipline. A single shared database means a missing org_id is a real bug class, not a theoretical one, which is why the scope is explicit and why row lookups pair id with org. A shared deploy means one bad migration touches everyone, so schema changes are additive and idempotent. And a per-request proxy is high blast radius, which is why it is flag-gated, fast-paths the common case, caches negatives, and never throws.
What's next is mostly hardening the seams. The custom-domain proxy is built and flag-gated rather than fully on. Longer term, the explicit-scope discipline is the thing we would most like to make harder to get wrong without making it invisible. A lint rule that flags a tenant-table query with no org filter would keep the safety we get from seeing it in the diff, while removing the chance of forgetting it. The model itself, slug in the path, branding in a row, org_id in every clause, has held up well as the surface has grown.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs