This project demonstrates how to implement multi-tenancy in a Next.js application using Makeswift, where each tenant maps to its own Makeswift site via that site's API key.
It uses hybrid routing: public visitors are identified by the URL path (e.g. localhost:3000/siteA serves Site A), while the Makeswift builder connects via the subdomain (e.g. siteA.localhost:3000). The bare root domain (localhost:3000) serves the default tenant. Subdomain URLs also work for public viewing, so both styles render the same tenant content — but the builder requires a subdomain because a Makeswift host URL is an origin only and cannot contain a path.
This guide assumes you've set up a Makeswift site before. If you haven't, start with the Makeswift quickstart.
You'll need:
- One Makeswift site per tenant (this example uses three: a default plus Site A and Site B). Each site has its own content and its own Site API key, found in the Makeswift dashboard under the site's settings.
- Node.js and pnpm (
pnpm@9— seepackageManagerin package.json).
-
Install dependencies:
pnpm install
-
Create your env file by copying the example, then fill in each Site API key:
cp .env.example .env.local
ROOT_DOMAIN=localhost # "localhost" in dev; your real domain (e.g. "example.com") in prod DEFAULT_MAKESWIFT_SITE_API_KEY=... # used for the bare root domain SITE_A_SUBDOMAIN=siteA # doubles as the path prefix (public) and subdomain (builder) SITE_A_MAKESWIFT_SITE_API_KEY=... SITE_B_SUBDOMAIN=siteB SITE_B_MAKESWIFT_SITE_API_KEY=...
-
Connect each Makeswift site to its subdomain. In each site's host settings in the Makeswift dashboard, set the host URL to that tenant's subdomain so the builder loads the right content (the builder cannot use a path):
- Default site →
http://localhost:3000 - Site A →
http://siteA.localhost:3000 - Site B →
http://siteB.localhost:3000
- Default site →
-
Run the development server:
pnpm dev
-
Visit each tenant in your browser — both routing styles work for public viewing:
- Default tenant:
http://localhost:3000 - Site A:
http://localhost:3000/siteA(path) orhttp://siteA.localhost:3000(subdomain) - Site B:
http://localhost:3000/siteB(path) orhttp://siteB.localhost:3000(subdomain)
Path URLs work everywhere. For the subdomain URLs (and the builder), most browsers (Chrome, Firefox, Edge) resolve
*.localhostto127.0.0.1automatically. Safari does not — see Resolving subdomains on Safari below. - Default tenant:
A request flows through the system in four steps:
- Detect the tenant from the subdomain (resolved against the configured
ROOT_DOMAIN), or, for path-based public URLs, from the first path segment. - Resolve to a Makeswift site by mapping the tenant identifier to the appropriate Site API key.
- Rewrite the URL so the resolved tenant is the first path segment.
- Serve tenant-specific content from the correct Makeswift site.
Public (path): localhost:3000/siteA/products
└─> Middleware: no tenant subdomain; first path segment "siteA"
is a valid tenant → passed through unchanged → /siteA/products
Public (subdomain) / Builder: siteA.localhost:3000/products
└─> Middleware: subdomain "siteA" is a valid tenant
→ rewritten → /siteA/products
Both → app/[[...path]]/page.tsx with params.path = ['siteA', 'products']
│
├─> Extracts tenantId = 'siteA' from the first path segment
│
├─> Calls getApiKey('siteA') → Site A's API key
│
├─> Creates a Makeswift client with Site A's API key
│
├─> Fetches the page snapshot for '/products' from Site A
│
└─> Renders the page with tenant-specific content
Defines and validates the environment variables for each tenant using @t3-oss/env-nextjs. Each tenant needs a subdomain identifier and a Makeswift Site API key. The validated env object is consumed throughout the app. See env.ts.
The core of the multi-tenancy logic. It maps tenant identifiers to Site API keys and exposes helpers for resolving a tenant from a host. See lib/makeswift/tenants.ts.
SUBDOMAIN_TO_API_KEY— the identifier → API key map.DEFAULT_TENANT_ID('default') is used for the bare root domain and any unrecognized host.getApiKey(subdomain)— returns the API key for a tenant identifier, and throws if it isn't a known tenant.getTenantFromHost(host)— resolves a host header to a known tenant id, falling back todefaultand never throwing. This is why the API route can safely derive a tenant from an arbitrary host (the apex domain,www, a preview URL, etc.).getSubdomainFromHost(host)/isValidTenantId(subdomain)— supporting helpers used by the middleware.isValidTenantIduseshasOwnProperty, so inherited keys (__proto__,constructor, …) are never mistaken for valid tenants.
Resolves the tenant and rewrites incoming URLs so the resolved tenant is always the first path segment. See middleware.ts.
- Subdomain first. If the
hostheader has a valid tenant subdomain relative toROOT_DOMAIN(e.g.siteA.localhost:3000), rewrite to/siteA/.... This is the path the Visual Builder uses, since a Makeswift host URL cannot contain a path. - Path fallback. Otherwise, if the first path segment is already a valid tenant (e.g.
/siteA/products), pass it through unchanged so the catch-all route reads the tenant from there. - Default. Otherwise — the bare root domain or any unrecognized host (e.g.
www, a platform preview URL) — prepend thedefaulttenant, so the page route never throws. - Skips Makeswift API routes and static files via
config.matcher(the API handler resolves the tenant from the host itself).
Examples (with ROOT_DOMAIN=localhost):
| Request | Rewritten to |
|---|---|
siteA.localhost:3000/products (subdomain) |
/siteA/products |
localhost:3000/siteA/products (path) |
/siteA/products |
localhost:3000/products |
/default/products |
www.localhost:3000/products |
/default/products |
This catch-all route renders Makeswift pages for the appropriate tenant. See app/[[...path]]/page.tsx.
- Reads the tenant id from the first path segment (inserted/validated by middleware).
- Reconstructs the Makeswift path by removing that segment.
- Creates a tenant-specific Makeswift client via
getApiKey(subdomain). - Fetches the page snapshot for the requested path from the correct site.
- Renders the page, or returns a 404 if no snapshot is found.
Handles Makeswift's draft mode and preview functionality so you can use the builder. These requests are excluded from the middleware, so the handler resolves the tenant from the host itself via getTenantFromHost — the builder always connects via a subdomain host URL. See app/api/makeswift/[...makeswift]/route.ts.
- Unified codebase, multiple sites — a single deployment serves any number of tenant sites, with one set of components registered once and available to all tenants.
- Content isolation — each tenant's pages and assets live in a separate Makeswift site, isolated at the API-key level, while sharing the same component library.
- Flexible public URLs — public visitors can reach a tenant by path (
example.com/siteA) with no per-tenant DNS, while the builder still connects by subdomain. - Scales horizontally — the same infrastructure handles any number of tenants from a single hosting environment.
Adding a tenant is mostly configuration, plus a small wiring change in two files:
-
Add environment variables in
.env.local(and your hosting platform):SITE_C_SUBDOMAIN=siteC SITE_C_MAKESWIFT_SITE_API_KEY=your-new-api-key
-
Register them in env.ts (both
serverandruntimeEnv):server: { // ... existing entries SITE_C_SUBDOMAIN: z.string().min(1), SITE_C_MAKESWIFT_SITE_API_KEY: z.string().min(1), }, runtimeEnv: { // ... existing entries SITE_C_SUBDOMAIN: process.env.SITE_C_SUBDOMAIN, SITE_C_MAKESWIFT_SITE_API_KEY: process.env.SITE_C_MAKESWIFT_SITE_API_KEY, }
-
Add the mapping in lib/makeswift/tenants.ts:
const SUBDOMAIN_TO_API_KEY = { // ... existing entries [env.SITE_C_SUBDOMAIN]: env.SITE_C_MAKESWIFT_SITE_API_KEY, }
-
Connect the new site's host URL in the Makeswift dashboard (e.g.
http://siteC.localhost:3000).
The same logic works in production with two changes:
- Set
ROOT_DOMAINto your real domain (e.g.ROOT_DOMAIN=example.com). Tenant subdomains are resolved relative to this value, and the bare apex domain maps to the default tenant. - Point each tenant subdomain at your deployment. Path-based public URLs (
example.com/siteA) work without any DNS changes, but the subdomain URLs and the Makeswift builder require each tenant subdomain to resolve. Configure wildcard DNS (*.example.com) or an individual DNS record per tenant sositeA.example.com,siteB.example.com, etc. all reach the same app, and update each Makeswift site's host URL to its production subdomain.
Path-based public URLs (localhost:3000/siteA) work in every browser without any setup. The subdomain URLs — and therefore the Makeswift builder — need *.localhost to resolve to 127.0.0.1. Chrome, Firefox, and Edge do this automatically, but Safari does not. To use the subdomain URLs in Safari, map them explicitly in /etc/hosts. This keeps ROOT_DOMAIN=localhost, so no other config changes:
-
Edit
/etc/hostswith sudo:sudo vim /etc/hosts
-
Add an entry per subdomain:
127.0.0.1 siteA.localhost 127.0.0.1 siteB.localhost -
Visit
http://siteA.localhost:3000,http://siteB.localhost:3000, etc.
- The builder requires a subdomain. A Makeswift host URL is an origin only and cannot contain a path, so each site is configured with a subdomain host URL. Public visitors can still use path-based URLs; the path is mapped to the same tenant.
- The first path segment is reserved for the resolved tenant, so tenant identifiers cannot collide with real top-level page paths.
- Adding a tenant requires a small code change to env.ts and lib/makeswift/tenants.ts, in addition to environment variables — see Adding a New Tenant.
- Browser support for
*.localhostvaries; Safari requires the/etc/hostsapproach for subdomain URLs. - Subdomain-only alternative. If you'd rather distinguish tenants purely by subdomain (no tenant prefix in the public path), see the subdomain-based multi-tenant example.