Web DevelopmentMulti-tenantNext.jsSaaSArchitecture

Multi-Tenant Architecture with a Single Next.js Deployment

How env vars + Payload CMS power unlimited tenant sites from a single Next.js codebase and one Vercel deployment.

March 31, 2026

multitenant nextjs single deploy

Running five separate Next.js codebases for five clients sounds manageable until the third time you backport the same bug fix to all of them. Multi-tenant architecture from a single deployment solves this: one codebase, one CI/CD pipeline, as many tenants as you need — each with its own content, theme, and domain.

The Architecture in One Sentence

A single Next.js deployment reads a TENANT_SLUG environment variable at startup, fetches that tenant's configuration from Payload CMS, and renders everything — content, theme colors, site metadata — accordingly. Adding a new tenant means creating a database record and deploying a new Vercel project with a different env var. No code changes.

What Lives Where

| Layer | Location | What it knows | |-------|----------|---------------| | Tenant config | Payload CMS Tenants collection | slug, name, theme colors, site metadata | | Content | Payload CMS Posts collection | scoped by tenant relationship | | Routing | Next.js | reads NEXT_PUBLIC_TENANT_SLUG, fetches from CMS | | Deployment | Vercel | one project per tenant, same codebase |

The Tenant Configuration

Every tenant has a record in the Tenants collection:

{
  id: 1,
  slug: 'arunabh-blog',
  name: 'Arunabh Blog',
  description: 'Notes on building software',
  theme: {
    primary: '#0ea5e9',
    secondary: '#6366f1',
    accent: '#f59e0b',
  },
  siteTitle: 'Arunabh Blog',
  siteDescription: 'Engineering notes from the build',
  status: 'active',
}

Fetching and Caching Tenant Config

The tenant config is fetched once and cached. It changes rarely, so a 5-minute cache is appropriate:

// apps/web/src/lib/payload-client.ts
const TENANT_SLUG = process.env.NEXT_PUBLIC_TENANT_SLUG

if (!TENANT_SLUG) {
  throw new Error('NEXT_PUBLIC_TENANT_SLUG is required')
}

export async function getTenant() {
  const res = await fetch(
    `${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/tenants?where[slug][equals]=${TENANT_SLUG}&limit=1`,
    { next: { revalidate: 300 } }
  )
  const data = await res.json()
  return data.docs?.[0] ?? null
}

Injecting Theme at the Layout Level

The root layout fetches the tenant and injects CSS variables:

// app/layout.tsx
import { getTenant } from '@/lib/payload-client'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const tenant = await getTenant()

  const theme = tenant?.theme ?? {
    primary: '#0ea5e9',
    secondary: '#6366f1',
    accent: '#f59e0b',
  }

  return (
    <html lang="en">
      <body
        style={{
          '--color-primary': theme.primary,
          '--color-secondary': theme.secondary,
          '--color-accent': theme.accent,
        } as React.CSSProperties}
      >
        {children}
      </body>
    </html>
  )
}

In your Tailwind config, reference these variables:

// tailwind.config.ts
export default {
  theme: {
    extend: {
      colors: {
        primary: 'var(--color-primary)',
        secondary: 'var(--color-secondary)',
        accent: 'var(--color-accent)',
      },
    },
  },
}

Now bg-primary, text-primary, etc. resolve to the tenant's colors at runtime. Every tenant gets a different color scheme from the same CSS classes.

Dynamic Metadata per Tenant

Override metadata using generateMetadata in the root layout or page:

// app/layout.tsx
export async function generateMetadata(): Promise<Metadata> {
  const tenant = await getTenant()
  return {
    title: {
      default: tenant?.siteTitle ?? 'Blog',
      template: `%s | ${tenant?.siteTitle ?? 'Blog'}`,
    },
    description: tenant?.siteDescription ?? '',
    metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3001'),
  }
}

The template pattern means page.tsx files can set just the page title ('TypeScript Generics') and the layout appends the site name ('TypeScript Generics | Arunabh Blog').

Filtering Content by Tenant

Every content query scopes to the current tenant:

export async function getPosts() {
  const tenant = await getTenant()
  if (!tenant) return []

  const res = await fetch(
    `${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/posts` +
    `?where[tenant][equals]=${tenant.id}` +
    `&where[status][equals]=published` +
    `&sort=-publishedAt` +
    `&limit=50`,
    { next: { revalidate: 60 } }
  )
  const data = await res.json()
  return data.docs ?? []
}

The tenant relationship field on every post is what makes isolation work. Without it, you'd be returning every post from every tenant to every site.

Deployment Setup

Each tenant is a separate Vercel project pointing at the same GitHub repository:

Repository: github.com/yourname/blog-monorepo
├── Vercel Project: "arunabh-blog"
│   ├── Root Directory: apps/web
│   └── Env: NEXT_PUBLIC_TENANT_SLUG=arunabh-blog
│          NEXT_PUBLIC_PAYLOAD_URL=https://cms.arunabh.me
│          NEXT_PUBLIC_APP_URL=https://blog.arunabh.me
└── Vercel Project: "tech-blog"
    ├── Root Directory: apps/web
    └── Env: NEXT_PUBLIC_TENANT_SLUG=tech
           NEXT_PUBLIC_PAYLOAD_URL=https://cms.arunabh.me
           NEXT_PUBLIC_APP_URL=https://tech.example.com

Deploying a fix: push to master once, both Vercel projects redeploy automatically.

What This Approach Doesn't Do

Subdomain routing from a single deployment. This architecture uses env vars, not request headers. You can't serve tenant-a.yourdomain.com and tenant-b.yourdomain.com from one Vercel project. That requires middleware-based routing on a different stack. The env var approach trades that flexibility for simplicity — each tenant gets its own deployment, which is also its own cache boundary.

Per-tenant feature flags. All tenants run the same code. If you need some tenants to have features others don't, you'd add feature fields to the Tenants collection and gate UI components on them.

Key Takeaways

  • One codebase, one repo, many Vercel deployments — each deployment is a single env var different
  • Tenant config (theme, metadata) fetched from CMS at startup with a 5-minute cache
  • CSS variables injected at layout level let Tailwind classes resolve to tenant-specific colors
  • Every content query must filter by tenant ID — this is the isolation mechanism
  • Adding a tenant is a database operation; removing a tenant is deactivating its record