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