Skip to the solution
HardFrontend10 min read

How do you build a multi-tenant Next.js app with subdomain routing?

832 views

The problem

Question presented to candidate: "We are building a store builder: every customer gets name.example.com, and paying customers can bring their own domain. Design the Next.js side: one app, many tenants. How do requests reach the right tenant, and what can go wrong?"

What a strong answer should cover:

  • One deployment serves every tenant; wildcard DNS and TLS for *.example.com, plus per-domain certificates for custom domains.
  • proxy.ts reads the Host header, resolves the tenant (subdomain or custom-domain lookup), and rewrites the URL to an internal route such as /s/[tenant]/... while the browser URL stays unchanged.
  • The tenant is part of the route path, so ISR and caches are keyed per tenant; generateStaticParams can prerender known tenants and new ones render on demand.
  • Isolation is enforced in the data layer (every query scoped by tenant), and the internal /s/* paths must not be reachable directly.
  • Edge cases: unknown hosts, cached 404s for tenants created later, cookies scoped per host, Server Actions allowedOrigins for custom domains, and per-tenant metadata.

Clarifying questions expected:

  • "Custom domains, or only subdomains?" β€” custom domains add certificate automation and a domain table.
  • "How different are tenants: theme only, or features and data residency?" β€” that decides one app versus several deployments.

Code / implementation expected: Yes β€” a proxy.ts that rewrites by host, and the tenant route it rewrites to.

  • nextjs
  • multi-tenant
  • proxy
  • architecture

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

10 min read Β· 9 sections

Read the code

proxy.ts: map subdomains and custom domains to an internal route
// proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { ROOT_DOMAIN, tenantFromCustomDomain } from "./lib/tenants";

function resolveTenant(host: string): string | null {
  const hostname = host.split(":")[0].toLowerCase();
  if (hostname === ROOT_DOMAIN || hostname === `www.${ROOT_DOMAIN}`) return null; // marketing site
  if (hostname.endsWith(`.${ROOT_DOMAIN}`)) return hostname.slice(0, -(ROOT_DOMAIN.length + 1));
  return tenantFromCustomDomain(hostname); // custom domain, or null
}

export function proxy(request: NextRequest) {
  const host = request.headers.get("host") ?? "";
  const { pathname, search } = request.nextUrl;
  const tenant = resolveTenant(host);

  // Nobody may reach the internal /s/* tree directly.
  if (pathname.startsWith("/s/")) return new NextResponse("Not found", { status: 404 });

  if (!tenant) return NextResponse.next(); // root domain: the marketing pages in app/

  const url = request.nextUrl.clone();
  url.pathname = `/s/${tenant}${pathname}`;
  url.search = search;
  const res = NextResponse.rewrite(url);
  res.headers.set("x-tenant", tenant);
  return res;
}

export const config = {
  matcher: ["/((?!_next/|favicon.ico|.*\\..*).*)"],
};
app/s/[tenant]/page.tsx: one prerendered page per tenant
// app/s/[tenant]/page.tsx
import { notFound } from "next/navigation";
import { getTenant, allTenantSlugs } from "@/lib/tenants";

export const revalidate = 60;
export function generateStaticParams() {
  return allTenantSlugs().map((tenant) => ({ tenant }));
}

export default async function TenantHome({ params }: { params: Promise<{ tenant: string }> }) {
  const { tenant: slug } = await params;
  const tenant = await getTenant(slug);
  if (!tenant) notFound();
  return <main><h1>{tenant.name}</h1><p>plan: {tenant.plan}</p></main>;
}
lib/tenants.ts: the tenant lookup
// lib/tenants.ts
export type Tenant = { slug: string; name: string; plan: "free" | "pro"; customDomain?: string };

// In a real app this is a database or edge config lookup.
const TENANTS: Tenant[] = [
  { slug: "acme", name: "Acme Corp", plan: "pro", customDomain: "shop.acme.test" },
  { slug: "globex", name: "Globex", plan: "free" },
];

export const ROOT_DOMAIN = process.env.ROOT_DOMAIN ?? "example.com";

export async function getTenant(slug: string): Promise<Tenant | null> {
  return TENANTS.find((t) => t.slug === slug) ?? null;
}

export function tenantFromCustomDomain(host: string): string | null {
  return TENANTS.find((t) => t.customDomain === host)?.slug ?? null;
}

export function allTenantSlugs() {
  return TENANTS.map((t) => t.slug);
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 106 of 129 in Next.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Next.js questions
Up nextHard

What are Cache Components in Next.js 16 and how does the 'use cache' directive work?

Continue