Next.js Middleware for Multi-Tenant SaaS: Subdomain Routing and Auth Done Right

Multi-tenant SaaS is one of those problems where the demo takes an afternoon and production takes a month. I have shipped multi-tenant platforms on Next.js for clients in Dhaka, Sydney, and Austin, and every single time, the hard part was never the UI. It was routing, tenant resolution, and making sure Tenant A can never, under any circumstance, read Tenant B's data.
Next.js middleware multi-tenant SaaS setups are the cleanest place to solve the routing half of that problem. Middleware runs before your pages render, it can rewrite requests, set headers, and reject traffic. It is also the place where people quietly create security holes, because middleware is not an authorization layer, it is a routing layer with an identity hint.
This guide walks through what I actually build: subdomain routing, custom domain support, tenant-aware auth, and the isolation rules that keep me out of incident calls.
Why middleware is the right place for tenant resolution
A multi-tenant app has to answer one question on every request: which tenant is this?
You can answer it in a layout, in a getServerSideProps equivalent, in a route handler, or in middleware. Only middleware answers it once, before anything else runs, and lets you normalize the request so the rest of your app does not care whether the user arrived on acme.myapp.com, app.myapp.com/acme, or acme.com (a custom domain).
That normalization is the whole value proposition. Your pages, Server Components, and API routes all read from the same resolved tenant object, and you stop sprinkling hostname parsing logic across 40 files. If you are still deciding on the framework itself, my comparison of Next.js vs Remix vs Astro for AI-enhanced web apps covers where Next.js wins for this kind of architecture.
One thing to internalize early: middleware runs on the Edge runtime by default. No Node APIs, no fs, no native Postgres driver. If you plan to hit your database from middleware, you need an HTTP-based client (Neon serverless driver, PlanetScale, Supabase REST, or your own API). I have seen teams burn a week on this. (You can opt into the Node.js runtime for middleware in recent Next.js versions, but the edge-first design still shapes what is safe to do there, see the Next.js middleware docs.)
Host vs path vs custom domain tenancy
Before writing code, pick your model. They are not interchangeable.
| Model | Example | Pros | Cons | Best for |
|---|---|---|---|---|
| Subdomain | acme.myapp.com |
Clean isolation, easy wildcard TLS, cookie scoping per tenant | Requires wildcard DNS + cert, harder local dev | B2B SaaS, most common |
| Path | myapp.com/acme |
Trivial DNS, one cert, simple local dev | Cookie/tenant leakage risk, SEO ambiguity, harder to white-label | Internal tools, marketplaces |
| Custom domain | app.acme.com |
Strong branding, enterprise-friendly | Per-domain TLS provisioning, domain verification flow, more edge cases | Enterprise / white-label tiers |
| Hybrid | Subdomain + custom domain | Best of both, upsell path | Most complexity in middleware | Mature SaaS |
Most SaaS I build starts subdomain-only and adds custom domains once an enterprise customer asks. The middleware logic below supports both from day one so you do not rewrite it later.
Setting up wildcard subdomains
You need three things before middleware matters: DNS, TLS, and local dev.
DNS: an A record for myapp.com and a wildcard CNAME (or A) for *.myapp.com pointing at your host. On Vercel this is automatic once you add the domain; on self-hosted you configure it in your ingress.
TLS: a wildcard certificate for *.myapp.com. Let's Encrypt supports wildcard certs via DNS-01 challenge. If you are on Vercel or Netlify, they handle it. If you are on your own Kubernetes cluster, cert-manager with a DNS-01 solver is the standard route.
Local dev: acme.localhost:3000 works in most browsers without hosts file edits. If it does not for you, add entries to /etc/hosts:
127.0.0.1 acme.localhost
127.0.0.1 globex.localhost
Set NEXT_PUBLIC_ROOT_DOMAIN=localhost:3000 in .env.local and parse accordingly. This trips people up constantly, so put it in your README.
Writing the middleware: subdomain routing step by step
Here is the shape I use. It is deliberately boring, which is what you want in middleware.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN! // e.g. "myapp.com"
// Hosts that are never tenants
const RESERVED_SUBDOMAINS = new Set([
'www', 'app', 'api', 'admin', 'dashboard', 'status', 'docs', 'blog',
])
export function middleware(req: NextRequest) {
const url = req.nextUrl
const host = (req.headers.get('host') ?? '').toLowerCase().split(':')[0]
// 1. Static assets and Next internals: skip entirely
if (
url.pathname.startsWith('/_next') ||
url.pathname.startsWith('/api/webhooks') ||
url.pathname.includes('.')
) {
return NextResponse.next()
}
// 2. Root domain: marketing site, no tenant
if (host === ROOT_DOMAIN || host === `www.${ROOT_DOMAIN}`) {
return NextResponse.next()
}
// 3. Subdomain of the root domain
if (host.endsWith(`.${ROOT_DOMAIN}`)) {
const sub = host.slice(0, -(ROOT_DOMAIN.length + 1))
if (RESERVED_SUBDOMAINS.has(sub)) return NextResponse.next()
// Rewrite /dashboard -> /_tenants/acme/dashboard
return NextResponse.rewrite(
new URL(`/_tenants/${sub}${url.pathname}`, req.url),
{ headers: { 'x-tenant-slug': sub } }
)
}
// 4. Custom domain: resolve later in a route handler or via KV lookup
return NextResponse.rewrite(
new URL(`/_domains/${host}${url.pathname}`, req.url),
{ headers: { 'x-tenant-host': host } }
)
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
Three details that matter more than they look:
- The
includes('.')check skips anything with a file extension. It is a blunt instrument and it will occasionally skip a legitimate route with a dot in it. If that bites you, replace it with an explicit asset prefix list. NextResponse.rewritenotredirect. Rewrites keep the URL in the browser (acme.myapp.com/dashboard) while serving a different internal path. Redirects change the URL and break relative links, cookies, and user expectations.- Headers set on the rewrite response are visible to Server Components via
headers()and to route handlers viareq.headers. That is how the tenant context propagates without a global store.
Structuring the app directory
The _tenants/[slug] folder is a route group with a dynamic segment. Underscore prefix keeps it out of the URL space.
app/
(marketing)/
page.tsx # myapp.com
_tenants/
[slug]/
layout.tsx # loads tenant, validates, provides context
dashboard/
page.tsx
_domains/
[host]/
layout.tsx # same idea, keyed by hostname
dashboard/
page.tsx
api/
webhooks/
route.ts # excluded from middleware
Both layout.tsx files do the same job: resolve the slug or host to a tenant record, 404 if not found or suspended, and expose the tenant to children. Duplicating that logic is fine. Sharing it via a helper is better. If you want a deeper look at how Server and Client Components split responsibilities in this structure, see React Server Components vs Client Components in Next.js 15.
Multi-tenant authentication in Next.js middleware
Here is the part that gets people in trouble. Middleware can check whether a session cookie exists. It cannot safely decide whether that session is allowed to see this tenant's data, because that requires a database call and middleware is on the edge.
The pattern I use:
- Middleware checks for the presence of a session cookie. No cookie, redirect to login with a
?next=param. - Middleware rewrites and passes the tenant slug as a header.
- The tenant layout (a Server Component, running on Node or edge with DB access) validates that the session's user actually belongs to that tenant.
- Every data access goes through a helper that scopes queries by
tenant_id, never by user input.
// middleware.ts (auth portion)
import { getSessionCookie } from 'better-auth/cookies' // or your provider
const sessionCookie = getSessionCookie(req)
const isProtected = url.pathname.startsWith('/dashboard')
if (isProtected && !sessionCookie) {
const login = new URL('/login', req.url)
login.searchParams.set('next', url.pathname)
return NextResponse.redirect(login)
}
That is it. No role checks, no database lookups, no "is this user an admin of this tenant" logic. Keep middleware dumb.
Where the real authorization lives
In the tenant layout:
// app/_tenants/[slug]/layout.tsx
import { notFound } from 'next/navigation'
import { getTenantBySlug, getMembership } from '@/lib/tenancy'
import { auth } from '@/lib/auth'
export default async function TenantLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const tenant = await getTenantBySlug(slug)
if (!tenant || tenant.status !== 'active') notFound()
const session = await auth()
const membership = session
? await getMembership(session.user.id, tenant.id)
: null
if (!membership) {
// Public marketing pages inside tenant space? Allow. Otherwise:
return <TenantLoginPrompt tenant={tenant} />
}
return (
<TenantProvider tenant={tenant} membership={membership}>
{children}
</TenantProvider>
)
}
This is enforceable because it runs on every request to that subtree, and because TenantProvider is the only source of tenant.id for downstream queries.
Provider-specific notes
| Provider | Middleware-friendly check | Gotcha |
|---|---|---|
| NextAuth / Auth.js | getToken() from next-auth/jwt |
Edge-safe only with JWT sessions, not database sessions |
| Clerk | clerkMiddleware() + auth() |
Clerk's own middleware wants to own the matcher; compose carefully |
| Auth0 | @auth0/nextjs-auth0 getSession() |
Session cookie is encrypted and large; avoid parsing in middleware |
| Better Auth | getSessionCookie() |
Cookie name is configurable, do not hardcode |
The general rule: in middleware, verify signature and expiry if you must, but never trust a client-supplied tenant identifier. The tenant comes from the hostname, which you control via DNS, not from a query param or header the user can set.
SaaS tenant isolation: the rules that actually prevent leaks
Tenant isolation in Next.js is 20% middleware and 80% discipline. These are the rules I enforce in code review.
Rule 1: Tenant ID comes from the resolved context, never from the request body or query string. If a handler reads req.query.tenantId, it is a bug.
Rule 2: Every query is scoped. Wrap your data layer so the tenant filter is not optional.
// lib/db/tenant.ts
export function tenantDb(tenantId: string) {
return {
invoices: {
list: () => db.invoice.findMany({ where: { tenantId } }),
byId: (id: string) =>
db.invoice.findFirst({ where: { id, tenantId } }), // note: findFirst, not findUnique
},
}
}
That findFirst with both conditions is the difference between a secure app and a support ticket. findUnique({ where: { id } }) ignores tenancy and will happily return another tenant's row.
Rule 3: Row Level Security if you are on Postgres. Even with perfect application code, RLS is your seatbelt. Set app.current_tenant per connection and let the database enforce it. (See the Postgres RLS docs.)
Rule 4: Cache keys include the tenant. This is the one that bites at scale. If you use unstable_cache or Redis and key on the URL path alone, acme.myapp.com/dashboard and globex.myapp.com/dashboard collide. Both rewrite to /_tenants/[slug]/dashboard, so the internal path is not unique.
const getStats = unstable_cache(
async (tenantId: string) => computeStats(tenantId),
['tenant-stats'],
{ tags: [`tenant:${tenantId}`] }
)
Always include tenant ID in the cache key and in the tag. Always. For the full picture of how Next.js caching layers interact, Next.js 15 Caching Demystified is worth reading alongside this section.
Rule 5: Background jobs and webhooks carry tenant context explicitly. A Stripe webhook arrives at api/webhooks/stripe with no hostname. You resolve the tenant from the Stripe customer ID or metadata, not from the request host. That is why webhooks are excluded from the middleware matcher above.
Custom domains and dynamic subdomains at scale
Custom domains are where the design gets interesting. You cannot enumerate them in middleware without a lookup, and you cannot do a database lookup on every request from the edge without paying for it in latency.
My approach: keep a KV store (Vercel KV, Upstash Redis, Cloudflare KV) mapping hostname -> tenantSlug, populated when a customer verifies their domain. Middleware reads from KV (fast, edge-local) and falls back to a rewrite into _domains/[host] if KV misses, where a Node-runtime layout does the authoritative check and 404s if the domain is not verified.
Verification flow, in short:
- Customer enters
app.acme.comin settings. - You generate a TXT record value like
myapp-verify=abc123. - Customer adds the TXT record.
- You check DNS via a route handler (Node runtime,
dns/promises). - On success, provision TLS (your host's API or cert-manager), write to KV, mark verified.
Do not skip step 5's ordering. I have seen apps write to KV before TLS is ready and serve certificate errors for 10 minutes.
Performance and caching notes from production
Middleware adds latency to every matched request. On Vercel's edge network it is typically single-digit milliseconds for pure logic. The cost comes from anything you do inside it.
- Keep middleware under 10ms of work. Hostname parsing, cookie presence check, one KV read. That is the budget.
- Do not fetch tenant records from Postgres in middleware. Do it in the layout, where it can be cached and deduped.
- Use
matcheraggressively. Excluding_next/static,_next/image, and asset extensions removes the majority of invocations. - Be careful with
NextResponse.rewriteand caching. Rewrites can affect how CDN caching keys work. If you see cross-tenant cache hits, that is your signal. - Prefer headers over cookies for tenant context. Cookies are sent back to the client and can be tampered with; headers set on the rewrite stay server-side.
One more: if you use generateStaticParams for tenant pages, you are now building per-tenant at build time, which does not scale past a handful of tenants and breaks for custom domains. For anything past a few dozen tenants, go dynamic with ISR and tenant-scoped cache tags. The broader techniques in my Next.js performance optimization guide apply directly here.
Testing multi-tenant middleware
You will not catch isolation bugs by clicking around. Write tests.
For middleware, unit test the host-parsing function in isolation. Extract it:
export function resolveTenant(host: string, root: string) {
const clean = host.toLowerCase().split(':')[0]
if (clean === root || clean === `www.${root}`) return { kind: 'root' as const }
if (clean.endsWith(`.${root}`)) {
const sub = clean.slice(0, -(root.length + 1))
return RESERVED_SUBDOMAINS.has(sub)
? { kind: 'root' as const }
: { kind: 'subdomain' as const, slug: sub }
}
return { kind: 'custom' as const, host: clean }
}
Now it is a pure function and you can table-test it with 30 cases in five minutes. Do that.
For integration, spin up two tenants in a test database and assert that a request authenticated as Tenant A's user, sent to Tenant B's subdomain, gets a 403 or a login prompt, not data. That single test catches more real bugs than any amount of code review. If your team leans on AI to generate this code, pair it with the verification strategies in human oversight for AI-generated code.
For end-to-end, Playwright with a custom baseURL per tenant.
Deployment considerations
On Vercel, wildcard domains and custom domains are handled by the platform, and middleware runs at the edge by default. You still need to add each custom domain via the Domains API and verify it.
On self-hosted (Docker, Kubernetes), you need:
- A wildcard DNS record pointing at your ingress
- A wildcard TLS cert (cert-manager with DNS-01)
- Ingress rules forwarding all subdomains to your Next.js service
NEXT_PUBLIC_ROOT_DOMAINset correctly per environment
I run this on Kubernetes with cert-manager and it works, but the initial setup is a day of yak-shaving. If you are pre-revenue, use a managed host.
Common pitfalls I see repeatedly
- Parsing the host without stripping the port, so
acme.localhost:3000resolves to slugacme.localhost:3000. - Forgetting
wwwand accidentally treating it as a tenant. - Caching tenant data without tenant in the key.
- Trusting a
x-tenant-idheader set by the client (headers from the client are overwritable; headers you set on a rewrite are not). - Doing database calls in middleware and hitting the edge runtime wall.
- Using
redirectinstead ofrewriteand wondering why the URL changes. - Letting a suspended tenant's subdomain keep resolving to a working app.
Where this leaves you
Middleware gives you one clean place to resolve tenancy and one clean place to gate unauthenticated traffic. Everything else, authorization, data scoping, cache keys, belongs in the layers below it. Get that division right and multi-tenant Next.js stops being scary.
If you are building a multi-tenant SaaS and want a second pair of eyes on the architecture before you ship, or you need someone to build the thing end to end, that is what I do. I have shipped 300+ web apps and platforms for teams across Bangladesh, the USA, the UK, and Australia, and multi-tenant routing is one of my favorite problems. Reach out through the contact page and tell me what you are building.
Frequently asked questions
Can I use Next.js middleware with a database to resolve tenants?
Technically yes, if you use an HTTP-based driver (Neon serverless, PlanetScale, Supabase REST) and accept the latency. I do not recommend it. Middleware should stay under 10ms and do only hostname parsing plus a cookie presence check. Do the authoritative tenant lookup in a Server Component layout, where it can be cached and deduped across the request.
How do I handle custom domains with Next.js middleware?
Keep a KV store (Vercel KV, Upstash, Cloudflare KV) mapping hostname to tenant slug. Middleware reads from KV and rewrites into a _domains/[host] route. A Node-runtime layout does the authoritative verification check. Populate KV only after the customer's DNS TXT record is verified and TLS is provisioned, otherwise you serve certificate errors.
Is middleware enough for tenant isolation?
No. Middleware handles routing and a coarse auth gate. Real isolation lives in your data access layer (always scope by tenant ID, use findFirst with both id and tenantId), in Postgres Row Level Security, and in cache keys that include the tenant ID. The cache key issue is the one that causes cross-tenant leaks in production most often.
Does this work on Vercel and self-hosted Kubernetes?
Yes, with different setup work. On Vercel, wildcard and custom domains are managed by the platform and middleware runs at the edge by default. On Kubernetes you need a wildcard DNS record, a wildcard TLS certificate via cert-manager with a DNS-01 solver, and ingress rules forwarding all subdomains to your Next.js service. Budget a day for the initial setup.