50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 18, 2026·7 min read

Multi-Tenant Subdomain Routing in Next.js: The Complete Pattern

How to route acme.yourapp.com to the right tenant in Next.js — middleware tricks, pitfalls, and the architecture we actually ship.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Multi-Tenant Subdomain Routing in Next.js: The Complete Pattern

Multi-tenancy is one of those things that sounds straightforward until you're three hours deep at 11pm wondering why your middleware is matching localhost as a subdomain. We've built this pattern enough times to have strong opinions about it, and we're going to share exactly what works — and what sounds good in blog posts but falls apart in production.

The goal: you have one Next.js app, and each customer gets their own subdomain — acme.yourapp.com, globex.yourapp.com, initech.yourapp.com. All pointing to the same deployment. All showing different data, different branding, maybe different features. Let's build it properly.

The Three Architectures (and Which One You Actually Want)

Before writing a single line of code, you need to pick your tenancy model. There are three real options, and they affect everything downstream — your routing, your database queries, your auth, all of it.

  • Subdomain-per-tenant: acme.yourapp.com — cleanest UX, requires wildcard DNS, what this post covers
  • Path-per-tenant: yourapp.com/acme — easier to set up, worse for branding, fine for internal tools
  • Custom domain per tenant: acme.com pointing to your app — the premium tier, needs extra work with domain verification

We're focusing on subdomain routing because it's the sweet spot. Path-based tenancy is trivial (just read the URL segment), and custom domains are a whole separate post. Subdomains are where most SaaS apps live, and where most developers hit unexpected walls.

Wildcard DNS: The Part Nobody Explains

Your routing code means nothing if DNS isn't set up right. You need a wildcard A record pointing *.yourapp.com to your server. In Vercel, this is automatic when you add a wildcard domain. On your own infra, you set an A record for * to your load balancer IP. Simple enough — but the local development story is messier.

For local dev, you have two options: use a service like lvh.me (which resolves *.lvh.me to 127.0.0.1 — genuinely useful, not a sponsor), or edit your /etc/hosts file and add entries manually. We usually go with lvh.me in dev and add a comment in the README so the next developer doesn't spend 40 minutes confused. acme.lvh.me:3000 just works.

Middleware: The Heart of Subdomain Routing

Next.js middleware runs at the edge before your page renders. That makes it the perfect place to read the subdomain, look up the tenant, and either rewrite the request or redirect. Here's the pattern we ship:

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

// Domains we want to completely ignore for tenant lookup
const RESERVED_SUBDOMAINS = new Set(['www', 'app', 'api', 'admin', 'mail'])

function getTenantSlug(request: NextRequest): string | null {
  const hostname = request.headers.get('host') || ''
  
  // Handle localhost and IP addresses — no subdomain
  if (hostname.includes('localhost') || hostname.includes('127.0.0.1')) {
    return null
  }

  // For lvh.me in development
  const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'yourapp.com'
  
  // e.g., hostname = 'acme.yourapp.com'
  // We want 'acme'
  if (!hostname.endsWith(`.${rootDomain}`)) {
    return null
  }

  const subdomain = hostname.slice(0, -(rootDomain.length + 1))
  
  // Bail on reserved subdomains and anything with dots (nested subdomains)
  if (RESERVED_SUBDOMAINS.has(subdomain) || subdomain.includes('.')) {
    return null
  }

  return subdomain || null
}

export function middleware(request: NextRequest) {
  const tenantSlug = getTenantSlug(request)

  if (!tenantSlug) {
    // No tenant context — serve the marketing/main app
    return NextResponse.next()
  }

  const url = request.nextUrl.clone()
  
  // Rewrite to a /[tenant] route group without changing the URL
  // User sees: acme.yourapp.com/dashboard
  // Next.js renders: /tenant/acme/dashboard
  url.pathname = `/tenant/${tenantSlug}${url.pathname}`
  
  return NextResponse.rewrite(url)
}

export const config = {
  matcher: [
    // Skip static files, api routes that handle auth themselves, etc.
    '/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)',
  ],
}

A few things worth calling out here. The RESERVED_SUBDOMAINS set saves you from routing www.yourapp.com into your tenant lookup. Add whatever makes sense for your app. We've had a production incident where someone registered a tenant called 'mail' and broke email deliverability — now mail is reserved by default.

Use NextResponse.rewrite(), not redirect(). Rewriting keeps the user's URL intact while letting Next.js render a completely different route. Redirecting would expose your internal routing structure and break bookmarks.

The File Structure That Makes This Work

The rewrite in your middleware sends requests to /tenant/[slug]/... so your folder structure needs to match. Here's what we use:

// File structure:
// app/
//   (marketing)/          <- public site, no tenant
//     page.tsx
//     pricing/page.tsx
//   tenant/
//     [slug]/
//       layout.tsx        <- tenant shell, loads tenant data
//       page.tsx          <- tenant dashboard
//       settings/
//         page.tsx

// app/tenant/[slug]/layout.tsx
import { notFound } from 'next/navigation'
import { getTenantBySlug } from '@/lib/tenants'

interface TenantLayoutProps {
  children: React.ReactNode
  params: { slug: string }
}

export default async function TenantLayout({ children, params }: TenantLayoutProps) {
  const tenant = await getTenantBySlug(params.slug)
  
  if (!tenant) {
    notFound()
  }

  return (
    <TenantProvider tenant={tenant}>
      <div style={{ '--brand-color': tenant.brandColor } as React.CSSProperties}>
        {children}
      </div>
    </TenantProvider>
  )
}

// This makes the tenant available everywhere in the subtree
// without prop drilling

The layout does the tenant lookup once and provides it to the whole subtree. Any page under /tenant/[slug]/ can call useTenant() and get the context. No prop drilling, no repeated database calls per page.

Passing Tenant Context Without Prop Drilling

The layout approach works for server components. But you'll also need tenant context in client components. Here's a lean context setup:

// lib/tenant-context.tsx
'use client'

import { createContext, useContext } from 'react'

interface Tenant {
  id: string
  slug: string
  name: string
  brandColor: string
  plan: 'starter' | 'pro' | 'enterprise'
}

const TenantContext = createContext<Tenant | null>(null)

export function TenantProvider({ 
  tenant, 
  children 
}: { 
  tenant: Tenant
  children: React.ReactNode 
}) {
  return (
    <TenantContext.Provider value={tenant}>
      {children}
    </TenantContext.Provider>
  )
}

export function useTenant(): Tenant {
  const tenant = useContext(TenantContext)
  if (!tenant) {
    throw new Error('useTenant must be used within a TenantProvider')
  }
  return tenant
}

// Usage in any client component:
// const { name, plan } = useTenant()
// if (plan === 'enterprise') { ... }

The throw in useTenant is intentional. If you forget to wrap something in TenantProvider, you want to know immediately, not get a mysterious null access error three components deep.

Auth: The Tricky Part Everyone Gets Wrong

Here's where multi-tenancy gets genuinely hard. Your auth tokens are usually scoped to a domain. A session cookie set on acme.yourapp.com doesn't get sent to globex.yourapp.com by default. This is actually correct security behavior — but it means you can't share sessions across tenants the naive way.

The two patterns that actually work: set your session cookie on the root domain (.yourapp.com, note the leading dot) so it's available across all subdomains, or issue tenant-specific JWTs that encode both the user ID and tenant ID. We generally prefer the root domain cookie approach with a tenant claim in the JWT payload. It keeps login at app.yourapp.com while allowing seamless navigation to any tenant subdomain the user has access to.

// When setting your auth cookie, use the root domain
// so it works across all subdomains

// In your auth callback / login route:
response.cookies.set('session', token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
  // The leading dot is what makes it cross-subdomain
  domain: process.env.NODE_ENV === 'production' 
    ? '.yourapp.com' 
    : undefined, // undefined = current hostname in dev
  path: '/',
  maxAge: 60 * 60 * 24 * 30, // 30 days
})

// Your JWT payload should include tenant memberships
// so you can verify access in middleware without a DB call:
const payload = {
  sub: user.id,
  email: user.email,
  tenants: user.memberships.map(m => ({
    slug: m.tenant.slug,
    role: m.role,
  }))
}

Encoding tenant memberships in the JWT means your middleware can do an access check without hitting the database on every request. The tradeoff: if you revoke access, the JWT still has the old memberships until it expires. For most apps, short expiry + refresh tokens handles this. For apps where immediate revocation matters (medical, financial), you'll want to hit the DB or a fast cache like Redis.

Environment Variables and the Local Dev Problem

One last thing that trips people up: environment variables. Your NEXT_PUBLIC_ROOT_DOMAIN needs to be right, or all your subdomain detection breaks.

// .env.local
NEXT_PUBLIC_ROOT_DOMAIN=lvh.me

// .env.production
NEXT_PUBLIC_ROOT_DOMAIN=yourapp.com

// In middleware, defensive access:
const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? 'yourapp.com'

// Quick test: spin up dev server and hit:
// http://acme.lvh.me:3000  <-- should route to tenant 'acme'
// http://lvh.me:3000       <-- should route to marketing site
// http://www.lvh.me:3000   <-- should route to marketing site (reserved)

Write those three test URLs on a sticky note and check them every time you change the middleware. We've shipped broken subdomain routing twice because we changed something unrelated in middleware and didn't do this basic sanity check. Both times it was caught in staging, but still embarrassing.

What to Watch Out For in Production

  • Tenant slug validation: enforce alphanumeric + hyphens only, max length ~63 chars (DNS limit), and block your reserved list at registration time, not just in middleware
  • 404 vs tenant-not-found: return a proper 404 page when a subdomain doesn't match any tenant, not a generic error — users will see this when tenants cancel
  • Wildcard SSL certificates: Vercel handles this automatically, on your own infra you'll need wildcard certs or Let's Encrypt's DNS challenge
  • Caching: if you cache tenant lookups (you should), make sure cache invalidation works when tenants update their settings or get suspended
  • Rate limiting per tenant: run-of-the-mill rate limiting by IP breaks if many users are behind the same NAT — rate limit by tenant ID instead

The tenant slug validation one burned us specifically. A user tried to register 'api' as their tenant slug, which would have routed all /api/* calls through the tenant layout. We caught it in code review but added it to the reserved list immediately.

If you want to skip the setup work and start with this architecture already in place, our SaaS template at peal.dev ships with subdomain routing, tenant context, and the auth cookie pattern all wired up — it's the starting point we wish we'd had the first time we built this.

The actual hard part of multi-tenancy isn't the routing — it's making sure every database query is scoped to the right tenant. One missing WHERE tenant_id = ? and you have a data leak. Build a query helper that enforces tenant scoping and make it the only way to access tenant data.

That last point is worth sitting with. Your middleware can be perfect and your routing bulletproof, but if your data layer isn't tenant-aware by default — not opt-in, default — you'll eventually expose one tenant's data to another. Row-level security in Postgres, or a service layer that always filters by tenant ID, are both solid approaches. Pick one and be consistent about it.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders