Multitenancy
This tutorial hosts several tenants, or sites, in one Next.js app, each on its own subdomain. The back end and the front end run as separate serverless functions in one project, so you maintain and deploy one project.
It uses Next.js and a small helper from Vovk.ts to serve different areas of the app under different subdomains:
- example.com for the root tenant,
- admin.example.com for the admin tenant,
- customer.example.com for a customer tenant,
- *.customer.example.com for a specific customer tenant (for example,
acme.customer.example.com), - pro.*.customer.example.com for a professional version of a customer tenant (for example,
pro.acme.customer.example.com).
The live example runs at multitenant.vovk.dev , and its source code is in vovk-multitenant-example .
Each tenant has its own API root at the /api path of its domain. For example, the customer tenant’s API is at customer.example.com/api, and the admin tenant’s API at admin.example.com/api. These API roots are segments, and the Next.js proxy rewrites a request to the right path by its tenant subdomain, with the Vovk.ts multitenant function.
This example deploys to Vercel, but you can adapt it to any platform that runs Node.js.
Configure DNS
The DNS records:
| Type | Host | Value |
|---|---|---|
| CNAME Record | *.multitenant | cname.vercel-dns.com. |
The project domains on Vercel:

See the Vercel documentation on domains, or your provider’s documentation on wildcard subdomains.
The project uses these domains:
multitenant.vovk.devfor the root tenant,admin.multitenant.vovk.devfor the admin tenant,customer.multitenant.vovk.devfor the customer tenant,*.customer.multitenant.vovk.devfor a specific customer tenant (for example,acme.customer.multitenant.vovk.dev), which shares the customer tenant API,pro.acme.customer.multitenant.vovk.devto show several levels of subdomains. Vercel’s wildcard support is limited, soacmestands in for any customer.
Organize Frontend Routes
Use Next.js dynamic routes for the tenant paths:
- page.tsx domain: multitenant.vovk.dev, segment: "root"
- page.tsx domain: admin.multitenant.vovk.dev, segment: "admin"
- page.tsx domain: customer.multitenant.vovk.dev, segment: "customer"
- page.tsx domain: *.customer.multitenant.vovk.dev, segment: "customer"
- page.tsx domain: pro.*.customer.multitenant.vovk.dev, segment: "customer/pro"
Create Backend API Segments and Controllers
After setting up a Vovk.ts app, create the segments and controllers with the Vovk.ts CLI, which writes the files for you.
First, create an API segment for each tenant. Each segment handles the requests to its API root.
npx vovk new segment # create the root segment at src/app/api/[[...vovk]]/route.ts
npx vovk new segment admin # create "admin" segment at src/app/api/admin/[[...vovk]]/route.ts
npx vovk new segment customer # create "customer" segment at src/app/api/customer/[[...vovk]]/route.ts
npx vovk new segment customer/pro # create "customer/pro" segment at src/app/api/customer/pro/[[...vovk]]/route.tsMore about segments.
Next, create the controllers of each segment. For example, ProductService and ProductController for the root segment, in src/modules/product/:
npx vovk new controller service productUserService and UserController for the admin segment, in src/modules/admin/user/:
npx vovk new controller service admin/userEnable Segmented Client
By default, Vovk.ts emits a “composed client” to src/client, imported as @/client. Its modules import all schemas from .vovk-schema, so every front-end module that imports the client sees the schema of the whole app.
A segmented client generates a separate client for each segment, which imports only that segment’s schema. Each segment gets its own folder.
A higher-level segment (such as “admin”) can also import a lower-level one (such as “customer”). Segments share RPC modules this way, and pages that don’t use those RPC modules don’t see their back-end details.
Turn off the composed client and turn on the segmented client in the config file:
// @ts-check
/** @type {import('vovk').VovkConfig} */
const config = {
composedClient: {
enabled: false, // Disable composed client
},
segmentedClient: {
enabled: true, // Enable segmented client
},
};
export default config;By default, the segmented client goes to ./src/client. The outDir option changes the folder.
Then import the client in the front-end code:
import { ProductRPC } from '@/client/root';
await ProductRPC.getProducts();The generated client’s files, shortened (see segmented client docs):
- index.ts segment: root
- index.ts segment: admin
- index.ts segment: customer
- index.ts segment: customer/pro
Update Segment Configuration
Set segmentNameOverride for every segment other than the root in the config file. It replaces the segment name in the URL path: for example, "customer/pro" becomes "".
// @ts-check
/** @type {import('vovk').VovkConfig} */
const config = {
composedClient: {
enabled: false, // Disable composed client
},
segmentedClient: {
enabled: true, // Enable segmented client
},
outputConfig: {
segments: {
admin: {
segmentNameOverride: '',
},
customer: {
segmentNameOverride: '',
},
'customer/pro': {
segmentNameOverride: '',
},
},
},
};
export default config;Create Next.js Proxy
multitenant from the "vovk" package is the router of the multitenant app. It takes the request details and returns what the proxy should do: redirect to a subdomain or rewrite to a path.
Parameters:
requestUrl: the full request URL, such asrequest.url.requestHost: the request host, such asrequest.headers.get("host").targetHost: the main host for redirects and rewrites (your production domain, orlocalhost:3000in development). A target host without a port matches the request host on any port.overrides: maps tenant subdomain names to routing rules. Each rule is an array of objects withfrom(a path prefix) andto(the target path).
For wildcard subdomains, use square-bracket patterns, such as [customer_name], for the Dynamic Segment . The page gets the value in params. A placeholder matches one DNS label (letters, digits and hyphens), and the host is matched case-insensitively, so a host such as %2e%2e.customer.example.com gets no rewrite.
A rule rewrites a path prefix, so a host also reaches what is nested under its target: acme.customer.example.com/api/pro reaches the customer/pro segment, and acme.customer.example.com/pro the pro page.
The proxy goes in src/proxy.ts. On Next.js 15, name the file src/middleware.ts.
import { type NextRequest, NextResponse } from 'next/server';
import { multitenant } from 'vovk';
export default function proxy(request: NextRequest) {
const { action, destination, message, subdomains } = multitenant({
requestUrl: request.url,
requestHost: request.headers.get('host') ?? '',
targetHost: process.env.VERCEL ? 'multitenant.vovk.dev' : 'localhost:3000',
overrides: {
admin: [
{ from: 'api', to: 'api/admin' }, // API
{ from: '', to: 'admin' }, // UI
],
customer: [
{ from: 'api', to: 'api/customer' }, // API
{ from: '', to: 'customer' }, // UI
],
'[customer_name].customer': [
{ from: 'api', to: 'api/customer' }, // API
{ from: '', to: 'customer/[customer_name]' }, // UI
],
'pro.[customer_name].customer': [
{ from: 'api', to: 'api/customer/pro' }, // API
{ from: '', to: 'customer/[customer_name]/pro' }, // UI
],
},
});
console.log({
requestUrl: request.url,
requestHost: request.headers.get('host') ?? '',
targetHost: process.env.VERCEL ? 'multitenant.vovk.dev' : 'localhost:3000',
action,
destination,
message,
subdomains,
});
// the API reads the tenant from this header; only the proxy sets it, not the client
const headers = new Headers(request.headers);
headers.delete('x-subdomains');
if (subdomains) {
headers.set('x-subdomains', new URLSearchParams(subdomains).toString());
}
if (action === 'rewrite' && destination) {
return NextResponse.rewrite(new URL(destination), { request: { headers } });
}
if (action === 'redirect' && destination) {
return NextResponse.redirect(new URL(destination));
}
return NextResponse.next({ request: { headers } });
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
* - SVG files
*/
'/((?!static|.*\\.png|.*\\.svg|.*\\.ico|.well-known|_next/image|_next/static).*)',
],
};The code above is fetched from GitHub repository.
The proxy passes the captured subdomains to the API in an x-subdomains request header and drops one the client sent, so a client can’t choose its tenant. The customer controllers read it with headers() from next/headers.
Open the Tenants Locally
Browsers resolve localhost and every name under it to the loopback address, so admin.localhost:3000 and acme.customer.localhost:3000 reach the dev server with no change to /etc/hosts. A hosts file takes no wildcards: a *.localhost line in it does nothing.
Roadmap
- 📝 Cover multiple domains.