Multi-tenant apps with Next.js

I've been part of the media economy in my hometown Cologne for over a decade, so when Timo, the CEO of Busch Glatz, reached out to Stefan and me in 2022, the context was familiar. Busch Glatz was a German B2B media company that had grown through acquisitions, consolidating trade publications for the German entertainment and media industries under one roof. Brands like MEEDIA, Blickpunkt:Film, GamesMarkt, and MusikWoche, each serving a different industry vertical with daily news, analysis, and premium content.

They had been developing a new unified platform for these publications for almost two years with a dozen developers. From what Timo told us, the project had likely burned a seven-figure sum at that point, and they were stuck. The brief for us: help re-architect the approach and get it to production in a short timeframe.

The core requirement was scalability across brands. Busch Glatz's business model was to grow through further acquisitions, so the platform needed to accommodate new publications without requiring separate infrastructure for each one.

After a week of concept work and a two-day proof of concept, we landed on a multi-tenant Next.js application: a single codebase deployed once, with all brand domains pointing to the same deployment.

What the user sees

From the outside, each brand looked and felt like its own website. The header layout, the color scheme, the typography, the logo — all configured per brand. The sites shared a component library and overall structure, which was visible in the consistent navigation patterns and cross-linking between publications.

MEEDIA homepage
Blickpunkt:Film, MEEDIA, GamesMarkt, MusikWoche - same platform, different brands

Each brand had its own visual identity configured through CSS custom properties, font families, and layout templates. The homepage of a daily news publication like MEEDIA could look different from a weekly trade magazine like MusikWoche, even though they ran on the same component library.

Article pages followed the same principle. The reading experience adapted to the brand's typography and color scheme, while the underlying components (article header, body, sidebar, related content) were shared across all tenants.

Article detail page with brand-specific styling
An article detail page — brand-specific styling on shared components

All of this was driven by a single identifier: the siteId.

The architecture

The app was structured as a monorepo with a single Next.js application. The route tree lived under a dynamic segment:

app/
  sites/
    [siteId]/
      page.tsx
      [slug]/
        page.tsx
      layout.tsx

Every route received siteId as a parameter. Components used it to load the right content from the CMS, apply the correct theme, and render the appropriate navigation. The key question was: how does the siteId get set? The user visits meedia.de, not app.example.com/sites/meedia.

The middleware rewrite

The mapping from domain to tenant happened in middleware.ts (renamed to proxy.ts in Next.js 16). The middleware ran before every request and rewrote the URL before the route tree saw it. The entire multi-tenant routing came down to this:

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

export function middleware(request: NextRequest) {
  // Users should not be able to canonically access the /sites folder
  if (request.nextUrl.pathname.startsWith(`/_sites`)) {
    return new Response(null, { status: 404 })
  }

  // Get site by domain
  const site = getSiteByDomain(request.nextUrl.hostname)
  if (site) {
    // Rewrite the response to the internal sites route
    return NextResponse.rewrite(new URL(`/sites/${site.id}`, request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: [
    '/((?!api|_next|[\\w-]+\\.\\w+).*)',
  ],
}

getSiteByDomain() mapped the incoming hostname to a site configuration. The NextResponse.rewrite() sent the request to /sites/${site.id} internally, but the user's URL stayed unchanged. A visitor on meedia.de/some-article saw that URL in their browser, while Next.js internally routed to /sites/meedia/some-article.

The NextResponse.rewrite() call was the entire multi-tenant routing mechanism. One rewrite in the middleware, and siteId was available as a regular dynamic route parameter throughout the application. Content queries filtered by siteId, styles loaded per siteId, navigation rendered per siteId.

Scaling to more brands

This was the architecture's primary purpose. Adding a new brand to the platform required four things:

  • A domain mapping in getSiteByDomain()
  • A brand configuration (colors, fonts, logo, layout templates)
  • Content in the CMS tagged with the new siteId
  • DNS pointing the new domain to the deployment

Adding a new publication meant adding a configuration entry and pointing the domain. The same application instance served all brands without a new deployment or separate infrastructure, and each one maintained its distinct identity through configuration.

If I built this today with Cache Components, the content queries would benefit from "use cache" with per-tenant cache tags. A call like getArticles(siteId) could be cached and invalidated per brand, so a CMS update on MEEDIA wouldn't affect the cache for Blickpunkt:Film. The architecture would be the same, but the caching story would be cleaner.

Disclaimer

In 2023, Busch Glatz filed for bankruptcy. Most of the B2B publications have since been sold and are operated on new platforms by their respective new owners.