Multi-tenant applications need URL strategies that identify tenants while maintaining clean, shareable URLs. Choose between subdomains, path prefixes, or custom domains based on your product needs.
Key Takeaways
- 1Subdomains provide tenant isolation and branding
- 2Path prefixes are simpler to implement and deploy
- 3Custom domains offer white-label capabilities
- 4Consider SSL certificate management for each approach
- 5Tenant resolution should happen early in the request
“Multi-tenant applications must resolve tenant identity early in the request pipeline to ensure proper data isolation and tenant-specific configuration throughout the request lifecycle.”
URL Strategies
The table below compares common approaches for identifying tenants in URLs. Your choice depends on whether you need tenant isolation, branding flexibility, or simple deployment.
| Strategy | Example | Best For |
|---|---|---|
| Subdomain | acme.app.com | B2B SaaS, team isolation |
| Path prefix | app.com/acme/ | Simple setups, MVPs |
| Custom domain | app.acme.com | Enterprise, white-label |
| Query param | app.com?tenant=acme | APIs, testing only |
Subdomain and custom domain approaches provide the strongest tenant branding. Path prefixes are simpler to implement and deploy. Query parameters should only be used for testing.
Subdomain Tenancy
Subdomain tenancy gives each tenant their own branded URL like acme.app.com. This approach provides clear isolation and enables tenant-specific SSL certificates if needed.
// Subdomain-based tenant resolution
// {tenant}.myapp.com
// Express middleware
function resolveTenantFromSubdomain(req, res, next) {
const host = req.hostname;
const parts = host.split('.');
// myapp.com -> no tenant (marketing site)
// acme.myapp.com -> tenant: acme
// app.myapp.com -> no tenant (main app)
if (parts.length >= 3) {
const subdomain = parts[0];
// Reserved subdomains
const reserved = ['www', 'app', 'api', 'admin', 'mail'];
if (reserved.includes(subdomain)) {
req.tenant = null;
} else {
req.tenant = await db.tenants.findOne({ subdomain });
}
}
if (!req.tenant && req.path !== '/login') {
return res.redirect('https://myapp.com/login');
}
next();
}
// Next.js middleware
import { NextResponse } from 'next/server';
export function middleware(request) {
const hostname = request.headers.get('host');
const subdomain = hostname.split('.')[0];
// Skip for main domain
if (subdomain === 'myapp' || subdomain === 'www') {
return NextResponse.next();
}
// Rewrite to tenant-specific path internally
const url = request.nextUrl.clone();
url.pathname = `/_tenant/${subdomain}${url.pathname}`;
return NextResponse.rewrite(url);
}The middleware extracts the subdomain, validates it against reserved names like www and api, and either looks up the tenant or rewrites the request internally. Invalid subdomains redirect to the login page.
If subdomain configuration feels too complex, path prefixes offer a simpler alternative that works well for many applications.
Path Prefix Tenancy
Path prefix tenancy puts the tenant identifier in the URL path, like app.com/acme/dashboard. This approach requires no DNS configuration and works with standard hosting.
// Path-based tenant resolution
// myapp.com/{tenant}/dashboard
// Express middleware
function resolveTenantFromPath(req, res, next) {
const pathParts = req.path.split('/').filter(Boolean);
if (pathParts.length > 0) {
const potentialTenant = pathParts[0];
const tenant = await db.tenants.findOne({ slug: potentialTenant });
if (tenant) {
req.tenant = tenant;
// Rewrite path without tenant prefix
req.url = '/' + pathParts.slice(1).join('/') || '/';
}
}
next();
}
// Route configuration
app.use('/:tenant', tenantRouter);
app.use('/', publicRouter);
// Link generation within tenant context
function getTenantUrl(tenant, path) {
return `/${tenant.slug}${path}`;
}
// In templates
<a href={getTenantUrl(tenant, '/dashboard')}>Dashboard</a>
// Renders: /acme/dashboard
// API routes
app.get('/:tenant/api/users', (req, res) => {
const { tenant } = req.params;
const users = await db.users.find({ tenantSlug: tenant });
res.json(users);
});The middleware extracts the tenant slug from the path and rewrites the URL to remove the prefix for downstream handlers. Link generation functions automatically include the tenant prefix in all URLs.
Enterprise customers often want to use their own domain. Custom domain support requires additional infrastructure but provides the best white-label experience.
Custom Domain Support
Custom domains let tenants access your app at their own domain, like app.acme.com. This requires domain verification, DNS configuration, and SSL certificate management.
// Custom domain resolution
// app.acme.com -> tenant: acme
// Database schema
// tenants: { id, name, subdomain, customDomain, customDomainVerified }
async function resolveTenantFromDomain(req, res, next) {
const host = req.hostname;
// Check for custom domain first
let tenant = await db.tenants.findOne({
customDomain: host,
customDomainVerified: true
});
// Fall back to subdomain
if (!tenant && host.endsWith('.myapp.com')) {
const subdomain = host.split('.')[0];
tenant = await db.tenants.findOne({ subdomain });
}
req.tenant = tenant;
next();
}
// Domain verification flow
app.post('/api/verify-domain', async (req, res) => {
const { domain } = req.body;
const tenant = req.tenant;
// Generate verification token
const token = crypto.randomBytes(32).toString('hex');
await tenant.update({
customDomain: domain,
customDomainVerified: false,
domainVerificationToken: token
});
// User adds TXT record: _myapp-verify.domain.com -> token
// Or CNAME: domain.com -> verify.myapp.com
res.json({
domain,
verificationMethod: 'dns',
txtRecord: {
name: `_myapp-verify.${domain}`,
value: token
}
});
});
// Verify domain ownership
app.post('/api/confirm-domain', async (req, res) => {
const tenant = req.tenant;
const domain = tenant.customDomain;
const records = await dns.resolveTxt(`_myapp-verify.${domain}`);
const verified = records.flat().includes(tenant.domainVerificationToken);
if (verified) {
await tenant.update({ customDomainVerified: true });
// Provision SSL certificate
await provisionSSL(domain);
}
res.json({ verified });
});Domain verification uses DNS TXT records to prove ownership. Once verified, you provision an SSL certificate for the custom domain. Modern platforms like Cloudflare and Vercel automate this process.
SSL certificates are essential for custom domains. Without HTTPS, browsers will show security warnings that erode user trust.
SSL Certificate Management
Managing SSL for multiple domains requires automation. Wildcard certificates work for subdomains, while custom domains need individual certificates provisioned via Let's Encrypt or similar services.
// SSL strategies for multi-tenant
// Subdomain: Wildcard certificate
// *.myapp.com covers all tenant subdomains
// One certificate, simple setup
// Custom domains: Individual certificates
// Use Let's Encrypt with ACME protocol
// Provision on-demand when domain is verified
// Cloudflare/Vercel/AWS approach
// Proxy handles SSL termination
// Automatic certificate provisioning
// Example: Caddy server config for automatic SSL
// {
// "apps": {
// "http": {
// "servers": {
// "srv0": {
// "listen": [":443"],
// "routes": [{
// "match": [{"host": ["*.myapp.com", "verified-domains.txt"]}],
// "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "localhost:3000"}]}]
// }]
// }
// }
// }
// }
// }
// Verify SSL is working
async function checkSSL(domain) {
try {
const response = await fetch(`https://${domain}`, { method: 'HEAD' });
return response.ok;
} catch {
return false;
}
}Wildcard certificates (*.myapp.com) cover all subdomains with a single certificate. For custom domains, use ACME protocol automation to provision certificates on-demand when domains are verified.
Once you can resolve tenants from URLs, you need to ensure all database queries and application logic respect tenant boundaries.
Tenant-Aware Routing
Every database query and API request must be scoped to the current tenant. Middleware that attaches tenant context early in the request makes this straightforward.
// Middleware chain for tenant resolution
const tenantMiddleware = [
resolveTenantFromDomain,
validateTenant,
attachTenantContext
];
app.use(tenantMiddleware);
// Tenant context for database queries
function attachTenantContext(req, res, next) {
// Scope all database queries to tenant
req.db = {
users: db.users.scope({ tenantId: req.tenant.id }),
projects: db.projects.scope({ tenantId: req.tenant.id })
};
// Set tenant in response headers (for debugging)
res.set('X-Tenant-ID', req.tenant.id);
next();
}
// Cross-tenant links (admin, support)
function getCrossTenantUrl(tenant, path) {
if (tenant.customDomain) {
return `https://${tenant.customDomain}${path}`;
}
return `https://${tenant.subdomain}.myapp.com${path}`;
}
// Tenant switching (for admins)
app.get('/admin/impersonate/:tenantId', adminOnly, async (req, res) => {
const tenant = await db.tenants.findById(req.params.tenantId);
const url = getCrossTenantUrl(tenant, '/dashboard');
res.redirect(url);
});The attachTenantContext middleware creates scoped database accessors that automatically filter by tenant. Cross-tenant links use a helper function that handles both subdomain and custom domain formats.