←Back to blog
nextjsnext-intlmiddlewarearchitecturereelbiti18nrouting
·6 min read

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

One subdomain, one app: reelbit.joca.dev served from the same Next.js app as joca.dev

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:

  1. The Chrome Web Store asks for a privacy policy URL, and I don't want that URL to depend on the visitor's Accept-Language header (or to redirect at all). It must answer 200, in English, with no hops.
  2. Three languages (es, en, ca) with next-intl, like the rest of the site.
  3. The landing can't inherit the navbar and footer of my personal site. It is a different product.
  4. Zero new infrastructure. It's a side project, not a service.

Why not a separate project

Separate app and deploySame app, host-based routing
Infra and CIDuplicatedOne
i18n, fonts, utilitiesCopied or in a shared packageReused as is
Failure isolationTotalNone: one bad deploy breaks both domains
Routing complexityNoneLives in one file, but it is mine
Cost of understanding the systemLow per piece, high overallHigh 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.devResult
/Redirect to /<detected locale> (done by next-intl)
/esRewrite to /es/reelbit
/es/privacyRewrite to /es/reelbit/privacy
/privacyRewrite to /en/reelbit/privacy, no redirect
/en/blog or /foo404 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:

  1. link: next-intl generates alternate-link headers. Here they would point at internal paths of the main site, not at valid subdomain URLs.
  2. set-cookie on /privacy: next-intl remembers the locale in the NEXT_LOCALE cookie. The unprefixed policy is locale-agnostic, so forcing en and storing it as the visitor's preference would be an ugly side effect.
  3. x-middleware-rewrite: the one that counts is the one from my NextResponse.rewrite, not the one next-intl proposes.

I checked it with curl, not from memory

Against the production build, sending Host: reelbit.joca.dev:

RequestObserved result
/privacy200, rewrite to /en/reelbit/privacy, no set-cookie
/es/privacy200, NEXT_LOCALE=es
/ with Accept-Language: ca307 to /ca
/en/blog404
/privacy without the special host307 (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 to proxy.ts with the same behavior, and there is a codemod. It works (the build labels it Proxy (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 a host → route prefix table. 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).