Back to blog
October 20, 2026•2 views

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

Next.jsMulti-Tenant SaaSMiddlewareSubdomain RoutingAuthenticationReactWeb Development
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.rewrite not redirect. 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 via req.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:

  1. Middleware checks for the presence of a session cookie. No cookie, redirect to login with a ?next= param.
  2. Middleware rewrites and passes the tenant slug as a header.
  3. 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.
  4. 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:

  1. Customer enters app.acme.com in settings.
  2. You generate a TXT record value like myapp-verify=abc123.
  3. Customer adds the TXT record.
  4. You check DNS via a route handler (Node runtime, dns/promises).
  5. 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 matcher aggressively. Excluding _next/static, _next/image, and asset extensions removes the majority of invocations.
  • Be careful with NextResponse.rewrite and 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_DOMAIN set 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:3000 resolves to slug acme.localhost:3000.
  • Forgetting www and accidentally treating it as a tenant.
  • Caching tenant data without tenant in the key.
  • Trusting a x-tenant-id header 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 redirect instead of rewrite and 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.

Share this post

Native Web Share is available on mobile — that share sheet includes Instagram and any other app you have installed.

Let's Connect

Get In Touch

Let's build something premium together.

Contact info

Follow me
Chat on WhatsApp