En mi post anterior puse una app Angular dentro de una página de este sitio: un micro frontend compuesto dentro de la página. Ese es un modelo. Otro es más simple de describir: dividir por ruta. Una ruta del sitio pertenece a otra app, con su propio build y su propio deploy, y el dominio sigue siendo el mismo.
Así que lo hice aquí. La página Herramientas ahora la sirve una segunda app Next.js. Abre el DevTools en ella y la respuesta trae un header que este sitio no envía en ningún otro lugar: x-zone: uses.
La página era la parte barata
La página en sí tiene 514 líneas: una lista de herramientas en cinco idiomas. Llevarla a otra app debería ser copiar y pegar.
No lo es, porque la página no vive sola. Está dentro de la barra lateral, el header móvil, el footer, el atajo de búsqueda, los selectores de idioma y de tema, los tokens de diseño y las traducciones. Contado, ese marco tiene unas 2.560 líneas, cinco veces la página. Un micro frontend por ruta no necesita una página; necesita todo el marco alrededor, idéntico, en dos apps.
Ahí estaba la decisión real. Copiar el marco significa dos barras laterales que se van separando con el tiempo. Darle a la app nueva un marco mínimo significa una página que parece otro sitio. La respuesta que escala es un monorepo con el marco como paquete compartido.
Paso uno: un monorepo que no cambia nada
Antes de crear cualquier app nueva, convertí el repositorio en un monorepo con pnpm y Turborepo: el sitio pasó a apps/portfolio, y el marco, el design system y las traducciones se volvieron paquetes. La regla era que quien visita no podía notar nada.
"Idéntico" tenía que medirse, no suponerse. Antes de mover un solo archivo, guardé el HTML de las 122 páginas y el conjunto completo de selectores CSS, y los comparé después de cada paso. Esa comparación se pagó dos veces:
- Desaparecieron 58 clases CSS. Tailwind escaneaba todo el repositorio, incluidos ejemplos de código en la documentación, y generaba CSS para ellos. Cuando pasó a escanear solo la app, ese CSS muerto se fue. Revisé cada una antes de aceptarlo.
- El marco perdió los estilos al quitar una línea. Tailwind solo genera las clases que encuentra, y los paquetes viven fuera de la app. Sin un
@sourceexplícito que apunte a ellos, la barra lateral sale sin estilo y todos los checks siguen en verde.
Después, una revisión independiente encontró uno más: Turborepo no sabía que la app dependía de los paquetes, así que un cambio solo en el marco reutilizaba los resultados de tests en caché de la app. El CI habría aprobado una traducción faltante. Una regla de dependencia lo resolvió.
Paso dos: la zona
Con el marco como paquete, la app nueva es pequeña. Sirve una ruta, usa el mismo marco y pone un prefijo a sus assets para que no choquen con los del portfolio en el mismo dominio:
const nextConfig: NextConfig = {
// The zone's assets must not collide with the portfolio's on the same domain.
assetPrefix: "/uses-static",
transpilePackages: ["@repo/i18n", "@repo/ui", "@repo/shell"],
async headers() {
return [{ source: "/(.*)", headers: [{ key: "x-zone", value: "uses" }] }]
},
}El portfolio le reenvía la ruta. Si falta la URL de la zona en un build de producción, el build falla en lugar de publicar un rewrite hacia ninguna parte:
export function usesRewrites(env: { USES_ZONE_URL?: string; NODE_ENV?: string }) {
const raw = env.USES_ZONE_URL ?? (env.NODE_ENV === "production" ? undefined : "http://localhost:3001")
if (!raw) throw new Error("USES_ZONE_URL is required in production builds")
const zone = raw.replace(/\/$/, "")
return [
{ source: `/${USES_LOCALE_PARAM}/uses`, destination: `${zone}/:locale/uses` },
{ source: `/${USES_LOCALE_PARAM}/uses/:path*`, destination: `${zone}/:locale/uses/:path*` },
{ source: "/uses", destination: `${zone}/uses` },
{ source: "/uses-static/:path*", destination: `${zone}/uses-static/:path*` },
]
}El dominio propio de la zona sirve un robots.txt que bloquea todo. Los buscadores llegan a la página por el dominio principal, y la URL canónica ya apunta allí.
Un link que sabe a dónde va
La parte sutil fue la navegación. La navegación client-side solo funciona dentro de la misma app: desde la página Herramientas, ir a la home con una ruta client-side le pediría esa ruta a una app que no la tiene. Así que el marco aprendió a qué app pertenece cada ruta:
export type Zone = "portfolio" | "uses"
const LOCALE_GROUP = `(?:${LOCALES.join("|")})`
const USES = new RegExp(`^(?:/${LOCALE_GROUP})?/uses(?:/.*)?$`)
export function zoneFor(pathname: string): Zone {
return USES.test(pathname) ? "uses" : "portfolio"
}
export function linkMode(current: Zone, href: string): "client" | "document" | "external" {
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//")) return "external"
const pathname = href.split(/[?#]/)[0] || "/"
return zoneFor(pathname) === current ? "client" : "document"
}Cada link y cada navegación programática del marco pasa por esa decisión. Cambiar de idioma dentro de Herramientas sigue siendo client-side. Ir de Herramientas a la home, o a la búsqueda, carga un documento nuevo, a propósito.
Lo que costó
- El salto extra. Servida directamente, la zona respondió en 224 ms; a través del rewrite del portfolio, en 267 ms.
- Client-side no es automáticamente más rápido. Cambiar de idioma dentro de la zona (client-side) tomó 534 ms, mientras que cruzar de la zona a la home (documento completo) tomó 318 ms. La transición client-side busca los datos de la página a través del rewrite: un viaje de ida y vuelta extra en cada cambio.
- Los deploys son independientes de verdad. Un commit solo en la zona reconstruye solo la zona; un commit solo en el portfolio reconstruye solo el portfolio; un cambio en el marco reconstruye los dos, porque los dos dependen de él.
- La falla tiene otra forma. Si la zona se cae,
/usesfallaría y el resto del sitio seguiría funcionando: un rewrite no tiene fallback. En el experimento con Angular, un micro frontend con falla tumbaba un bloque y dejaba la página en pie.
Lo que solo la producción mostró
Todo pasó localmente. Dos cosas solo aparecieron con las dos apps publicadas:
- Los prefetch fallaban a través del rewrite. Next.js 16 hace prefetch de una ruta segmento por segmento, con un header especial. Llamada directamente, la zona respondía esas peticiones. A través del portfolio, la misma petición cruzaba el rewrite y llegaba a la zona de una forma que no sabía servir: 404. Las peticiones completas de la página pasaban sin problema; solo se rompía el prefetch. La navegación seguía funcionando, solo que sin prefetch. Mi primera corrección, mover el rewrite a la capa de la plataforma (
vercel.json), no cambió nada. Lo que funcionó fue desactivar el prefetch de los links dentro de la zona: la petición que fallaba simplemente dejó de existir. - La respuesta de revalidación llegaba sin headers. En el experimento con Angular, el panel mostraba 0,3 KB como costo del módulo para quien volvía al post. El 304 que la plataforma envía al revalidar no trae ninguno de los headers de CORS y de timing, así que el navegador solo reporta el tamaño de los headers. El panel ahora lo trata como caché.
Lo que habría hecho el producto gestionado
Vercel tiene un producto de Microfrontends que hace este enrutamiento en el edge, desde un único archivo de configuración, con prefetch entre zonas y un proxy local. Yo lo hice con rewrites comunes de Next.js para que un experimento no dependiera de un producto que podría tener costo. La diferencia es quién opera la plomería, no si el modelo funciona.
Un repositorio, dos dueños
Una pregunta justa es si la app nueva no debería vivir en su propio repositorio. Los micro frontends tratan de deploys y dueños independientes, no de repositorios. Aquí la zona tiene su propia app, su propio proyecto en Vercel y su propio deploy, y un archivo CODEOWNERS hace explícita la responsabilidad. Un repositorio separado me habría obligado a publicar el marco como paquete versionado y mantener dos apps sincronizadas con él, que es justamente la divergencia que quería evitar. El experimento con Angular sí tuvo su propio repositorio, porque no compartía nada con el sitio.
Cuándo no vale la pena
Para una persona y una página, esto es excesivo, y ese era el punto. El costo no es la página: es el marco, el monorepo, los rewrites y un modo de falla nuevo. Empieza a valer la pena cuando varios equipos necesitan publicar partes del mismo sitio a su propio ritmo. Ahora sé cómo se ve el primer paso de eso.
