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

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.
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.
| 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 |
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',
}
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
}
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.
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').
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.
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.
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.