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.

⚡ 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 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
- ✅ Edit
/etc/hosts→ add local domains - ✅ Start dev server on port 80 (or configure port)
- ✅ Access
http://tenant-a.localin browser - ✅ 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.localandtenant-b.localto127.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
- Hostname Detection: The middleware extracts the hostname from the request headers
- Tenant Mapping: Maps hostnames to tenant slugs using the
tenantMapobject - URL Rewriting: Rewrites the request to the internal Next.js route structure
- 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
.localand 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.localinstead ofhttp://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 Ahttp://tenant-b.local- Should show the Tenant Bhttp://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:
- Verify your
/etc/hostsentries are correct - Flush your DNS cache:
# On macOS sudo dscacheutil -flushcache # On Linux sudo systemd-resolve --flush-caches - Try accessing
http://127.0.0.1to ensure the server is running
Middleware Not Triggering
Issue: Tenant routing not working, showing default content
Solution:
- Check the middleware matcher pattern
- Verify the hostname is being correctly extracted
- 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
getDevelopmentDomainutility 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:
- Use a different port and update your domains:
127.0.0.1 tenant-a.local:3000 127.0.0.1 tenant-b.local:3000 - Or use a proxy like nginx to forward port 80 to your app
Production Deployment Considerations
When deploying to production:
- Update Middleware: Ensure only production domains are in the tenant map
- Update DNS: Configure actual domain DNS records
- SSL Certificates: Set up HTTPS for all tenant domains
- Environment Variables: Configure production-specific settings
- 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
- Realistic Testing: Test production-like URL structures locally
- Tenant Isolation: Clear separation between tenant data and logic
- No Workarounds: No need for subdirectories or query parameters
- SEO Testing: Verify domain-specific SEO metadata
- Feature Parity: Same code path as production
Maintenance Benefits
- Single Codebase: Same middleware handles dev and production
- Environment-Aware: Automatic switching between dev and prod domains
- Type Safety: Full TypeScript support with proper interfaces
- 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-ahttp://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.