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?
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.
| Host header | Result |
|---|---|
Languages.LOCALHOST:3010 | languages (case and port ignored) |
systems.osztromok.com. (production) | systems (trailing dot ignored) |
evillanguages.localhost, languages.localhost.evil.com, x.languages.localhost | null |
127.0.0.1:3010, an empty header, languages%2elocalhost | null |
languages.localhost in production, or the production names in development | null |
"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.
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 app | Result |
|---|---|
languages.localhost (curl resolves the name itself) | 200, Languages |
Host: webdevelopment.localhost:3010 | 200, Web Development |
Host: LANGUAGES.localhost | 200, Languages |
Host: localhost | 200, the front page |
Host: example.com, Host: evillanguages.localhost | 404, 404 |
languages host asking for /sites/systems or /sites | 404, 404 (the internal layout is hidden) |
languages host, /nothing-here | 404 |
X-Forwarded-Host: systems.localhost sent to the languages host | 200, 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) | |
|---|---|---|
| Build | 18.6 s for three apps | 11.2 s |
| Memory, running | 3 processes, 268 MB (about 89 MB each) | 1 process, 84 MB |
| One site fails | The others keep running | Every site is down together |
| Change to one site | Rebuild that app | Rebuild everything |
| Change to shared code | Rebuild every app | Rebuild the one app |
| Routing | Apache sends each host to a port | Apache sends every host to one port; the proxy file picks the site |
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.
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.
next dev with the proxy file. Memory and build figures are single runs on a
development machine.
Hands-On Exercises
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 solutionBuild 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 solutionCompare 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 solutionChapter 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 isnull- 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.rewritekeeps the visitor's address - Hide the internal
/sites/โฆpath (404); trust onlyHost, neverX-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
*.localhostthemselves: 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