Un subdominio, una sola app: cómo sirvo reelbit.joca.dev desde mi web en Next 16

Resumen: reelbit.joca.dev y joca.dev son la misma app Next 16, el mismo deploy y el mismo árbol de rutas. Un check del header host en el middleware reescribe el subdominio a /[locale]/reelbit. Lo difícil no fue el rewrite. Fue que next-intl y el rewrite no se pisaran, y que /privacy devolviera un 200 en inglés sin redirigir.
Si quieres el contexto del producto, está en el post de ReelBit. Aquí va solo la parte de routing.
El problema y las restricciones
Quería una landing con dominio propio para una extensión de Chrome. Las restricciones, de más rígida a menos:
- La Chrome Web Store pide una URL de política de privacidad y no quiero que esa URL dependa de la cabecera
Accept-Languagede quien la abra (ni de que redirija). Debe responder200, en inglés, sin saltos. - Tres idiomas (
es,en,ca) con next-intl, como el resto del sitio. - La landing no puede heredar el navbar y el footer de mi web personal. Es otro producto.
- Cero infraestructura nueva. Es un side project, no un servicio.
Por qué no un proyecto aparte
| Otra app y otro deploy | La misma app, routing por host | |
|---|---|---|
| Infra y CI | Duplicada | Una sola |
| i18n, fuentes, utilidades | Copiadas o en un paquete compartido | Reutilizadas tal cual |
| Aislamiento de fallos | Total | Ninguno: un mal deploy rompe los dos dominios |
| Complejidad de routing | Ninguna | Vive en un archivo, pero es mía |
| Coste de entender el sistema | Bajo por pieza, alto en conjunto | Alto en una pieza, bajo en conjunto |
Para un side project con un único autor, el aislamiento de fallos es lo que menos me importa y la duplicación es lo que más. Con un equipo y varios dueños el cálculo sería distinto. Lo cuento al final.
El rewrite por 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 (sin puerto, en minúsculas) con una constante de src/lib/reelbit.ts. Y handleReelbitHost aplica esta tabla:
| Petición en reelbit.joca.dev | Resultado |
|---|---|
/ | Redirect a /<locale detectado> (lo hace next-intl) |
/es | Rewrite a /es/reelbit |
/es/privacy | Rewrite a /es/reelbit/privacy |
/privacy | Rewrite a /en/reelbit/privacy, sin redirect |
/en/blog o /foo | 404 dentro del árbol de ReelBit |
La última fila importa: el subdominio no expone el resto de mi web. No es un alias de joca.dev, es una proyección de un subárbol.
Donde se complica: next-intl sobre la ruta interna
La tentación es hacer el rewrite y listo. El problema es que next-intl, al ver /privacy sin prefijo de idioma, hace justo lo que debe hacer en cualquier otro sitio: redirigir a /<locale>/privacy. Es decir, rompe la restricción número uno.
La salida fue construir primero la ruta interna y ejecutar 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;
Como la ruta interna ya lleva prefijo de idioma, next-intl no tiene nada que redirigir, resuelve el locale y deja sus cabeceras de request (el layout raíz usa getLocale(), así que necesita que estén). Yo solo las traslado a un rewrite propio. Y tres filtros, cada uno con su razón:
link: next-intl genera cabeceras de enlaces alternativos. Aquí apuntarían a rutas internas del sitio principal, no a URLs válidas del subdominio.set-cookieen/privacy: next-intl recuerda el idioma en la cookieNEXT_LOCALE. La política sin prefijo es agnóstica del idioma: forzareny guardarlo como preferencia del visitante sería un efecto secundario feo.x-middleware-rewrite: el que vale es el de miNextResponse.rewrite, no el que propone next-intl.
Lo comprobé con curl, no de memoria
Contra el build de producción, enviando Host: reelbit.joca.dev:
| Petición | Resultado observado |
|---|---|
/privacy | 200, rewrite a /en/reelbit/privacy, sin set-cookie |
/es/privacy | 200, NEXT_LOCALE=es |
/ con Accept-Language: ca | 307 a /ca |
/en/blog | 404 |
joca.dev/privacy (sin el host especial) | 307 (no existe ahí, next-intl la trata como cualquier ruta sin prefijo) |
Un matiz que me gusta de este diseño: la política es una sola URL estable (reelbit.joca.dev/privacy) y también existen las versiones por idioma. La canonical de la versión en inglés apunta a /privacy, y el x-default de los alternates también.
Route groups: que cada dominio tenga su propio chrome
Antes del subdominio, src/app/[locale]/layout.tsx pintaba navbar y footer para todo. La landing de ReelBit no los quiere. Moví las páginas del sitio a un route group:
src/app/[locale]/
├── layout.tsx # solo providers y metadata
├── (site)/ # navbar + footer de joca.dev
│ ├── layout.tsx
│ ├── page.tsx, about, blog, projects
└── reelbit/ # su propio layout, fuente y footer
├── layout.tsx
├── page.tsx
└── privacy/
El paréntesis hace que (site) no aparezca en la URL, así que no cambió ninguna ruta existente. Es un refactor casi mecánico (el commit son renombrados más un layout nuevo de 15 líneas), y es lo que permite que reelbit tenga su fuente (Silkscreen) y su footer sin tocar el resto.
Una consecuencia que muerde: todos los enlaces de la landing hacia mi web son absolutos. Un /es/blog relativo, servido desde reelbit.joca.dev, apuntaría a reelbit.joca.dev/es/blog, que es justo un 404 por diseño. De ahí MAIN_ORIGIN y reelbitUrl() en src/lib/reelbit.ts.
Lo que haría distinto, y cuándo esto deja de escalar
- El archivo se llama
middleware.ts, y en Next 16 está deprecado. Según la documentación incluida en el paquete, se renombró aproxy.tsy el comportamiento es el mismo; hay un codemod. Funciona (el build lo etiquetaProxy (Middleware)), pero es deuda que debería pagar antes de que sea una sorpresa. - El blast radius es compartido. Un deploy roto tumba landing, política de privacidad y web personal a la vez. Y la política es lo que la tienda enlaza. Hoy lo acepto. Si ReelBit tuviera usuarios de verdad que dependieran de esa URL, la sacaría a un deploy propio.
- Un solo subdominio es un
if. Cinco no lo son. El día que haya un segundo producto, elif (isReelbitHost)debe convertirse en una tablahost → prefijo de ruta. No lo he generalizado a propósito: sería abstraer con un solo caso. - No tengo tests automáticos del middleware. La tabla de arriba la verifiqué a mano. Para algo que decide qué responde la URL que enlaza la tienda, un test que lance
NextRequestcon distintoshostes la deuda más barata de pagar. - El matcher excluye cualquier ruta con punto, así que los assets (
/reelbit/screenshot-2.webp) no pasan por el middleware. Es lo que quiero, pero es un detalle que conviene conocer antes de depurar un 404 raro.
La idea de fondo: el routing por host es barato mientras solo exista una excepción. La parte cara nunca fue el rewrite, sino acordarse de todo lo que el rewrite no arregla por sí solo (cookies, cabeceras, enlaces absolutos, layouts).