One subdomain, one app: how I serve reelbit.joca.dev from my site on Next 16

TL;DR: reelbit.joca.dev and joca.dev are the same Next 16 app, the same deploy and the same route tree. A check on the host header in the middleware rewrites the subdomain to /[locale]/reelbit. The rewrite was not the hard part. Keeping next-intl and the rewrite from stepping on each other was, and so was making /privacy return a 200 in English with no redirect.
For the product itself, see the ReelBit post. This one is only about routing.
The problem and the constraints
I wanted a landing page on its own domain for a Chrome extension. Constraints, from most rigid to least:
- The Chrome Web Store asks for a privacy policy URL, and I don't want that URL to depend on the visitor's
Accept-Languageheader (or to redirect at all). It must answer200, in English, with no hops. - Three languages (
es,en,ca) with next-intl, like the rest of the site. - The landing can't inherit the navbar and footer of my personal site. It is a different product.
- Zero new infrastructure. It's a side project, not a service.
Why not a separate project
| Separate app and deploy | Same app, host-based routing | |
|---|---|---|
| Infra and CI | Duplicated | One |
| i18n, fonts, utilities | Copied or in a shared package | Reused as is |
| Failure isolation | Total | None: one bad deploy breaks both domains |
| Routing complexity | None | Lives in one file, but it is mine |
| Cost of understanding the system | Low per piece, high overall | High in one piece, low overall |
For a single-author side project, failure isolation is what I care about least and duplication is what I care about most. With a team and several owners the math changes. More on that at the end.
The host rewrite
The (simplified) logic in src/middleware.ts:
const intlMiddleware = createMiddleware(routing);
export default function middleware(request: NextRequest) {
if (isReelbitHost(request)) return handleReelbitHost(request);
return intlMiddleware(request);
}
isReelbitHost compares the host (no port, lowercased) with a constant from src/lib/reelbit.ts. handleReelbitHost then applies this table:
| Request on reelbit.joca.dev | Result |
|---|---|
/ | Redirect to /<detected locale> (done by next-intl) |
/es | Rewrite to /es/reelbit |
/es/privacy | Rewrite to /es/reelbit/privacy |
/privacy | Rewrite to /en/reelbit/privacy, no redirect |
/en/blog or /foo | 404 inside the ReelBit tree |
The last row matters: the subdomain does not expose the rest of my site. It is not an alias of joca.dev, it is a projection of one subtree.
Where it gets tricky: next-intl on the internal path
The tempting move is to do the rewrite and call it done. The problem is that next-intl, seeing /privacy with no locale prefix, does exactly what it is supposed to do everywhere else: redirect to /<locale>/privacy. That breaks constraint number one.
The way out was to build the internal path first and run next-intl on it:
const target = request.nextUrl.clone();
target.pathname = `/${locale}/reelbit${rest}`; // /en/reelbit/privacy
const intlResponse = intlMiddleware(new NextRequest(target, request));
if (intlResponse.headers.has("location")) return intlResponse;
const response = NextResponse.rewrite(target);
intlResponse.headers.forEach((value, key) => {
if (key === "link") return; // (1)
if (bare && key === "set-cookie") return; // (2)
if (key === "x-middleware-rewrite") return; // (3)
response.headers.append(key, value);
});
return response;
Since the internal path already carries a locale prefix, next-intl has nothing to redirect, resolves the locale and sets its request headers (the root layout calls getLocale(), so it needs them). I just carry them over to my own rewrite. Three filters, each with a reason:
link: next-intl generates alternate-link headers. Here they would point at internal paths of the main site, not at valid subdomain URLs.set-cookieon/privacy: next-intl remembers the locale in theNEXT_LOCALEcookie. The unprefixed policy is locale-agnostic, so forcingenand storing it as the visitor's preference would be an ugly side effect.x-middleware-rewrite: the one that counts is the one from myNextResponse.rewrite, not the one next-intl proposes.
I checked it with curl, not from memory
Against the production build, sending Host: reelbit.joca.dev:
| Request | Observed result |
|---|---|
/privacy | 200, rewrite to /en/reelbit/privacy, no set-cookie |
/es/privacy | 200, NEXT_LOCALE=es |
/ with Accept-Language: ca | 307 to /ca |
/en/blog | 404 |
/privacy without the special host | 307 (it doesn't exist there; next-intl treats it like any unprefixed route) |
One thing I like about this design: the policy has one stable URL (reelbit.joca.dev/privacy) and the per-language versions exist too. The English version's canonical points at /privacy, and so does the x-default in alternates.
Route groups: each domain gets its own chrome
Before the subdomain, src/app/[locale]/layout.tsx rendered the navbar and footer for everything. The ReelBit landing doesn't want them. I moved the site pages into a route group:
src/app/[locale]/
├── layout.tsx # providers and metadata only
├── (site)/ # joca.dev navbar + footer
│ ├── layout.tsx
│ ├── page.tsx, about, blog, projects
└── reelbit/ # its own layout, font and footer
├── layout.tsx
├── page.tsx
└── privacy/
The parentheses keep (site) out of the URL, so no existing route changed. It is an almost mechanical refactor (the commit is renames plus a new 15-line layout), and it is what lets reelbit have its own font (Silkscreen) and footer without touching the rest.
One consequence that bites: every link from the landing back to my site is absolute. A relative /es/blog, served from reelbit.joca.dev, would point to reelbit.joca.dev/es/blog, which is a 404 by design. Hence MAIN_ORIGIN and reelbitUrl() in src/lib/reelbit.ts.
What I'd do differently, and when this stops scaling
- The file is called
middleware.ts, and in Next 16 that is deprecated. According to the docs shipped in the package, it was renamed toproxy.tswith the same behavior, and there is a codemod. It works (the build labels itProxy (Middleware)), but it is debt I should pay before it becomes a surprise. - The blast radius is shared. A broken deploy takes down the landing, the privacy policy and my personal site at once, and the policy is what the store links to. I accept that today. If ReelBit had real users depending on that URL, I would move it to its own deploy.
- One subdomain is an
if. Five are not. The day a second product shows up,if (isReelbitHost)has to become ahost → route prefixtable. I haven't generalized it on purpose: that would be abstracting from a single case. - There are no automated tests for the middleware. I verified the table above by hand. For something that decides what the store-linked URL answers, a test that fires
NextRequests with different hosts is the cheapest debt to pay. - The matcher skips any path with a dot, so assets (
/reelbit/screenshot-2.webp) never hit the middleware. That is what I want, but it is worth knowing before debugging a weird 404.
The underlying idea: host-based routing is cheap while there is only one exception. The expensive part was never the rewrite, it was remembering everything the rewrite doesn't fix on its own (cookies, headers, absolute links, layouts).