←Tornar al blog
nextjsnext-intlmiddlewarearquitecturareelbiti18nrouting
·7 min de lectura

Un subdomini, una sola app: com serveixo reelbit.joca.dev des de la meva web amb Next 16

Un subdomini, una sola app: reelbit.joca.dev servit des del mateix Next.js que joca.dev

Resum: reelbit.joca.dev i joca.dev són la mateixa app Next 16, el mateix desplegament i el mateix arbre de rutes. Una comprovació de la capçalera host al middleware reescriu el subdomini cap a /[locale]/reelbit. El difícil no va ser el rewrite. Va ser que next-intl i el rewrite no es trepitgessin, i que /privacy retornés un 200 en anglès sense redirigir.

Si vols el context del producte, el tens a el post de ReelBit. Aquí només hi ha la part de routing.

El problema i les restriccions

Volia una landing amb domini propi per a una extensió de Chrome. Les restriccions, de la més rígida a la menys:

  1. La Chrome Web Store demana una URL de política de privacitat, i no vull que aquesta URL depengui de la capçalera Accept-Language de qui l'obri (ni que redirigeixi). Ha de respondre 200, en anglès, sense salts.
  2. Tres idiomes (es, en, ca) amb next-intl, com la resta del web.
  3. La landing no pot heretar el navbar i el footer de la meva web personal. És un altre producte.
  4. Zero infraestructura nova. És un side project, no un servei.

Per què no un projecte a part

Una altra app i un altre desplegamentLa mateixa app, routing per host
Infra i CIDuplicadaUna sola
i18n, fonts, utilitatsCopiades o en un paquet compartitReutilitzades tal qual
Aïllament de falladesTotalCap: un mal desplegament trenca els dos dominis
Complexitat de routingCapViu en un fitxer, però és meva
Cost d'entendre el sistemaBaix per peça, alt en conjuntAlt en una peça, baix en conjunt

Per a un side project amb un únic autor, l'aïllament de fallades és el que menys m'importa i la duplicació és el que més. Amb un equip i diversos responsables el càlcul seria un altre. Ho explico al final.

El rewrite per host

La lògica (simplificada) de src/middleware.ts:

const intlMiddleware = createMiddleware(routing);

export default function middleware(request: NextRequest) {
  if (isReelbitHost(request)) return handleReelbitHost(request);
  return intlMiddleware(request);
}

isReelbitHost compara el host (sense port, en minúscules) amb una constant de src/lib/reelbit.ts. I handleReelbitHost aplica aquesta taula:

Petició a reelbit.joca.devResultat
/Redirect a /<locale detectat> (ho fa next-intl)
/esRewrite a /es/reelbit
/es/privacyRewrite a /es/reelbit/privacy
/privacyRewrite a /en/reelbit/privacy, sense redirect
/en/blog o /foo404 dins de l'arbre de ReelBit

L'última fila importa: el subdomini no exposa la resta de la meva web. No és un àlies de joca.dev, és una projecció d'un subarbre.

On es complica: next-intl sobre la ruta interna

La temptació és fer el rewrite i plegar. El problema és que next-intl, en veure /privacy sense prefix d'idioma, fa exactament el que ha de fer a qualsevol altre lloc: redirigir a /<locale>/privacy. És a dir, trenca la restricció número u.

La sortida va ser construir primer la ruta interna i executar next-intl sobre ella:

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;

Com que la ruta interna ja porta prefix d'idioma, next-intl no té res a redirigir, resol el locale i deixa les seves capçaleres de request (el layout arrel fa servir getLocale(), així que les necessita). Jo només les passo a un rewrite propi. I tres filtres, cadascun amb la seva raó:

  1. link: next-intl genera capçaleres d'enllaços alternatius. Aquí apuntarien a rutes internes del web principal, no a URLs vàlides del subdomini.
  2. set-cookie a /privacy: next-intl recorda l'idioma a la cookie NEXT_LOCALE. La política sense prefix és agnòstica de l'idioma: forçar en i guardar-ho com a preferència del visitant seria un efecte secundari lleig.
  3. x-middleware-rewrite: el que val és el del meu NextResponse.rewrite, no el que proposa next-intl.

Ho vaig comprovar amb curl, no de memòria

Contra el build de producció, enviant Host: reelbit.joca.dev:

PeticióResultat observat
/privacy200, rewrite a /en/reelbit/privacy, sense set-cookie
/es/privacy200, NEXT_LOCALE=es
/ amb Accept-Language: ca307 a /ca
/en/blog404
/privacy sense el host especial307 (allà no existeix; next-intl la tracta com qualsevol ruta sense prefix)

Un matís que m'agrada d'aquest disseny: la política té una sola URL estable (reelbit.joca.dev/privacy) i també existeixen les versions per idioma. La canonical de la versió en anglès apunta a /privacy, i l'x-default dels alternates també.

Route groups: que cada domini tingui el seu propi chrome

Abans del subdomini, src/app/[locale]/layout.tsx pintava navbar i footer per a tot. La landing de ReelBit no els vol. Vaig moure les pàgines del web a un route group:

src/app/[locale]/
├── layout.tsx        # només providers i metadata
├── (site)/           # navbar + footer de joca.dev
│   ├── layout.tsx
│   ├── page.tsx, about, blog, projects
└── reelbit/          # el seu propi layout, font i footer
    ├── layout.tsx
    ├── page.tsx
    └── privacy/

Els parèntesis fan que (site) no aparegui a la URL, així que no va canviar cap ruta existent. És un refactor gairebé mecànic (el commit són canvis de nom més un layout nou de 15 línies), i és el que permet que reelbit tingui la seva font (Silkscreen) i el seu footer sense tocar la resta.

Una conseqüència que mossega: tots els enllaços de la landing cap a la meva web són absoluts. Un /es/blog relatiu, servit des de reelbit.joca.dev, apuntaria a reelbit.joca.dev/es/blog, que és un 404 per disseny. D'aquí MAIN_ORIGIN i reelbitUrl() a src/lib/reelbit.ts.

El que faria diferent, i quan això deixa d'escalar

  • El fitxer es diu middleware.ts, i a Next 16 està deprecat. Segons la documentació inclosa al paquet, es va reanomenar a proxy.ts amb el mateix comportament, i hi ha un codemod. Funciona (el build l'etiqueta Proxy (Middleware)), però és deute que hauria de pagar abans que sigui una sorpresa.
  • El blast radius és compartit. Un desplegament trencat tomba landing, política de privacitat i web personal alhora, i la política és el que la botiga enllaça. Avui ho accepto. Si ReelBit tingués usuaris de debò que depenguessin d'aquesta URL, la trauria a un desplegament propi.
  • Un sol subdomini és un if. Cinc no ho són. El dia que hi hagi un segon producte, l'if (isReelbitHost) s'ha de convertir en una taula host → prefix de ruta. No l'he generalitzat a propòsit: seria abstreure amb un sol cas.
  • No tinc tests automàtics del middleware. La taula de dalt la vaig verificar a mà. Per a una cosa que decideix què respon la URL que enllaça la botiga, un test que llanci NextRequest amb diferents host és el deute més barat de pagar.
  • El matcher exclou qualsevol ruta amb punt, així que els assets (/reelbit/screenshot-2.webp) no passen pel middleware. És el que vull, però convé saber-ho abans de depurar un 404 estrany.

La idea de fons: el routing per host és barat mentre només hi ha una excepció. La part cara mai va ser el rewrite, sinó recordar tot el que el rewrite no arregla per si sol (cookies, capçaleres, enllaços absoluts, layouts).