Subdomains and Middleware

Learning Website with Next.js

Chapter 2 ยท Subdomains & Middleware

Chapter 1 gave every site its own app, so the apps never had to ask “which site is this request for?”: Apache sent each host name to a different port. This chapter builds the other shape, one app that answers for every site, and measures both. The question it has to answer well is small and dangerous: which site is a request for, judging only by its Host header?

Run for real, on Next.js 16.4
The one-app version was built, started and sent twelve different requests, including hostile ones, and opened in headless Chrome. Apache was not available, so the Apache lines are written and untested.

Step 1: Which Site Is This For?

The answer lives in the shared package, so every app and every test uses the same one. The rule is strict: after lower-casing and removing a port and one trailing dot, the host must be exactly languages.localhost (languages.osztromok.com in production). The bare domain is the front page. Anything else is null: not ours.

export function siteForHost(host: string | null | undefined, env: Environment): HostTarget | null { if (!host) return null; const name = host.toLowerCase().replace(/:\d+$/, "").replace(/\.$/, ""); const base = env === "prod" ? DOMAIN : "localhost"; if (name === base) return "portfolio"; for (const site of SITE_NAMES) { if (name === `${site}.${base}`) return site; } return null; }
Host headerResult
Languages.LOCALHOST:3010languages (case and port ignored)
systems.osztromok.com. (production)systems (trailing dot ignored)
evillanguages.localhost, languages.localhost.evil.com, x.languages.localhostnull
127.0.0.1:3010, an empty header, languages%2elocalhostnull
languages.localhost in production, or the production names in developmentnull
What went wrong, and how to avoid it
The mistake: my first version also trimmed whitespace, and the test "languages.localhost\n" came back as languages. A Host header containing a newline is malformed and should be refused, not tidied up, so the function was more forgiving than the rule it implements. The takeaway: normalise only what the rule says is equivalent (case, port, trailing dot), and put the awkward inputs in the test before trusting the function. The fix was to remove the trim; all 9 tests pass.

Step 2: The Proxy File

In Next.js 16 the file that runs before every request is proxy.ts. (Earlier versions called it middleware.ts, and the build output still labels it “Proxy (Middleware)”.) It reads the Host header and rewrites the request to that site's folder inside the app: languages.localhost/ is answered by app/sites/languages, and the visitor's address does not change.

export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; // /sites/... is the app's internal layout: a visitor must never reach it by typing it if (pathname === "/sites" || pathname.startsWith("/sites/")) { return new NextResponse("Not found", { status: 404 }); } // only the Host header is trusted const target = siteForHost(request.headers.get("host"), environment()); if (target === null) return new NextResponse("Unknown site", { status: 404 }); const url = request.nextUrl.clone(); url.pathname = `/sites/${target}${pathname === "/" ? "" : pathname}`; return NextResponse.rewrite(url); }

The page behind it, app/sites/[site]/page.tsx, lists the nine values (eight sites and the front page) in generateStaticParams and sets dynamicParams = false, so each is built in advance and any other value is a 404. The build produced nine static pages and the “Proxy” entry, in 11.2 seconds.

Real Requests

Request to the one appResult
languages.localhost (curl resolves the name itself)200, Languages
Host: webdevelopment.localhost:3010200, Web Development
Host: LANGUAGES.localhost200, Languages
Host: localhost200, the front page
Host: example.com, Host: evillanguages.localhost404, 404
languages host asking for /sites/systems or /sites404, 404 (the internal layout is hidden)
languages host, /nothing-here404
X-Forwarded-Host: systems.localhost sent to the languages host200, Languages: the forged header was ignored
No Host header at all (HTTP/1.0)404
A file under /_next/static/200, application/javascript (the matcher skips it)

A headless Chrome opened http://languages.localhost:3010/ with no special settings and showed the Languages page: browsers resolve *.localhost to your own machine themselves, so development needs no hosts-file entries. Only the Host header is trusted because X-Forwarded-Host is sent by whoever makes the request unless a proxy overwrites it, the same decision as in the Django project.

Step 3: One App or Many? Measure

Many apps (Chapter 1)One app (this chapter)
Build18.6 s for three apps11.2 s
Memory, running3 processes, 268 MB (about 89 MB each)1 process, 84 MB
One site failsThe others keep runningEvery site is down together
Change to one siteRebuild that appRebuild everything
Change to shared codeRebuild every appRebuild the one app
RoutingApache sends each host to a portApache sends every host to one port; the proxy file picks the site
How much to trust these numbers
Memory is the Windows working set of the Node processes after each server had answered a request: an idle, freshly started machine, not a loaded server, and Debian will differ. Build times are one run each. Eight separate apps would be about 700 MB by extrapolation, which was not measured. Treat the table as the shape of the cost, not a benchmark.

The Apache Setting That Breaks the One-App Shape

Written but not run (there is no Apache here): for the one-app shape every host goes to the same port, and Apache must keep the original Host header.

<VirtualHost *:443> ServerName languages.osztromok.com ProxyPreserveHost On ProxyPass / http://127.0.0.1:3010/ </VirtualHost>

Without ProxyPreserveHost On, Apache sends Host: 127.0.0.1:3010 to the app, which is “not ours”, so every request is a 404. It cannot be seen in development, which makes it the kind of fault that appears on the day of the move.

Choosing (an opinion, not a measurement): with eight sites on one small server, one app is the lighter choice. With sites that change at different speeds, or may move to different servers, separate apps are safer. This course keeps the separate apps and treats the one-app version as a tested alternative.

What was not verified
Nothing was run behind Apache. The case where the rewritten page is produced on each request (not built in advance) was not tried, and neither was next dev with the proxy file. Memory and build figures are single runs on a development machine.

Hands-On Exercises

Exercise 1

Write the shared function that decides the site from a Host header, strictly, and a test with hostile inputs. Show the mistake a too-forgiving version makes and fix it.

๐Ÿ“„ View solution
Exercise 2

Build one app that rewrites each request to its site's folder by Host header, hides the internal folder, ignores forwarded hosts and refuses unknown hosts. Send it a table of real requests and open it in headless Chrome.

๐Ÿ“„ View solution
Exercise 3

Compare one app with several on build time and running memory on this machine, write the Apache lines for both shapes, and name the setting that silently breaks the one-app shape. Say how much to trust the numbers.

๐Ÿ“„ View solution

Chapter 2 Quick Reference

  • Two shapes: many apps (Apache routes by host to ports) or one app (a proxy file routes by host inside Next)
  • siteForHost(host, env) in the shared package: exact names only; case, port and one trailing dot ignored; anything else is null
  • Normalise only what the rule calls equivalent: a trim made "languages.localhost\n" valid, and a test caught it
  • Next.js 16: proxy.ts (earlier: middleware.ts); NextResponse.rewrite keeps the visitor's address
  • Hide the internal /sites/โ€ฆ path (404); trust only Host, never X-Forwarded-Host; unknown host is a 404, never a default site
  • The matcher /((?!_next/|favicon.ico).*) keeps Next's own files out of the proxy
  • generateStaticParams + dynamicParams = false: nine pages built in advance, anything else 404
  • Browsers resolve *.localhost themselves: no hosts-file entries
  • Measured here: one app 84 MB and a 11.2 s build; three apps 268 MB and 18.6 s; one failure takes down everything in the one-app shape
  • One-app Apache needs ProxyPreserveHost On; untested here