Full-Stack

Multi-tenant subdomains in Next.js 14 without losing your mind

Middleware, wildcard DNS, and the data model that keeps tenant isolation honest. A field guide from production.

Subdomain-per-tenant looks like a branding feature and turns out to be an architecture decision. acme.yourapp.com and globex.yourapp.com need to feel like separate products while running on one codebase and one deploy. Here's the setup that's been carrying Forzive in production.

Wildcard DNS first

Before any code, point a wildcard *.yourapp.com record at your host and provision a wildcard TLS certificate. Cloudflare does both for free, and it's the piece people forget — without it, every new tenant means a manual DNS entry, which kills self-serve onboarding. With it, a new subdomain just works the instant a tenant signs up.

Resolve the tenant in middleware

Middleware runs before every request, which makes it the one honest place to figure out who is asking. Parse the host, strip the root domain, and hand the remaining label down to the app as a header. Crucially, the app reads tenancy from that header — never from the URL — so the rest of your code doesn't care how the tenant was resolved.

middleware.ts
// middleware.ts — resolve the tenant from the host on every request.
import { NextRequest, NextResponse } from "next/server";

const ROOT = "forzive.com";

export function middleware(req: NextRequest) {
  const host = req.headers.get("host") ?? "";
  const sub = host.replace(`.${ROOT}`, "");

  // No subdomain (or "www") → marketing site, untouched.
  if (host === ROOT || sub === "www") return NextResponse.next();

  // Pass the tenant down as a header — the app never reads it from the URL.
  const res = NextResponse.next();
  res.headers.set("x-tenant", sub);
  return res;
}
Treat `www` and the apex as the marketing site. It's an easy edge case to miss: without the guard, www.yourapp.com becomes a tenant named "www" and your landing page 404s.

The data model is where isolation actually happens

The subdomain is just a lookup key. Real isolation lives in the database: every tenant-owned table carries a tenantId, and every query is scoped to the tenant resolved in middleware. I funnel all of this through a single data helper so no individual query can forget the scope — isolation is structural, not a thing I have to remember on call number four hundred.

Resolve the tenant record once per request (cache it), and from then on the rest of your handlers take a tenant context as input. They never see a host string, never parse a subdomain. That separation is what keeps the system understandable as it grows.

Gotchas that cost me an evening each

  • Local development. localhost has no subdomains. Use tenant.localhost:3000 (modern browsers route it) or a tool like lvh.me, and make the root configurable so the same middleware works in dev and prod.
  • Cookies across subdomains. Decide deliberately: set the cookie Domain to the apex if tenants should share a session, or scope it per-subdomain if they shouldn't. The default behavior is rarely the one you want.
  • Caching. Any CDN or fetch cache must include the tenant in its key, or one gym will be served another gym's page. The header-based approach makes this explicit — add x-tenant to the cache key and move on.

Get the wildcard DNS, the middleware resolution, and the scoped data layer right, and multi-tenant subdomains stop being scary. The whole thing is maybe 50 lines of infrastructure code — the discipline is in keeping tenancy in those few places and nowhere else.

More articlesBuilding something? Let's talk