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

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

Un subdominio, una sola app: reelbit.joca.dev servido desde el mismo Next.js que joca.dev

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:

  1. La Chrome Web Store pide una URL de política de privacidad y no quiero que esa URL dependa de la cabecera Accept-Language de quien la abra (ni de que redirija). Debe responder 200, en inglés, sin saltos.
  2. Tres idiomas (es, en, ca) con next-intl, como el resto del sitio.
  3. La landing no puede heredar el navbar y el footer de mi web personal. Es otro producto.
  4. Cero infraestructura nueva. Es un side project, no un servicio.

Por qué no un proyecto aparte

Otra app y otro deployLa misma app, routing por host
Infra y CIDuplicadaUna sola
i18n, fuentes, utilidadesCopiadas o en un paquete compartidoReutilizadas tal cual
Aislamiento de fallosTotalNinguno: un mal deploy rompe los dos dominios
Complejidad de routingNingunaVive en un archivo, pero es mía
Coste de entender el sistemaBajo por pieza, alto en conjuntoAlto 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.devResultado
/Redirect a /<locale detectado> (lo hace next-intl)
/esRewrite a /es/reelbit
/es/privacyRewrite a /es/reelbit/privacy
/privacyRewrite a /en/reelbit/privacy, sin redirect
/en/blog o /foo404 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:

  1. link: next-intl genera cabeceras de enlaces alternativos. Aquí apuntarían a rutas internas del sitio principal, no a URLs válidas del subdominio.
  2. set-cookie en /privacy: next-intl recuerda el idioma en la cookie NEXT_LOCALE. La política sin prefijo es agnóstica del idioma: forzar en y guardarlo como preferencia del visitante sería un efecto secundario feo.
  3. x-middleware-rewrite: el que vale es el de mi NextResponse.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ónResultado observado
/privacy200, rewrite a /en/reelbit/privacy, sin set-cookie
/es/privacy200, NEXT_LOCALE=es
/ con Accept-Language: ca307 a /ca
/en/blog404
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ó a proxy.ts y el comportamiento es el mismo; hay un codemod. Funciona (el build lo etiqueta Proxy (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, el if (isReelbitHost) debe convertirse en una tabla host → 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 NextRequest con distintos host es 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).