How to Build Multi-Tenant Apps in Next.js • Without Middleware?
Learn how to build multi-tenant Next.js applications using rewrites instead of middleware for cleaner, more maintainable subdomain routing.
This article shows you how to implement multi-tenant subdomain routing in Next.js using configuration-based rewrites instead of middleware. The approach is inspired by Vercel's Platforms Starter Kit but eliminates the complexity of runtime middleware in favor of declarative Next.js configuration.
Why Skip Middleware?
The Vercel Platforms Starter Kit uses middleware to extract subdomains from the request hostname and rewrite URLs accordingly. While this works, it introduces:
- Runtime overhead — JavaScript executes on every request
- Complexity — Subdomain logic is spread across middleware and routing
- Maintenance burden — Changes require understanding both middleware and Next.js routing
Using Next.js rewrites moves this logic to the configuration layer where it belongs. No JavaScript runs, no middleware to debug, and the routing logic is centralized in next.config.ts.
The Core Concept
Instead of using middleware to parse the hostname and rewrite URLs, define rewrites in next.config.ts that:
- Match requests based on the
hostheader - Extract the subdomain as a route parameter
- Rewrite to the appropriate app directory path
This leverages Next.js's built-in rewrite system to handle multi-tenancy without custom code.
Implementation: Subdomain Configuration
The complete implementation lives in config/ssl/next-subdomains.ts. Here's how it works:
Detecting the Root Domain
First, determine the base domain dynamically based on the environment:
export const createSubdomainConfig = (env: VercelEnv, customDomains: string[] = []) => {
// Detect root domain from Vercel environment variables or fallback to localhost
const rootDomain = env.VERCEL_ENV
? `(${env.VERCEL_URL}|${env.VERCEL_BRANCH_URL}|${env.VERCEL_PROJECT_PRODUCTION_URL}|${customDomains.join("|")})`
: "app.localhost";- Production/Preview: Uses Vercel environment variables (
VERCEL_URL,VERCEL_PROJECT_PRODUCTION_URL) - Local development: Defaults to
app.localhost - Custom domains: Supports additional domains via the
customDomainsparameter
Rewrite Rules
The configuration returns two key rewrites using Next.js's beforeFiles phase:
return {
rewrites: () => ({
beforeFiles: [
{
// Handle subdomain routing
source: "/:path((?!api|_next|_static|_vercel|\\.well-known|.*\\.\\w+$).*)*",
has: [{ type: "host", value: `(?<subdomain>.*).${rootDomain}` }],
missing: [{ type: "host", value: `root.${rootDomain}` }],
destination: "/:subdomain*/:path*",
},
{
// Handle root domain routing
source: "/:path((?!api|_next|_static|_vercel|\\.well-known|.*\\.\\w+$).*)*",
has: [{ type: "host", value: `${rootDomain}` }],
destination: "/root/:path*",
},
],
}),
};Key details:
sourceregex: Excludes Next.js internal paths (_next,_static), API routes (api), Vercel internals (_vercel), well-known URIs (.well-known), and static files (any path ending with a file extension like.jpg,.css)hascondition: Matches requests with a subdomain in the hostname, extracting it as a named groupmissingcondition: Preventsroot.your-domain.comfrom being treated as a subdomain —rootis reserved for the main domain pathdestination: Rewrites to/:subdomain/:pathor/root/:pathbased on the matched rule
See the full implementation for the complete code.
Using the Configuration
In next.config.ts, import and apply the configuration:
import { createSubdomainConfig } from "./config/ssl/next-subdomains";
import { env } from "~/env/server";
const { rewrites, redirects, allowedDevOrigins } = createSubdomainConfig(
env,
["sashkode.dev", "sashkode.app"]
);
const nextConfig: NextConfig = {
allowedDevOrigins,
rewrites,
redirects,
// ... other config
};The configuration also returns:
allowedDevOrigins: Enables CORS for local subdomain developmentredirects: Redirectslocalhosttoapp.localhostin development
App Directory Structure
With rewrites configured, organize your app directory by subdomain:
Each subdomain gets its own directory. The [subdomain] directory handles dynamic tenants.
Example: Static Subdomain Page
Here's a simplified example of a videos subdomain page (the actual implementation uses a re-export pattern):
// Simplified example - the actual app uses a re-export pattern
// Real implementation: export { VideosPage as default } from "~/video/server/videos-page"
export default function VideosPage() {
return (
<div>
<h1>Videos Subdomain</h1>
{/* Your videos listing */}
</div>
);
}Access at videos.app.localhost (development) or videos.your-domain.com (production).
Example: Dynamic Subdomain Page
The wildcard subdomain page captures any subdomain not explicitly defined. Here's the actual implementation from the repository:
// The repository uses a custom Page helper, but here's the standard Next.js equivalent:
export default async function SubdomainPage({
params,
}: {
params: Promise<{ subdomain: string }>;
}) {
const { subdomain } = await params;
return (
<div>
<h1>Subdomain: {subdomain}</h1>
{/* Load tenant-specific data based on subdomain */}
</div>
);
}See the full page implementation.
Cross-Subdomain Navigation
Navigating between subdomains requires full URLs. The SubdomainLink component makes this type-safe and seamless:
import { SubdomainLink } from "~/platform/client/components/subdomain-link";
// Link to root domain
<SubdomainLink subdomain="root" pathname="/about">
About Us
</SubdomainLink>
// Link to videos subdomain
<SubdomainLink subdomain="videos" pathname="/[slug]" params={{ slug: "my-video" }}>
Watch Video
</SubdomainLink>
// Link to dynamic subdomain
<SubdomainLink subdomain="acme">
Acme Corp Dashboard
</SubdomainLink>See the complete component for the full implementation including TypeScript types.
Local Development with Subdomains
Option 1: HTTP (Quick Start)
For basic testing, redirect localhost to app.localhost:
redirects: () => [
{
permanent: false,
source: "/:path*",
has: [{ type: "host", value: "localhost" }],
destination: "http://app.localhost:3000/:path*",
},
];Access your app at:
http://app.localhost:3000(root domain)http://videos.app.localhost:3000(videos subdomain)http://tenant1.app.localhost:3000(dynamic subdomain)
Option 2: HTTPS (Production-Like)
For a production-like environment with SSL, use Next.js's experimental HTTPS support with custom certificates:
npm run dev:sslThis runs:
{
"scripts": {
"predev:ssl": "./config/ssl/trust.sh",
"dev:ssl": "next dev --experimental-https --experimental-https-ca ./config/ssl/certificates/RootCA.crt --experimental-https-key ./config/ssl/certificates/localhost.key --experimental-https-cert ./config/ssl/certificates/localhost.crt"
}
}The predev:ssl script installs the CA certificate, and dev:ssl starts Next.js with HTTPS enabled.
For detailed certificate setup, see the Local SSL/HTTPS in Next.js article.
Production Deployment on Vercel
1. Just Configure as a Wildcard Domain in Vercel
In the Vercel dashboard, add the wildcard domain:
*.your-domain.comVercel will automatically provision SSL certificates for all subdomains using Let's Encrypt.
www vs non-www domain
I prefer redirecting www.your-domain.com → your-domain.com (non-www) rather than the other way around. This keeps the base domain as the canonical URL and avoids needing an additional rewrite rule for www. If you prefer using www as your primary domain, you'll need to update the rewrites configuration to handle www requests appropriately.
2. Update Configuration (Optional)
If you added custom domains to createSubdomainConfig, Vercel's environment variables will already include your production URL:
const { rewrites, redirects } = createSubdomainConfig(
env,
["your-domain.com"] // Optional: add custom domains for preview branches
);Type Safety with TypeScript
The SubdomainLink component provides full TypeScript support for routes and parameters:
// Type error: "videos" subdomain doesn't have a /dashboard route
<SubdomainLink subdomain="videos" pathname="/dashboard">
// Valid: videos has a /[slug] route
<SubdomainLink subdomain="videos" pathname="/[slug]" params={{ slug: "intro" }}>
// Type error: missing required params
<SubdomainLink subdomain="videos" pathname="/[slug]">
// Valid: params provided
<SubdomainLink subdomain="videos" pathname="/[slug]" params={{ slug: "intro" }}>The types are derived from Next.js's typed routes, ensuring links stay in sync with your app structure.
See the type definitions for the complete implementation or more on type safety in the Why I'm NOT switching to TanStack Start - Type safety in Next.js article.
Further Exploration
This implementation is part of a larger multi-tenant architecture. Explore related code:
- Full subdomain configuration — Complete rewrite and redirect logic
- SubdomainLink component — Type-safe cross-subdomain navigation
- Root domain page — Example root domain implementation
- Dynamic subdomain page — Wildcard subdomain handler
- Next.js config — How everything ties together