Multi-Tenant Development Environment: 4-Step Local Guide

Set up production-like local domains with Next.js and Payload CMS to test tenant routing, previews, and SEO locally.

·Matija Žiberna·
Multi-Tenant Development Environment: 4-Step Local Guide

⚡ 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 explains how to set up a local development environment that mimics production for multi-tenant applications using Payload CMS and Next.js. The approach uses local domain mapping and middleware rewrites to simulate production domains during development.

Overview

When building multi-tenant applications, you need to test how different tenants (domains) behave in production. Instead of using subdirectories or query parameters, this setup allows you to:

  • Access different tenants via local domains (e.g., tenant-a.local, tenant-b.local)
  • Test production-like URL structures locally
  • Ensure middleware, routing, and tenant resolution work exactly like production
  • Debug domain-specific features without deploying

Quick Start Checklist

  1. ✅ Edit /etc/hosts → add local domains
  2. ✅ Start dev server on port 80 (or configure port)
  3. ✅ Access http://tenant-a.local in browser
  4. ✅ Verify middleware rewrites in Network tab

Step 1: Configure Local Domain Mapping

First, map your production domains to local development domains using your system's hosts file.

Edit /etc/hosts File

sudo nano /etc/hosts

Add these entries to map local domains to localhost:

# Multi-tenant development domains
127.0.0.1 tenant-a.local
127.0.0.1 tenant-b.local

Why This Works

  • These entries tell your system to resolve tenant-a.local and tenant-b.local to 127.0.0.1 (localhost)
  • Your browser will send the host header with the domain name to your local Next.js server
  • This allows your middleware to identify tenants by hostname, just like in production

Step 2: Configure Next.js Middleware

The middleware handles the core logic of routing requests to the correct tenant based on the hostname.

File: src/middleware.ts

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') || '';

  // Simplified middleware structure - full implementation with auth, preview mode, and production domains:
  // See [Production-Ready Multi-Tenant Setup](/blog/production-ready-multi-tenant-nextjs-payload#middleware-implementation)

  const tenantMap = {
    'tenant-a.local': 'tenant-a',
    'tenant-b.local': 'tenant-b',
    // Add localhost for testing purposes
    'localhost:3000': 'tenant-b', // Test tenant-b locally
  };

  const tenant = tenantMap[hostname];
  const isPreview = url.searchParams.get('preview') === 'true';

  // Middleware rewrites requests to /tenant-slugs/[tenant]/[...slug]
  if (
    tenant &&
    !pathname.startsWith('/_next') &&
    !pathname.startsWith('/api') &&
    !pathname.startsWith('/static') &&
    !pathname.startsWith('/admin') &&
    !pathname.includes('.') // Exclude files like favicon.ico, robots.txt
  ) {
    // Construct the new URL: /tenant-slugs[-preview]/[tenant]/[...slug]
    // If path is '/', it becomes /tenant-slugs-preview/tenant-b/home
    const routePrefix = isPreview ? 'tenant-slugs-preview' : 'tenant-slugs';
    const slugPath = pathname === '/' ? '/home' : pathname;
    const newUrl = new URL(
      `/${routePrefix}/${tenant}${slugPath}`,
      req.url
    );

    // Preserve query parameters
    newUrl.search = url.search;

    // Rewrite the request to the internal tenant folder
    return NextResponse.rewrite(newUrl);
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for:
     * 1. /api routes
     * 2. /_next (Next.js internals)
     * 3. /_static (inside /public)
     * 4. /_vercel (Vercel internals)
     * 5. Static files (e.g. /favicon.ico, /sitemap.xml)
     */
    '/((?!api|_next|_static|_vercel|[\\w-]+\\.\\w+).*)',
  ],
};

Full middleware implementation with auth, preview mode, and production domains: Production-Ready Multi-Tenant Setup

How It Works

  1. Hostname Detection: The middleware extracts the hostname from the request headers
  2. Tenant Mapping: Maps hostnames to tenant slugs using the tenantMap object
  3. URL Rewriting: Rewrites the request to the internal Next.js route structure
  4. Preview Support: Handles both production and preview modes

Key Features

  • Production Mimicry: Uses the same hostname detection as production
  • Multi-Protocol Support: Works with both .local and production domains
  • Preview Mode: Supports draft preview functionality
  • Static Asset Handling: Excludes static assets and API routes from tenant routing

Step 3: Development Domain Utility

Create a utility to map production domains to development domains when running locally.

File: src/payload/utilities/getDevelopmentDomain.ts

/**
 * Map production domains to development domains for local testing
 */
export const getDevelopmentDomain = (domain: string): string => {
  const devDomainMap: Record<string, string> = {
    'tenant-a.vercel.app': 'tenant-a.local',
    'tenant-b.vercel.app': 'tenant-b.local',
  };

  return devDomainMap[domain] || domain;
};

Usage in SEO and URL Generation

This utility is used in places where URLs need to be generated for the current environment. The getDevelopmentDomain() utility maps production domains to local test domains. See Development Environment Guide for implementation.

Example usage in SEO utilities:

// Example usage in SEO utilities
const isDevelopment = process.env.NODE_ENV === 'development';
const finalDomain = isDevelopment ? getDevelopmentDomain(tenant.domain) : tenant.domain;
const protocol = isDevelopment ? 'http' : 'https';
const baseUrl = `${protocol}://${finalDomain}`;

Step 4: Development Server Configuration

When starting your development server, make sure it's configured to handle the custom domains.

Start Development Server

# Using pnpm (as per project conventions)
pnpm dev

The server typically starts on port 3000, but you can configure it in your package.json:

{
  "scripts": {
    "dev": "next dev -p 80"
  }
}

Why Port 80?

  • Running on port 80 allows you to access the domains without specifying a port
  • http://tenant-a.local instead of http://tenant-a.local:3000
  • More closely mimics production where sites run on standard HTTP/HTTPS ports

Testing the Setup

1. Verify Domain Resolution

Open these URLs in your browser:

  • http://tenant-a.local - Should show the Tenant A
  • http://tenant-b.local - Should show the Tenant B
  • http://localhost:3000 - Should work (fallback behavior)

2. Check Middleware Behavior

Look at the network requests in your browser dev tools:

  • Request URL: http://tenant-a.local/
  • Rewritten to: http://localhost:3000/tenant-slugs/tenant-a/home
  • The response should show content for the Tenant A

3. Test Production Features

Test features that depend on tenant identification:

  • SEO Metadata: Check that titles include the correct tenant name. For detailed SEO testing with tenant-specific metadata and OG images, see Multi-Tenant SEO with Payload & Next.js
  • Branding: Verify tenant-specific logos, colors, and content
  • Routing: Ensure internal links use the correct domain
  • API Calls: Confirm API requests include the correct tenant context

Troubleshooting

Domain Not Resolving

Issue: Browser shows "Server not found" or DNS error

Solution:

  1. Verify your /etc/hosts entries are correct
  2. Flush your DNS cache:
    # On macOS
    sudo dscacheutil -flushcache
    
    # On Linux
    sudo systemd-resolve --flush-caches
    
  3. Try accessing http://127.0.0.1 to ensure the server is running

Middleware Not Triggering

Issue: Tenant routing not working, showing default content

Solution:

  1. Check the middleware matcher pattern
  2. Verify the hostname is being correctly extracted
  3. Add console logging to the middleware:
    console.log('Middleware called with hostname:', hostname);
    

Mixed Content Errors

Issue: HTTPS resources on HTTP development domains

Solution:

  • Use the getDevelopmentDomain utility for all URL generation
  • Ensure all internal links use protocol-relative URLs
  • Configure your CMS to generate correct URLs for the environment

Port Conflicts

Issue: Port 80 requires sudo or is already in use

Solution:

  1. Use a different port and update your domains:
    127.0.0.1 tenant-a.local:3000
    127.0.0.1 tenant-b.local:3000
    
  2. Or use a proxy like nginx to forward port 80 to your app

Production Deployment Considerations

When deploying to production:

  1. Update Middleware: Ensure only production domains are in the tenant map
  2. Update DNS: Configure actual domain DNS records
  3. SSL Certificates: Set up HTTPS for all tenant domains
  4. Environment Variables: Configure production-specific settings
  5. Monitor Logs: Watch for any development-specific code paths

For comprehensive production setup including hardcoded tenant configuration, edge middleware optimization, and Payload CMS multi-tenant plugin configuration, see Production-Ready Multi-Tenant Setup.

Benefits of This Approach

Development Advantages

  1. Realistic Testing: Test production-like URL structures locally
  2. Tenant Isolation: Clear separation between tenant data and logic
  3. No Workarounds: No need for subdirectories or query parameters
  4. SEO Testing: Verify domain-specific SEO metadata
  5. Feature Parity: Same code path as production

Maintenance Benefits

  1. Single Codebase: Same middleware handles dev and production
  2. Environment-Aware: Automatic switching between dev and prod domains
  3. Type Safety: Full TypeScript support with proper interfaces
  4. Debugging: Easy to debug tenant-specific issues locally

Alternative Approaches

Subdirectory Approach

Instead of domain-based routing, you could use subdirectories:

  • http://localhost:3000/tenant-a/...
  • http://localhost:3000/tenant-b/...

Pros: No hosts file modification needed Cons: Doesn't mimic production URL structure

Query Parameter Approach

Use query parameters to specify tenants:

  • http://localhost:3000/?tenant=tenant-a
  • http://localhost:3000/?tenant=tenant-b

Pros: Simplest setup Cons: Very different from production, SEO issues

Conclusion

This development environment setup provides a production-like experience for multi-tenant applications without complex infrastructure. By using local domain mapping and intelligent middleware, you can:

  • Test tenant-specific features realistically
  • Debug production-like URL structures
  • Maintain a single codebase for all environments
  • Ensure seamless transition from development to production

The key insight is that by mapping local domains to localhost and using middleware to handle routing, you create a development environment that behaves identically to production while keeping the setup simple and maintainable.

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.