By Shyam Verma

Next.js Middleware Multi-Tenancy: What The Docs Don't Tell You

Next.js Middleware Multi-Tenancy: What The Docs Don't Tell You

After migrating a multi-city festival platform from WordPress Multisite to Next.js, I chose middleware-based multi-tenancy to serve 10+ city sites from one codebase. Spoiler: It works, but not how the docs suggest.

This is the story of what I learned the hard way.


The Experiment: Can Middleware Replace Separate Apps?

The Context: The client required all city sites under subpaths (like /houston/events, /chicago/films) instead of subdomains. This constraint ruled out the simpler subdomain approach.

The Goal: Serve 10+ festival cities from a single Next.js codebase with clean subpath URLs—no subdomains, no separate deployments.

My Initial Assumptions (All Wrong):

❌ "Static generation will work fine with middleware rewrites"
❌ "Headers set in middleware are available everywhere"
❌ "Layouts can detect paths dynamically"
❌ "Build times scale linearly"
❌ "Cache invalidation is straightforward"

Reality after 12 months in production: None of these held true.


The Basic Implementation (The Easy Part)

The middleware concept is simple—took me 20 minutes to implement:

// middleware.ts - The part that actually works
import { NextResponse } from 'next/server'
import { isValidCity } from '@/lib/utils/cityValid'

export function middleware(req: NextRequest) {
  const { pathname, search } = req.nextUrl
  const citySlug = pathname.split('/')[1]

  // Rewrite city URLs: /houston/events → /city/houston/events
  const rewriteTo = isValidCity(citySlug)
    ? `/city${pathname}${search}`
    : `/main${pathname}${search}`

  const response = NextResponse.rewrite(new URL(rewriteTo, req.url))

  // Store city context in headers (spoiler: this doesn't work everywhere)
  response.headers.set('x-city', citySlug)
  response.headers.set('x-pathname', pathname)

  return response
}

Folder structure:

app/
├── main/              # Main site (example.com/films)
└── city/[website]/    # All city sites (/{city}/films)

This part was easy. User visits /houston/events, middleware rewrites to /city/houston/events, Next.js handles it like a normal dynamic route.

Data isolation via headless CMS: Perfect. City admins only see their data via role-based permission filters. Zero security leaks in 12 months of production.

Deployment wins: Single codebase. One build. All cities update simultaneously. Bug fixes apply everywhere instantly.

Now here's what broke...


What Actually Broke (My Journey of Fixes)

Mistake #1: Assuming Static Generation Would "Just Work"

What I tried:

I wrote generateStaticParams() to pre-generate all city pages at build time. The docs said middleware and SSG work together.

// app/city/[website]/page.tsx
export async function generateStaticParams() {
  const websites = await fetchWebsites()
  return websites.map(site => ({ website: site.id }))
}

What happened: Build failed. Pages weren't generated. Random 404s.

Why: Middleware rewrites happen at runtime. Build happens at build time. The build process doesn't know /houston/events becomes /city/houston/events until a user visits.

My fix: Killed static generation entirely. Went full dynamic.

export const dynamic = 'force-dynamic'      // Most pages
export const revalidate = 1800              // Slower-changing pages (30 min cache)

Cost: First page load is server-side rendered every time. No pre-built HTML. But with CDN caching, the impact is manageable.

Reference: This is a known issue. Multiple Stack Overflow threads and GitHub issues (#56253, #58171) document generateStaticParams() breaking with dynamic routes and export configs.

Learning: Middleware multi-tenancy and true SSG are fundamentally incompatible. Pick one.

Mistake #2: Thinking Headers Work Everywhere

What I tried:

I set x-pathname and x-city headers in middleware, thinking I could access them in layouts to switch navigation styles dynamically.

// middleware.ts
response.headers.set('x-city', citySlug)
response.headers.set('x-pathname', pathname)

// app/main/layout.tsx
const headersList = await headers()
const pathname = headersList.get('x-pathname')
const isStreamSite = pathname.startsWith('/stream')  // Show different nav

What happened: Headers were undefined, empty, or stale. Layouts rendered with wrong navigation.

Why: Next.js caches layouts separately. Sometimes layout renders before middleware runs. Headers aren't reliably available in layout context during SSR.

My fix: Gave up. Hardcoded layout logic.

// app/main/layout.tsx
// disabled: not correct way to do it, we will do it later
const isStreamSite = false  // Just hardcoded this

Had to create separate nested layouts (/main/stream/layout.tsx) instead of detecting path dynamically.

Cost: More boilerplate. Less flexibility. But it works consistently.

Reference: Well-documented on GitHub #44051 and Netlify forums. Headers set in middleware aren't readable via next/headers in server components.

Learning: Don't rely on middleware headers in layouts. Use explicit route structure instead.

Mistake #3: Naively Assuming Cache Invalidation Would Be Simple

What I tried:

Simple revalidatePath('/') after content updates in CMS.

What happened: Some pages updated. Some didn't. Stale content everywhere. No pattern.

Why: Middleware rewrites create two URLs for every page:

  • User sees: /houston/events
  • Next.js caches: /city/houston/events

Revalidating one doesn't revalidate the other.

My fix (took 3 attempts):

Built a custom API that manually tracks every route pattern and revalidates both static and dynamic paths:

// Custom cache clearing API endpoint
const staticPaths = ['/main', '/main/films', ...] // 50+ paths
const dynamicPaths = ['/city/[website]', '/city/[website]/events/[id_slug]', ...] // 20+ patterns

// Must use 'page' type for dynamic routes (learned this the hard way)
for (const path of dynamicPaths) {
  revalidatePath(path, 'page')
}

Cost:

  • Manual maintenance: Every new route must be added to this list
  • Takes 5-10 seconds to clear entire cache across all tenants
  • Easy to forget when adding new features

Reference: This is the #1 reported issue with multi-tenant Next.js. GitHub #59825 tracks revalidatePath not working with middleware rewrites. Discussion #35968 covers on-demand revalidation failures.

Learning: Middleware breaks Next.js's cache assumptions. You need explicit route tracking for every single path pattern.

Mistake #4: Build Times Will Scale Linearly (They Don't)

Before middleware: 2-3 minute builds
After 10+ city sites: 8-15 minute builds

Why: Every build must:

  • Fetch data for ALL cities from CMS
  • Generate metadata for 50+ static pages + 20+ dynamic patterns
  • Pre-render where possible with ISR

My fix: Added build_only mode to skip non-critical steps. Accepted 4-7x build time as cost of single deployment.

Reference: GitHub Discussion #17260 covers multi-tenancy and ISR scalability challenges. Vercel recommends moving away from build-time generation to runtime ISR for multi-tenant apps.

Learning: Middleware multi-tenancy builds scale O(n) with tenant count. Fine for 10-20 tenants. Painful for 50+. Impossible for 100+.


Mistake #5: Root Layout Can't Change Based on Middleware Decision

What I tried:

Single root layout.tsx that renders different layouts based on whether middleware detected a city or main site.

// app/layout.tsx (this approach FAILED)
export default async function RootLayout({ children }) {
  const isCity = // somehow detect from middleware

  return isCity ? <CityLayout>{children}</CityLayout> : <MainLayout>{children}</MainLayout>
}

What happened: Client-side navigation broke. Clicking links caused full page reloads or 404s on _next/data URLs.

Why: Next.js App Router requires root layouts to be static. When you change the root layout based on runtime logic (middleware), client-side navigation fails because:

  • The router doesn't know which layout to hydrate
  • Middleware rewrites happen server-side, but client navigation is client-side
  • Layout changes across navigations cause full page loads instead of SPA behavior

My fix: Split into separate route groups with distinct layouts.

// Structure that actually works:
app/
├── layout.tsx              // Minimal root (just <html>, <body>, scripts)
├── main/
│   └── layout.tsx          // Main site layout
└── city/[website]/
    └── layout.tsx          // City site layout

Cost:

  • Can't share layout logic easily between main and city sites
  • Code duplication in layout components
  • Each route group needs its own <html> and <body> tags

Reference: This is a documented limitation. GitHub Discussion #50034 covers multiple root layouts, and Issue #47112 specifically documents client-side navigation breaking with middleware. Issue #40549 shows rewrites not working on client-side navigation since v12.2+.

Learning: Root layouts must be static per route group. Middleware-based conditional layouts don't work. Use route groups (/main, /city) with separate layouts.


The Verdict After 12 Months in Production

Would I do it again? Yes—but only if forced to use subpaths.

What Actually Works

  • Zero security leaks (CMS permissions are solid)
  • Single deployment for 10+ sites
  • Bug fix once, fixed everywhere
  • Added new tenant in 30 minutes

What You'll Sacrifice

  • ❌ Static generation (forced to dynamic + ISR)
  • ❌ Fast builds (4-7x slower: 2min → 8-15min)
  • ❌ Automatic cache (70+ routes manually tracked)
  • ❌ Scalability (max 20-30 tenants before pain)
  • ❌ Client-side nav (full page loads between layouts)

The Decision Framework

Choose middleware multi-tenancy if:

  • Client requires subpaths (can't use subdomains)
  • You have 10-20 tenants, identical structure
  • Content changes frequently (SSG not critical)
  • Team has deep Next.js expertise

Use subdomains instead if:

  • You have that option (far simpler)
  • You need 50+ tenants (build times)
  • You need true SSG (marketing sites)
  • Team is small or learning Next.js

Bottom line: Middleware multi-tenancy trades elegant code for operational simplicity. It works—but it's a compromise, not a solution.


GitHub issues referenced in this article:

  • #56253 - generateStaticParams with export
  • #44051 - Middleware headers in server components
  • #59825 - revalidatePath with middleware
  • #47112 - Client-side navigation breaks with middleware
  • #40549 - Rewrites not working on client-side navigation
  • #50034 - Multiple root layouts
  • #17260 - Multi-tenancy and ISR

Ready to Build Something Exceptional?

Let's turn your idea into reality. Whether you need a fractional developer for ongoing work or want to discuss a specific project, I'm here to help.

20+
Years Experience
150K+
Websites Powered
2
Successful Exits
7x
Faster with AI