Technical Article

Multi-Tenant Next.js: Rewrite URLs by Host With Proxy

How a multi-tenant Next.js app maps each host to its site with a Proxy rewrite, and the rules that keep one tenant’s pages off another tenant’s domain.

Share this article

A multi-tenant Next.js app serves many sites from one deployment by rewriting each URL, based on the host, to an internal route that knows which tenant it is. A visitor to acme.example.com/pricing keeps seeing that address, while Next.js renders /s/acme/pricing. The mechanism is a few lines in Proxy, the file Next.js 16 renamed from middleware. The hard part is the rules around it: what an unknown host gets, how a tenant page is kept off other hosts, and where the tenant has to be checked again. Those rules are the ones my Laravel platform enforces for the two sites it serves, and this article translates them into Next.js. The order those decisions come in is covered in SaaS platform architecture: the decisions that come first.

How the rewrite works

Every tenant’s pages live under one dynamic route, such as app/s/[site]/[[...slug]]/page.tsx. Proxy runs before routing, reads the request’s host, finds the site it belongs to, and rewrites the path into that route. A rewrite is internal: the browser’s address bar and the URL search engines index stay on the tenant’s own domain. A redirect would change the address, which is not what you want here.

Vercel’s Platforms starter kit, which the Next.js multi-tenant guide points to, uses this shape with a /s/[subdomain] route. Don’t name the folder with a leading underscore: in the App Router, folders starting with _ are private and can’t be routed to, rewrites included.

A request to acme.example.com/pricing passes through Proxy, which looks up the host, finds the site acme and rewrites the request to /s/acme/pricing; the address bar still shows acme.example.com/pricing.
The host chooses the site; the path stays the visitor’s.

The Proxy file, with the rules that matter

This version applies the same resolution rules my Laravel platform has tests for: an unknown host is a 404, never a default site; a suspended site is a 503; and a site’s secondary domains redirect permanently to its primary one, so search engines see one host per site.

proxy.ts: resolve the site by host, refuse what doesn’t resolve, rewrite what does.
import { NextResponse, type NextRequest } from 'next/server';
import { siteForHost } from '@/lib/sites';

export async function proxy(request: NextRequest) {
  const url = request.nextUrl;
  const host = (request.headers.get('host') ?? '').split(':')[0].toLowerCase();

  // The internal route is never reachable directly, on any host.
  if (url.pathname === '/s' || url.pathname.startsWith('/s/')) {
    return new NextResponse(null, { status: 404 });
  }

  const site = await siteForHost(host);
  if (!site) return new NextResponse(null, { status: 404 });
  if (site.suspended) return new NextResponse(null, { status: 503 });

  if (host !== site.primaryHost) {
    const status = ['GET', 'HEAD'].includes(request.method) ? 301 : 308;
    return NextResponse.redirect(new URL(url.pathname + url.search, `https://${site.primaryHost}`), status);
  }

  return NextResponse.rewrite(new URL(`/s/${site.slug}${url.pathname}${url.search}`, request.url));
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};

Three details are easy to miss. The /s/ check is what stops beta.example.com/s/acme/pricing from showing Acme’s page under Beta’s domain, which is both a tenant leak and duplicate content. The query string has to be appended by hand, because building a new URL from a path drops it. And the matcher keeps Proxy off static files and images, which the Next.js documentation recommends, because otherwise it runs on every asset request.

Looking up the host cheaply

Proxy runs on every page request, so siteForHost() must be fast. For subdomains of your own domain, the site can often be read from the host name itself. For custom domains, it needs a lookup: a table of hosts in the database, or a key-value store, as the Platforms starter uses.

Cache the lookup, but cache only the answer, the site’s slug or id, never a whole site object. That is the rule my Laravel resolver follows: it caches the domain id and loads the site fresh, so a site that has just been suspended or moved to a new domain is never served from a stale copy. The Next.js documentation adds its own constraint: Proxy should not rely on shared modules or globals, and should pass information to the app through headers, cookies, rewrites or the URL. The rewrite path is that channel here.

The tenant page checks the tenant again

The page receives the site from the route and loads its content for that site only. If the site or the page doesn’t exist, it calls notFound(). Treat the site parameter as input, not as proof: every query filters by it, and an unknown value returns nothing.

app/s/[site]/[[...slug]]/page.tsx. Route params are a promise in current Next.js versions.
import { notFound } from 'next/navigation';

export default async function Page({ params }: { params: Promise<{ site: string; slug?: string[] }> }) {
  const { site, slug = [] } = await params;
  const page = await getPage(site, '/' + slug.join('/'));

  if (!page) notFound();

  return <PageView page={page} />;
}

Two places need the tenant checked again, outside Proxy. Server Functions are handled as POST requests to the route that uses them, so a matcher change can quietly take them out of Proxy’s reach; the Next.js documentation says to verify authorization inside each Server Function rather than relying on Proxy. And any cache key, whether for fetched data or rendered output, must include the site, because the same path exists on every tenant. My Laravel platform keys its page cache by host for exactly that reason.

Per-tenant metadata, sitemaps and canonicals

  • Generate metadata from the site: title templates, Open Graph images and the canonical URL on the site’s primary host.
  • Serve a sitemap and robots.txt per host, listing only that site’s pages.
  • Never let the root marketing domain render tenant routes, or every tenant’s pages appear twice in search.
  • For local development, acme.localhost resolves to your machine in most browsers, so you can test real host-based routing without editing hosts files.

Tests to write first

  • An unknown host returns 404 and renders no site.
  • A request to /s/acme/... on any host returns 404.
  • A secondary domain redirects to the primary one, keeping the path and query.
  • Two hosts requesting the same path get different sites’ content, including from cache.
  • A Server Function called for one site cannot read or change another site’s data.

The first four mirror tests on my Laravel platform. If the Next.js app sits in front of a separate API, the API must enforce the tenant on its own as well: multi-tenant isolation covers why the data layer should fail closed rather than trust the front-end.

Where to go next

If you are deciding whether each customer should get their own deployment instead, see single tenant vs multi tenancy. For a review of a multi-tenant Next.js front-end and the API behind it, see SaaS architecture.

Share this article

References

  1. Next.js documentation: proxy.js
  2. Next.js documentation: How to build multi-tenant apps
  3. Vercel Platforms starter kit (source)

Evidence and further reading

All insights →
InsightSaaS Platform Architecture: The Decisions That Come FirstSeven decisions a multi-tenant platform has to make before features, in the order they bind each other, with what each one cost on four platforms I run.

Facing a similar problem?

Platform & Integration Review

For SaaS founders and engineering leads whose platform is getting slower and riskier to change.

Request an assessment

Let's build what's next

Have a complex system that needs to be built right?

Whether you are starting from an idea, replacing an existing platform, or scaling a system, let's talk.

Better Technology.
Brighter Possibilities.