Path-based routing

Route tenants using URL path prefixes in a multi-tenant Makeswift application.

Path-based routing uses the first segment of the URL path to identify the tenant for public visitors. For example, example.com/siteA/products serves the products page for Site A, while the bare root domain (example.com) serves the default tenant.

This is a hybrid approach: public visitors are identified by the URL path, while the Visual Builder connects via subdomain (because a Makeswift host URL is an origin only and cannot contain a path). Both styles render the same tenant content.

Before following this guide, complete the common configuration steps.

How it works

The middleware resolves the tenant in three steps:

  1. Subdomain first. If the host header has a valid tenant subdomain (for example, siteA.localhost:3000), rewrite to /siteA/.... This is how the Visual Builder connects.
  2. Path fallback. If the first path segment is already a valid tenant (for example, /siteA/products), pass it through unchanged.
  3. Default. Otherwise — the bare root domain or any unrecognized host — prepend the default tenant.
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

Add the middleware

Create a middleware.ts file at the root of your project:

middleware.ts
1import { NextRequest, NextResponse } from "next/server";
2
3import {
4 DEFAULT_TENANT_ID,
5 getSubdomainFromHost,
6 isValidTenantId,
7} from "./lib/makeswift/tenants";
8
9export function middleware(request: NextRequest) {
10 const host = request.headers.get("host") ?? "";
11 const url = request.nextUrl.clone();
12
13 // 1. Prefer the subdomain — this is how the Visual Builder connects.
14 const subdomain = getSubdomainFromHost(host);
15 if (subdomain != null && isValidTenantId(subdomain)) {
16 if (
17 url.pathname !== `/${subdomain}` &&
18 !url.pathname.startsWith(`/${subdomain}/`)
19 ) {
20 url.pathname = `/${subdomain}${url.pathname}`;
21 }
22 return NextResponse.rewrite(url);
23 }
24
25 // 2. Fall back to path-based routing for public viewing.
26 const firstPathSegment = url.pathname.split("/").at(1) ?? null;
27 if (firstPathSegment != null && isValidTenantId(firstPathSegment)) {
28 return NextResponse.next();
29 }
30
31 // 3. No tenant in the host or the path — use the default tenant.
32 url.pathname = `/${DEFAULT_TENANT_ID}${url.pathname}`;
33 return NextResponse.rewrite(url);
34}
35
36export const config = {
37 matcher: [
38 "/((?!api|_next/static|_next/image|favicon.ico).*)",
39 ],
40};
RequestRewritten 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

Makeswift API routes (/api/makeswift/*) are excluded via config.matcher — the API handler resolves the tenant from the host itself.

Configure the Visual Builder host URLs

Path-based URLs don’t carry tenant information in the host header, so the Visual Builder cannot connect using a path URL alone. Each Makeswift site needs its host URL set to the subdomain URL.

In the Makeswift dashboard, open the settings for each site and set the host URL:

SiteHost URL
Defaulthttp://localhost:3000
Site Ahttp://siteA.localhost:3000
Site Bhttp://siteB.localhost:3000

For production, replace localhost:3000 with your deployed domain (for example, siteA.example.com).

Test the setup

Start the development server and verify each tenant loads:

$npm run dev

Both path and subdomain URLs work for public viewing:

URLTenant
http://localhost:3000Default
http://localhost:3000/siteASite A (path)
http://localhost:3000/siteBSite B (path)
http://siteA.localhost:3000Site A (subdomain)
http://siteB.localhost:3000Site B (subdomain)

Path URLs work everywhere. For the subdomain URLs (and the Visual Builder), most browsers (Chrome, Firefox, Edge) resolve *.localhost to 127.0.0.1 automatically. Safari does not — see Resolving subdomains on Safari below.

Production deployment

The same logic works in production with two changes:

  • Set ROOT_DOMAIN to your real domain (for example, 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 Visual Builder require each tenant subdomain to resolve. Configure wildcard DNS (*.example.com) or an individual DNS record per tenant.

Resolving subdomains on Safari

Chrome, Firefox, and Edge resolve *.localhost to 127.0.0.1 automatically, but Safari does not. To make the tenant subdomains work in Safari during local development, map them in /etc/hosts:

  1. Edit /etc/hosts:

    $sudo vim /etc/hosts
  2. Add an entry for each subdomain:

    /etc/hosts
    127.0.0.1 siteA.localhost
    127.0.0.1 siteB.localhost
  3. Visit http://siteA.localhost:3000, http://siteB.localhost:3000, and so on.

Example repository

For a complete working implementation, see the multi-tenant path example repository.