Multi-Tenant SaaS in the Next.js App Router: How We Handle Subdomain Middleware and the Cache Leak We Caught in Time
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Key takeaways
- Next.js middleware can rewrite `acme.myapp.com` to an internal `/_tenants/acme/...` path while keeping the URL bar unchanged, using `NextResponse.rewrite()` against the `host` header.
- Middleware now runs on the Node.js runtime by default under Fluid Compute, not just the Edge runtime, so a full database client is fair game there — we still put a fast lookup in front of tenant resolution to keep latency down under concurrent load.
- `unstable_cache()` and `fetch()` caching key strictly off the arguments you pass them. Leave the tenant ID out of the key and every tenant shares one cache entry — this is the leak we caught in staging before it reached production.
- Postgres row-level security (RLS), driven by a per-request `SET app.tenant_id`, is a stronger backstop than trusting every query to include a `WHERE tenant_id = ?` clause — it fails closed instead of failing open.
- Custom domains need a domain-to-tenant lookup table plus the Vercel Domains API to provision and verify each one; subdomain routing alone doesn't cover a tenant who wants their own brand in the address bar.
How does subdomain-based multi-tenancy work in the Next.js App Router?
Subdomain-based multi-tenancy means resolving which tenant a request belongs to from the `Host` header, then routing that request into the same App Router tree every tenant shares, with the URL bar still showing the tenant's own subdomain. At Devya we don't build a folder per client — we write one `/app/_tenants/[tenant]/dashboard/page.tsx` tree and route every incoming subdomain into it.
Middleware is where that mapping happens, because it runs before any route match and can rewrite the request path without a redirect:
```ts // middleware.ts import { NextResponse, type NextRequest } from 'next/server'; const ROOT_DOMAIN = 'myapp.com'; export function middleware(request: NextRequest) { const host = request.headers.get('host') || ''; const subdomain = host.endsWith('.' + ROOT_DOMAIN) ? host.replace('.' + ROOT_DOMAIN, '') : null; if (!subdomain || subdomain === 'www') { return NextResponse.next(); } const url = request.nextUrl.clone(); url.pathname = `/_tenants/${subdomain}${url.pathname}`; return NextResponse.rewrite(url); } export const config = { matcher: ['/((?!_next|api/health|favicon.ico).*)'], }; ```
`NextResponse.rewrite()` is the operative call: the browser still shows `acme.myapp.com/dashboard`, while Next.js resolves `/app/_tenants/[tenant]/dashboard/page.tsx` internally. A redirect would leak the internal path into the address bar and cost an extra round trip; a rewrite doesn't.
On Vercel this needs a wildcard domain (`*.myapp.com`) added to the project and a wildcard DNS record pointed at Vercel — a single apex domain won't catch arbitrary subdomains.
How do we resolve the tenant in middleware without hitting the database on every request?
A subdomain string alone isn't proof a tenant exists or is active — a suspended tenant's subdomain still matches the routing pattern. Looking that up against Postgres on every request adds a database round trip to every page load. We moved that lookup — slug to tenant ID, plan, and active/suspended status — into Vercel Edge Config, refreshed by a webhook whenever a tenant record changes. Reads out of Edge Config run in single-digit milliseconds because the data replicates to the edge network, versus the tens of milliseconds a cold Postgres round trip costs from inside middleware.
```ts import { get } from '@vercel/edge-config'; type TenantMeta = { id: string; status: 'active' | 'suspended' }; const tenant = await get<TenantMeta>(`tenant:${subdomain}`); if (!tenant || tenant.status === 'suspended') { return NextResponse.redirect(new URL('/not-found', request.url)); } const headers = new Headers(request.headers); headers.set('x-tenant-id', tenant.id); return NextResponse.rewrite(url, { request: { headers } }); ```
Forwarding `x-tenant-id` as a header, not just the slug in the URL, is what lets every Server Component and Route Handler downstream read the tenant via `headers()` without re-deriving it from the path on every layer.
Why did one tenant see another tenant's cached dashboard data?
We had a dashboard summary — revenue, active users, open tickets — wrapped in `unstable_cache()` to avoid recomputing it on every request. The first version keyed the cache off the query name alone, with no tenant ID in the key array. `unstable_cache()` caches by the key array you give it, not by the arguments the wrapped function receives — every tenant calling the same function hit the same cache entry, so whichever tenant's request populated the cache first served every other tenant their numbers until the next revalidation.
In staging, with two test tenants, this surfaced as one tenant's revenue figure appearing on the other tenant's dashboard — not a crash, just quietly wrong data, which is worse because nothing throws an error to flag it.
```ts async function dashboardStatsFor(tenantId: string) { return unstable_cache( () => db.query.stats(tenantId), ['dashboard-stats', tenantId], { tags: [`tenant-${tenantId}`], revalidate: 60 }, )(); } ```
Including the tenant ID inside the key array, plus a tenant-scoped tag, fixed it — now we can invalidate one tenant's cache without touching any other tenant's. The same rule applies to plain `fetch()` calls: if a tenant ID isn't part of the URL or explicitly added via `{ next: { tags: [...] } }`, two tenants hitting a route that calls the same upstream endpoint with otherwise-identical parameters can still collide. We now treat 'does this cache key include the tenant ID' as a required line item in code review, not something any one engineer is expected to remember mid-implementation.
How should tenant scoping look in the data access layer?
Every table holding tenant data gets a `tenant_id` column, and every query goes through a helper that adds the `WHERE tenant_id = ?` clause instead of trusting each call site to add it manually. That helper stops most mistakes, but it's still an application-level convention — a raw query or a new endpoint that bypasses the helper defeats it silently.
Postgres row-level security (RLS) is the backstop that fails closed instead of failing open:
```sql ALTER TABLE invoices ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON invoices USING (tenant_id = current_setting('app.tenant_id')::uuid); ```
With RLS on, even a query that forgets the `WHERE` clause returns zero rows for every other tenant, because Postgres enforces the policy at the row level regardless of what the application asked for. The connection runs `SET app.tenant_id = '...'` at the start of each request, inside the same transaction that serves it — this composes fine with a serverless Postgres connection pooler, since the `SET` is scoped to that pooled connection's transaction and doesn't leak into the next one.
What changes when a tenant brings their own custom domain?
A subdomain covers `acme.myapp.com`, but an enterprise tenant asking for `app.acme.com` needs a second resolution path: a lookup table mapping verified custom domains to tenant IDs, checked in the same middleware before the subdomain check runs. Provisioning the domain itself goes through the Vercel Domains API — adding the domain to the project, returning the DNS records the tenant needs to set, and polling verification status — rather than asking a tenant to email a CNAME request. SSL is issued automatically once the domain verifies, which is the part manual custom-domain support used to make painful.
Subdomain vs. path-based vs. custom-domain multi-tenancy — which should we build first?
- Path-based (`myapp.com/acme/dashboard`) is the fastest to ship and needs no DNS work, but tenant isolation lives only in the URL — cookies and CSP need extra care to avoid cross-tenant leakage on a shared origin.
- Subdomain (`acme.myapp.com/dashboard`) is what most B2B SaaS products should default to — clean separation, each tenant feels like their own instance — at the cost of wildcard DNS, a wildcard TLS certificate, and subdomain support in local dev.
- Custom domain (`app.acme.com`) is what enterprise tenants ask for once they want their own brand in the address bar, and it carries the most operational overhead: per-domain verification, provisioning, and certificate lifecycle management.
We ship subdomain-based routing as the default and layer custom domains on top as a paid-tier feature, rather than building custom-domain support from day one — the DNS verification and certificate lifecycle is real engineering work that doesn't pay off until a customer is actually asking for it.
FAQ
**Q: Does Next.js middleware run on the Edge runtime or Node.js?**
**A:** Under Fluid Compute, middleware runs on the Node.js runtime by default, not just the constrained Edge runtime — full Node APIs and database clients are available there now, though a fast lookup like Edge Config or Redis in front of tenant resolution still keeps middleware latency low under concurrent load.
**Q: How do we test subdomain routing locally?**
**A:** `*.localhost` hostnames resolve to `127.0.0.1` in every modern browser with no `/etc/hosts` editing required, so `acme.localhost` and `beta.localhost` work out of the box for local multi-tenant testing.
**Q: Should each tenant get a separate database instead of a shared one with a tenant_id column?**
**A:** Most SaaS products should start with a shared database, a `tenant_id` column on every table, and Postgres row-level security for isolation. A database per tenant is usually a later move, driven by a specific compliance requirement or one tenant's scale outgrowing shared infrastructure, not a starting default.
**Q: Does Next.js's built-in caching automatically scope by tenant?**
**A:** No. `unstable_cache()` and the `fetch()` cache key strictly off the arguments and tags supplied to them — the tenant ID has to be part of that key explicitly, or every tenant shares one cache entry.
**Q: Can row-level security replace tenant checks in application code?**
**A:** RLS is a backstop, not a replacement — the application still has to set `app.tenant_id` correctly per request. What RLS adds is a failure mode that returns zero rows instead of another tenant's rows when the application-level check is missing.
Further Reading
Full-Stack Engineering
Observability in the Next.js App Router: Field Notes on instrumentation.ts, onRequestError, and the Trace That Leaked Across Requests
instrumentation.ts and onRequestError give the Next.js App Router real tracing and centralized error reporting. Fluid Compute reusing one instance across concurrent requests broke a module-level trace variable we'd relied on for years — our field notes on wiring up OpenTelemetry correctly.
Full-Stack Engineering
Database Connections in Serverless: Pool Math, PgBouncer Transaction Mode, and the Flags That Survive It
A connection pool in a serverless deployment is per-instance, not per-application, so the real ceiling is concurrent instances multiplied by pool size. Our team's field notes on why PostgreSQL starts rejecting clients, what Fluid Compute changed, and the driver settings that survive transaction-mode pooling.