Production-Ready Multi-Tenant Setup with Next.js & Payload
Hardcoded tenant routing with Next.js middleware and Payload CMS for isolated, high-performance enterprise deployments

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
This guide focuses on implementing a high-performance, hardcoded multi-tenant architecture with minimal overhead for predetermined tenants. Perfect for enterprise applications with a fixed number of tenants.
Overview
The architecture combines Next.js middleware-based routing with Payload CMS's multi-tenant plugin to achieve complete data isolation while maintaining optimal performance through hardcoded tenant configuration.
1. Next.js Configuration (next.config.ts)
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Tenant routing handled by middleware for better performance
// No rewrites needed here - middleware handles everything
experimental: {
serverActions: {
bodySizeLimit: '500mb',
},
},
// Optional: Image optimization for tenant-specific domains
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**.yourdomain.com", // Your tenant domains
},
],
},
};
export default nextConfig;
Key Points:
- No rewrites in config: Middleware handles all routing more efficiently
- Domain whitelisting: Pre-configure tenant domains for image optimization
- Performance: Middleware runs at edge before Next.js routing
2. Middleware Implementation (src/middleware.ts)
This is the core of the tenant routing system:
import { NextResponse, type NextRequest } from 'next/server';
export async function middleware(req: NextRequest) {
const url = req.nextUrl;
const { pathname } = url;
const hostname = req.headers.get('host') || '';
// ===== HARDCODED TENANT CONFIGURATION =====
// This approach eliminates database lookups for maximum performance
const tenantConfig = {
// Production tenants
'tenant1.yourdomain.com': {
slug: 'tenant1',
name: 'Tenant One',
previewMode: true
},
'tenant2.yourdomain.com': {
slug: 'tenant2',
name: 'Tenant Two',
previewMode: true
},
// Development tenants
'tenant1.localhost:3000': {
slug: 'tenant1',
name: 'Tenant One Dev',
previewMode: true
},
'tenant2.localhost:3000': {
slug: 'tenant2',
name: 'Tenant Two Dev',
previewMode: true
},
// Default fallback (optional)
'localhost:3000': {
slug: 'tenant1',
name: 'Default Dev',
previewMode: true
}
};
const tenant = tenantConfig[hostname];
const isPreview = url.searchParams.get('preview') === 'true';
// ===== ROUTING LOGIC =====
if (tenant &&
!pathname.startsWith('/_next') &&
!pathname.startsWith('/api') &&
!pathname.startsWith('/admin') &&
!pathname.includes('.')) {
// Route to appropriate handler based on preview mode
const routePrefix = tenant.previewMode && isPreview
? 'tenant-slugs-preview'
: 'tenant-slugs';
const slugPath = pathname === '/' ? '/home' : pathname;
// Rewrite to tenant-specific route
const newUrl = new URL(
`/${routePrefix}/${tenant.slug}${slugPath}`,
req.url
);
// Preserve query parameters
newUrl.search = url.search;
return NextResponse.rewrite(newUrl);
}
return NextResponse.next();
}
export const config = {
matcher: [
// Match all paths except static assets and API routes
'/((?!api|_next|_static|_vercel|[\\w-]+\\.\\w+).*)',
],
};
For local development setup with domain mapping and testing, see Multi-Tenant Development Environment: 4-Step Local Guide.
Performance Benefits:
- Zero database lookups: Hardcoded configuration eliminates runtime queries
- Edge execution: Runs at CDN edge for minimal latency
- Type safety: Full TypeScript support for tenant configuration
3. Payload CMS Configuration (payload.config.ts)
import { buildConfig } from 'payload';
import { postgresAdapter } from '@payloadcms/db-postgres';
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant';
const config = buildConfig({
// Database configuration
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL,
},
migrationDir: './src/migrations',
}),
// Multi-tenant plugin configuration
plugins: [
multiTenantPlugin({
enabled: true,
debug: process.env.NODE_ENV === 'development',
cleanupAfterTenantDelete: true,
tenantsSlug: 'tenants',
// Tenant field access control
tenantField: {
access: {
read: ({ req }) => !!req.user,
update: ({ req }) => req.user?.roles?.includes('super-admin'),
create: ({ req }) => req.user?.roles?.includes('super-admin'),
},
},
// User-tenant relationship
tenantsArrayField: {
includeDefaultField: true,
arrayFieldName: 'tenants',
arrayTenantFieldName: 'tenant',
},
// Super admin access
userHasAccessToAllTenants: (user) =>
user?.roles?.includes('super-admin'),
// Tenant-aware collections
collections: {
// List all collections that should be tenant-isolated
pages: {},
posts: {},
media: {},
products: {},
users: {}, // Users can be assigned to specific tenants
// ... other collections
},
}),
],
// Admin preview configuration
admin: {
livePreview: {
url: ({ data, req }) => {
if (!data?.tenant) return null;
const tenantSlug = typeof data.tenant === 'string'
? data.tenant
: data.tenant?.slug;
if (!tenantSlug) return null;
const protocol = req?.protocol || 'https';
const host = req?.host || 'yourdomain.com';
return `${protocol}//${host}/tenant-slugs-preview/${tenantSlug}`;
},
collections: ['pages', 'posts', 'products'], // Preview-enabled collections
},
},
});
export default config;
4. Frontend Route Structure
Directory Organization:
src/app/
├── (frontend)/
│ ├── tenant-slugs/
│ │ └── [tenant]/
│ │ ├── layout.tsx # Tenant-specific layout
│ │ └── [...slug]/
│ │ └── page.tsx # Dynamic page handler
│ └── tenant-slugs-preview/
│ └── [tenant]/
│ └── [...slug]/
│ └── page.tsx # Preview mode handler
Page Implementation (src/app/(frontend)/tenant-slugs/[tenant]/[...slug]/page.tsx):
import { notFound } from "next/navigation";
import { queryPageBySlug } from "@/payload/db";
type Props = {
params: Promise<{ slug?: string[]; tenant: string }>;
};
export default async function TenantPage({ params: paramsPromise }: Props) {
const { slug, tenant } = await paramsPromise;
// Normalize slug (handle root path)
const normalizedSlug = !slug || slug.length === 0 ? ["home"] : slug;
// Query page with tenant context
const page = await queryPageBySlug({
slug: normalizedSlug,
tenant,
draft: false
});
if (!page) {
return notFound();
}
// Render page blocks
return <RenderPageBlocks blocks={page.layout} />;
}
// Static generation with tenant context
export async function generateStaticParams() {
// Generate static paths for all tenants
const tenants = ['tenant1', 'tenant2']; // Hardcoded list
const paths = [];
for (const tenant of tenants) {
// Get tenant-specific pages
const pages = await getTenantPages(tenant);
paths.push(...pages.map(page => ({
tenant,
slug: page.slug.split('/')
})));
}
return paths;
}
Layout Implementation (src/app/(frontend)/tenant-slugs/[tenant]/layout.tsx):
export default async function TenantLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ tenant: string }>;
}) {
const { tenant } = await params;
// Fetch tenant-specific data
const [navbar, footer, businessInfo] = await Promise.all([
getNavbar(tenant),
getFooter(tenant),
getBusinessInfo(),
]);
return (
<div>
<Navbar data={navbar} tenant={tenant} />
{children}
<Footer data={footer} tenant={tenant} />
</div>
);
}
Metadata Generation
For SEO metadata generation across routes with tenant-specific titles, descriptions, and image URLs, see Multi-Tenant SEO with Payload & Next.js — Complete Guide.
5. Production Deployment Considerations
Environment Variables:
# Database
DATABASE_URL=postgresql://...
# Payload
PAYLOAD_SECRET=your-secret-key
# Tenant domains (optional, for validation)
ALLOWED_TENANT_DOMAINS=tenant1.yourdomain.com,tenant2.yourdomain.com
Vercel Configuration (vercel.json):
{
"framework": "nextjs",
"regions": ["iad1"], // Single region for consistency
"functions": {
"src/middleware.ts": {
"runtime": "edge"
}
}
}
6. Development Environment Setup
Local Hosts Configuration:
Add to /etc/hosts (or C:\Windows\System32\drivers\etc\hosts on Windows):
127.0.0.1 tenant1.localhost
127.0.0.1 tenant2.localhost
For complete local development setup including domain mapping, development domain utilities, and testing procedures, see Multi-Tenant Development Environment: 4-Step Local Guide.
Development Script (package.json):
{
"scripts": {
"dev": "next dev -p 3000",
"dev:tenants": "next dev -p 3000 & next dev -p 3001"
}
}
Development Middleware Adjustment:
// In src/middleware.ts, add development detection
const isDevelopment = process.env.NODE_ENV === 'development';
const tenantConfig = {
// Production domains
...(isDevelopment ? {} : {
'tenant1.yourdomain.com': { slug: 'tenant1', previewMode: true },
'tenant2.yourdomain.com': { slug: 'tenant2', previewMode: true },
}),
// Development domains
...(isDevelopment ? {
'tenant1.localhost:3000': { slug: 'tenant1', previewMode: true },
'tenant2.localhost:3000': { slug: 'tenant2', previewMode: true },
} : {}),
};
7. Performance Optimization
Caching Strategy:
// In database functions
export const getTenantPages = unstable_cache(
async (tenant: string) => {
// Fetch tenant-specific pages
},
[`tenant-pages`],
{
tags: ['pages'],
revalidate: 3600, // 1 hour
}
);
CDN Benefits:
- Middleware runs at edge locations
- Static generation per tenant
- Tenant-specific caching strategies
8. Scaling Considerations
For larger tenant counts (50+), consider:
Alternative: Vercel Edge Config
// Instead of hardcoded config
import { get } from '@vercel/edge-config';
const tenant = await get(`tenants.${hostname}`);
Benefits:
- Zero deployment updates for tenant changes
- Global replication
- Sub-millisecond lookups
But this is overkill for fixed tenant lists where hardcoded configuration provides better performance and type safety.
9. Security Best Practices
Tenant Isolation Validation:
// In API routes
export async function GET(req: NextRequest) {
const tenant = req.headers.get('x-tenant');
if (!isValidTenant(tenant)) {
return new Response('Invalid tenant', { status: 400 });
}
// Proceed with tenant-scoped query
}
Environment-Specific Configuration:
const allowedHostnames = process.env.ALLOWED_TENANT_DOMAINS?.split(',') || [];
if (!allowedHostnames.includes(hostname) && !isDevelopment) {
return NextResponse.error();
}
10. Monitoring and Debugging
Tenant Logging:
// In middleware
console.log(`[Tenant Routing] ${hostname} -> ${tenant?.slug || 'not-found'}`);
// In page handlers
console.log(`[Page Request] Tenant: ${tenant}, Path: ${pathname}`);
// In database queries
console.log(`[DB Query] Fetching page for tenant: ${tenant}, slug: ${slug}`);
Performance Metrics:
- Track middleware execution time
- Monitor cache hit rates per tenant
- Measure database query performance with tenant filters
Summary
This production-ready multi-tenant setup provides:
- Maximum Performance: Hardcoded configuration eliminates runtime lookups
- Complete Data Isolation: Payload CMS plugin ensures database-level separation
- Clean URLs: Domain-based routing with middleware
- Type Safety: Full TypeScript support
- Development Efficiency: Local domain mapping for realistic testing
- Scalability: Easy to add new tenants without code changes
The approach is ideal for enterprise applications with a predetermined number of tenants, offering the best balance of performance, security, and maintainability.
Related Guides:
- Multi-Tenant Development Environment: 4-Step Local Guide — for local testing setup
- Multi-Tenant SEO with Payload & Next.js — Complete Guide — for SEO metadata generation