Stub
i haven't finished writing this yet. i publish drafts early as part of WFD 17.
there are definitely better ways to pull this off. but if you have a legacy codebase that can accommodate this approach, it works well enough to get the job done.
i was curious whether a single Next.js server could serve two completely different sites depending on the domain. same codebase, same deployment, but different route sets and different default pages per hostname. the idea is that site-a.com shows one set of pages while site-b.com shows a different set, and routes that belong to one site are inaccessible from the other.
this turned out to be straightforward in concept and full of subtle traps in practice.
flowchart TD
R[incoming request] --> M[middleware reads Host header]
M --> D{which site?}
D -->|site-a| P{matches rewrite prefix?}
P -->|yes| RW[rewrite: prepend /path2/ and serve]
P -->|no| PT[pass through to normal routes]
D -->|site-b| RS{restricted route?}
RS -->|yes, /path2/*| RD[redirect to /dashboard]
RS -->|no| PT2[pass through to normal routes]
hostname detection in middleware
the first problem was figuring out which site a request belongs to. Next.js middleware runs on every request before the page renders, which makes it the right place to read the Host header and branch accordingly.
type Site = "site-a" | "site-b";
const SITE_A_HOSTS = ["site-a.com", "www.site-a.com"];
const SITE_A_PORTS = ["3000"];
function getSiteFromHost(host: string | null | undefined): Site {
if (!host) {
return "site-b";
}
const hostname = host.split(":")[0].toLowerCase();
const port = host.split(":")[1];
if (SITE_A_HOSTS.includes(hostname)) {
return "site-a";
}
if (port && SITE_A_PORTS.includes(port)) {
return "site-a";
}
return "site-b";
}
checking hostnames first covers production, and the port fallback covers local development. defaulting to site-b when nothing matches keeps things working without environment variables or build-time configuration.
rewriting routes per domain
with site detection in place, the next challenge was serving different pages per domain from the same filesystem. suppose site-a's pages live under /path2/ in the filesystem (e.g. /path2/dashboard, /path2/reports), but users visiting site-a.com/dashboard should see those pages without the /path2/ prefix in the URL. middleware rewrites solve this by silently prepending /path2/ to the internal path:
const REWRITE_PREFIXES = [
"/dashboard",
"/reports",
"/settings",
"/profile",
];
function getRewritePath(pathname: string): string | null {
for (const prefix of REWRITE_PREFIXES) {
if (pathname === prefix || pathname.startsWith(prefix + "/")) {
return `/path2${pathname}`;
}
}
return null;
}
rewrites keep the browser URL unchanged while serving a different internal page. users on site-a see /dashboard in their address bar while Next.js renders the page at /path2/dashboard. routes that don't match any rewrite prefix pass through normally, so shared pages like /sign-in work on both sites without duplication.
for site-b, any request to /path2/* gets redirected to /dashboard because those routes shouldn't be reachable from that domain:
if (isSiteB(site) && isRestrictedRoute(req)) {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
the regex that broke everything
the rewrites worked, but restricting routes on site-b introduced a subtle problem. the first attempt at matching restricted routes used this pattern:
const isRestrictedRoute = createRouteMatcher(["/path2(.*)", "/special-signup(.*)"]);
this silently broke every API call on site-b. the pattern /path2(.*) matches /path2-something, /path2api, and any other path that happens to start with /path2 followed by anything. with an earlier prefix choice, the same class of bug matched API endpoints too, so every affected request got redirected to /dashboard, which returned HTML. the client then tried to parse that HTML as JSON:
Uncaught (in promise) SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
tracking this down meant working backwards from a JSON parse error through network responses to discover the redirect, and then tracing the redirect back to the overly broad regex. the fix itself was one character:
const isRestrictedRoute = createRouteMatcher(["/path2/(.*)", "/special-signup(.*)"]);
/path2/(.*) instead of /path2(.*). the trailing slash before the wildcard constrains the match to paths nested under /path2/ rather than any path sharing the same starting characters.
redirect loops between middleware and pages
with the regex fixed, a second bug surfaced: an infinite redirect loop. the codebase already had logic in the dashboard page's getServerSideProps that checked whether the user belonged to a certain context, and if so, redirected them to /path2/dashboard. when the middleware was added to block /path2/dashboard on site-b, the two systems fought each other:
- user visits
/dashboard on site-b
- page detects relevant context, redirects to
/path2/dashboard
- middleware detects site-b, redirects
/path2/dashboard back to /dashboard
- go to step 2
GET /dashboard 307
GET /path2/dashboard 307
GET /dashboard 307
GET /path2/dashboard 307
the root cause was routing logic living in two places. the fix was to remove the redirect from the page entirely and let the middleware own all routing decisions. the page renders content regardless of context, and the middleware decides which internal page to serve based on the domain. consolidating routing into one layer eliminated the conflict.
flowchart LR
subgraph before [before: two layers fighting]
U1[user visits /dashboard] --> G1[getServerSideProps redirects to /path2/dashboard]
G1 --> MW1[middleware redirects back to /dashboard]
MW1 --> U1
end
subgraph after [after: middleware owns routing]
U2[user visits /dashboard] --> MW2[middleware decides based on domain]
MW2 -->|site-a| RW2[rewrite to /path2/dashboard]
MW2 -->|site-b| SV2[serve /dashboard directly]
end
forwarding site identity to server-side props
once routing was stable, some pages still needed to know which site they were being served on to adjust their behavior. the middleware handles this by setting a custom request header before passing the request through:
function withSiteHeader(
req: NextRequest,
site: string,
response?: NextResponse
): NextResponse {
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-site", site);
if (response) {
response.headers.set("x-site", site);
return response;
}
return NextResponse.next({ request: { headers: requestHeaders } });
}
in getServerSideProps, the site is then available via context.req.headers["x-site"]. this lets pages branch their behavior per domain without duplicating the page file.
the subtlety here is that this must set a request header using NextResponse.next({ request: { headers } }). i initially tried setting a response header, which didn't work because response headers don't propagate to getServerSideProps in Next.js. this is a middleware-specific API for forwarding context downstream.
local development with two ports
the last piece was making this testable locally. in production, two DNS records point to the same server and the Host header distinguishes them. locally, i wanted localhost:3000 for site-a and localhost:3001 for site-b, both backed by the same Next.js dev server.
Next.js runs on port 3000 directly. a Caddy reverse proxy in Docker listens on 3001 and forwards to 3000:
:3001 {
reverse_proxy host.docker.internal:3000
}
the site detection picks up the port number from the Host header, so port 3000 resolves to site-a and port 3001 falls through to site-b. the Caddy config is two lines.
flowchart LR
B1[browser :3000] --> NX[Next.js dev server :3000]
B2[browser :3001] --> CD[Caddy proxy :3001]
CD --> NX
NX --> MW[middleware reads Host header]
MW -->|port 3000| SA[site-a routes]
MW -->|port 3001| SB[site-b routes]
i initially tried setting a custom Host header like site-b.localhost:3001 on the proxy to make domain detection explicit, but that broke auth. session cookies set on localhost aren't sent to site-b.localhost because the browser treats them as different origins. removing the host override and letting Caddy forward the original Host: localhost:3001 header means both ports share the same cookie domain, and auth works across both.
open questions
- auth provider multi-domain in production. when the two sites are on genuinely different domains, session cookies won't be shared. the auth provider needs to be configured for cross-domain sessions, and that configuration is separate from the routing work described here.
- SEO and canonical URLs. shared pages like sign-in and sign-up are reachable from both domains. canonical tags should be set per domain to avoid search engines treating them as duplicate content.
- client-side navigation. middleware only intercepts server-side requests. client-side navigations via
next/link or router.push skip the middleware entirely, so an in-app link to a restricted route would render successfully until the next full page load triggers the redirect. this is mitigatable by not rendering those links on the wrong site, but it's an inherent limitation of the middleware approach.
arguments against:
- middleware routing adds a layer of indirection that makes it harder to reason about what page a URL actually serves
- regex-based route matching is fragile and easy to get wrong, as the
/path2(.*) vs /path2/(.*) bug demonstrated
- client-side navigation bypasses middleware entirely, so route restrictions are only enforced on server-side requests
- two domains sharing one codebase means every change needs to be tested against both sites, which doubles the surface area for regressions
- the cookie and auth complications in production are unsolved and may require rethinking the approach entirely
arguments for:
- one deployment, one codebase, one build. operational simplicity compared to maintaining two separate applications
- middleware runs before anything else, so routing decisions are centralized in one place rather than scattered across pages
- rewrites keep URLs clean for each domain without duplicating page files in the filesystem
- the dev proxy setup is minimal and makes it easy to test both sites locally against the same running server
- shared pages like sign-in only need to exist once, and domain-specific pages are cleanly separated by directory
what i took away
the middleware layer in Next.js is well-suited for domain-based routing because it sits in front of everything and can rewrite, redirect, or annotate requests before pages or API routes see them. the pages themselves stay domain-agnostic and just render content, while the middleware handles which content is reachable from where.
the main lessons were about where routing logic should live (one layer, not split between middleware and pages), how regex greediness in route matchers can silently break unrelated paths, and how cookie scoping interacts with local dev proxies in ways that aren't obvious until auth stops working.