Multitenacy
This article describes how to host multiple tenants/websites, served from different subdomains in a single Next.js application. The back-end and front-end are served as different serverless functions, and the app is implemented as a single Next.js project, making it not only easy to maintain and deploy but also cost-effective, simplifying infrastructure management and reducing hosting costs.
This guide will walk you through the steps to implement multitenancy in your Next.js application with a little help from Vovk.ts, making possible to serve different areas of your application under different subdomains, illustrated by implementing the following areas, showcasing multiple use-cases:
- 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, like
acme.customer.example.com
, - pro.*.customer.example.comβ for a professional version of a customer tenant, like
pro.acme.customer.example.com
.
The live example is available at multitenant.vovk.devβ, and the source code is available in the vovk-multitenant-exampleβ.
Each tenant has its own root API endpoint, served from the corresponding domain /api
path. In other words, API for the customer tenant is served from customer.example.com/api
, and API for the admin tenant is served from admin.example.com/api
. These root endpoints are implemented as segments and handled by the Next.js middleware to rewrite the request to the appropriate path based on the tenant subdomain.
For the front-end, youβre going to use the built-in Next.js dynamic routesβ to handle tenant-specific paths. For example, the specific customer tenant page will have its own page file src/customer/[customer_name]/page.tsx
, and the admin tenant will use src/admin/page.tsx
.
Vovk.ts provides a tiny routing utility multitenant
that accepts request information and returns the action to take, such as redirecting to a specific subdomain or rewriting the request to a different path. Itβs going to be used in the Next.js middleware to handle the request and redirect or rewrite it to the appropriate path based on the tenant subdomain.
Configure DNS
In order to set up multitenant.vovk.devβ with the subdomains Iβve used Vercel as a hosting provider. The DNS records are configured as follows:
Type | Host | Value |
---|---|---|
CNAME Record | *.multitenant | cname.vercel-dns.com. |
On the Vercel side, the project domains are configured as follows:

See Vercel documentationβ for more information on configuring domains or check your hosting provider documentation for configuring wildcard subdomains.
On the screenshot the following domains are configured:
multitenant.vovk.dev
for the root tenant,admin.multitenant.vovk.dev
for the admin tenant,customer.multitenant.vovk.dev
for the customer tenant,*.customer.multitenant.vovk.dev
for a specific customer tenant, likeacme.customer.multitenant.vovk.dev
, itβs going to share customer tenant API,pro.acme.customer.multitenant.vovk.dev
to illustrate multiple subdomains. Vercel has limited support for wildcard subdomains, andacme
is used as a placeholder for the customer name to illustrate the idea.
Organize front-end routes
Use Next.js built-in dynamic routesβ to handle tenant-specific 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 back-end API segments and controllers
After setting up Vovk.ts application, the easiest way to get started is to use the Vovk.ts CLI to create the segments and controllers. The CLI will generate the necessary files for you, so you can focus on implementing the business logic.
First, create the API segments for each tenant. The segments will handle the requests to the root API endpoints for each tenant.
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.ts
Read more about Vovk.ts segments.
Next, create the controllers for each segment. The controllers will handle the requests to the API endpoints for each tenant.
Create the ProductService
and ProductController
for the root segment at src/modules/product/
folder:
npx vovk new controller service product
Create the UserService
and UserController
for the admin segment at src/modules/admin/user/
folder:
npx vovk new controller service admin/user
Read more about Vovk.ts controllers.
Enable segmented client
By default, Vovk.ts uses so-called βcomposed clientβ emitted to node_modules/.vovk-client
folder, that in its turn re-exported from "vovk-client"
package. The modules import all schemas from .vovk-schema
folder, making the entire app schema be visible for an inspection in every front-end module that imports the client.
This problem is solved by introducing βsegmented clientβ that generates a separate client for each segment, allowing you to import only the schemas that are relevant to the segment youβre working on. Each segment will have its own directory in the project folder and the client will import only a schema that is relevant to that segment.
This approach also allows higher order segments (such as βadminβ) to import lower order segments (such as βcustomerβ), making it possible to share the RPC libraries between segments but keep the back-end implementation secure.
In order to do that, you need to optionally disable the composed client (at this case you donβt need the "vovk-client"
package) and enable the segmented client in your 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 will be generated in the /src/client
folder, but you can change the output directory by specifying the outDir
option in the segmentedClient
configuration.
Once the segmented client is enabled, you can import the client in your front-end code like this:
import { ProductRPC } from '@/client/product';
await ProductRPC.getProducts();
And the file structure of the generated client will look like this:
- index.tsx segment: root
- index.tsx segment: admin
- index.tsx segment: customer
- index.tsx segment: customer/pro
Update segment configuration
For each segment, you can specify the segmentNameOverride
option in the segmentConfig
object in your config file. This option allows you to override the default segment name used in the URL path (was "customer/pro"
, now an empty string ""
).
// @ts-check
/** @type {import('vovk').VovkConfig} */
const config = {
composedClient: {
enabled: false, // Disable composed client
},
segmentedClient: {
enabled: true, // Enable segmented client
},
segmentConfig: {
// Segment configuration
admin: {
segmentNameOverride: '',
},
customer: {
segmentNameOverride: '',
},
'customer/pro': {
segmentNameOverride: '',
},
},
};
export default config;
Create middleware
Package "vovk"
provides a utility function multitenant
that plays a role of a router for your multitenant application. It accepts the request information and returns the action that middleware
needs to take, such as redirecting to a specific subdomain or rewriting the request to a different path.
The function accepts the following parameters:
requestUrl
: the URL of the request, including the protocol, host, and path, can be obtained fromrequest.url
.requestHost
: the host of the request, can be obtained fromrequest.headers.get("host")
.targetHost
: the host to which the request should be redirected or rewritten, can be set to your production domain orlocalhost:3000
for local development.overrides
: an object that maps tenant subdomain domain names to their specific routing rules. Each rule is an array of objects withfrom
andto
properties, wherefrom
is the path prefix to match andto
is the path to which the request should be rewritten or redirected.
When a wildcard subdomain is used, the square bracket pattern such as [customer_name]
will be used to provide the Dynamic Segmentβ and passed with params
.
import { NextRequest, NextResponse } from 'next/server';
import { multitenant } from 'vovk';
export default function middleware(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: [
// admin.multitenant.vovk.dev
{ from: 'api', to: 'api/admin' }, // API
{ from: '', to: 'admin' }, // UI
],
customer: [
// customer.multitenant.vovk.dev
{ from: 'api', to: 'api/customer' }, // API
{ from: '', to: 'customer' }, // UI
],
'[customer_name].customer': [
// *.customer.multitenant.vovk.dev
{ from: 'api', to: 'api/customer' }, // API
{ from: '', to: 'customer/[customer_name]' }, // UI
],
'pro.[customer_name].customer': [
// pro.*.customer.multitenant.vovk.dev
{ from: 'api', to: 'api/customer/pro' }, // API
{ from: '', to: 'customer/[customer_name]/pro' }, // UI
],
},
});
console.log({
action,
destination,
message,
subdomains,
});
if (action === 'rewrite' && destination) {
return NextResponse.rewrite(new URL(destination));
} else if (action === 'redirect' && destination) {
return NextResponse.redirect(new URL(destination));
} else if (action === 'notfound') {
return new NextResponse('Not Found', { status: 404 });
}
return NextResponse.next();
}
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).*)',
],
};
Update hosts for local development
Update your /etc/hosts
file to support subdomains on localhost.
127.0.0.1 *.localhost