Skip to Content
Multitenancy Tutorial

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.

Multitenancy

It uses Next.js and a small helper from Vovk.ts to serve different areas of the app under different subdomains:

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:

TypeHostValue
CNAME Record*.multitenantcname.vercel-dns.com.

The project domains on Vercel:

Domain configuration

See the Vercel documentation  on domains, or your provider’s documentation on wildcard subdomains.

The project uses these domains:

  • 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 (for example, acme.customer.multitenant.vovk.dev), which shares the customer tenant API,
  • pro.acme.customer.multitenant.vovk.dev to show several levels of subdomains. Vercel’s wildcard support is limited, so acme stands 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.ts

More 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 product

UserService and UserController for the admin segment, in src/modules/admin/user/:

npx vovk new controller service admin/user

Enable 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.

Segmented client

Turn off the composed client and turn on the segmented client in the config file:

vovk.config.mjs
// @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 "".

vovk.config.mjs
// @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 as request.url.
  • requestHost: the request host, such as request.headers.get("host").
  • targetHost: the main host for redirects and rewrites (your production domain, or localhost:3000 in 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 with from (a path prefix) and to (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.

src/proxy.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.
Last updated on