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

·Matija Žiberna·
Production-Ready Multi-Tenant Setup with Next.js & Payload

⚡ 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.

No spam. Unsubscribe anytime.

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:

  1. Maximum Performance: Hardcoded configuration eliminates runtime lookups
  2. Complete Data Isolation: Payload CMS plugin ensures database-level separation
  3. Clean URLs: Domain-based routing with middleware
  4. Type Safety: Full TypeScript support
  5. Development Efficiency: Local domain mapping for realistic testing
  6. 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:

0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.