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.


After 12 months: would I do it again?

Yes, but only because the client required subpaths. If you can use subdomains instead, do that. It's simpler.

What actually works well: security is solid (zero data leaks between tenants), deployment is clean (one build, all cities), and adding a new tenant takes about 30 minutes.

What hurts: build times went from 2 minutes to 8-15 minutes. I'm manually tracking 70+ routes for cache invalidation. Static generation is basically off the table. And client-side navigation between route groups causes full page reloads.

If your client demands subpaths and you have 10-20 tenants with identical structure, this approach works. If you're past 30 tenants or need true SSG, look elsewhere. And if subdomains are an option? Take it.


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

Interested in Collaborating?

Whether it's a startup idea, a technical challenge, or a potential partnership—let's have a conversation.

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