Cada proyecto de BugDump puede publicar un portal: una página pública donde sus usuarios ven qué bugs se están corrigiendo y votan por los que les importan. Hasta el 12 de octubre de 2026, un portal vivía en bugdump.com/p/acme-store. Ahora vive en acme-store.bugdump.com, y no se movió ni un archivo de rutas: TanStack Router reescribe la URL cuando entra y cuando sale, así que la app sigue pensando en rutas /p/acme-store/....
Lo construí primero para Kookee, mi plataforma de contenido, y después para BugDump, el mismo día y con las mismas versiones (@tanstack/react-router 1.170, @tanstack/react-start 1.168, Better Auth 1.7). Las cuatro primeras trampas de abajo salieron en Kookee; la quinta solo la tuvo BugDump. Ninguna hace fallar la comprobación de tipos ni un test unitario que no pase por el router.
Las rutas se quedan, la URL cambia
Las páginas del portal ya vivían bajo una sola ruta, /p/$projectSlug, con la lista de tareas en su índice y cada tarea en /p/$projectSlug/tasks/$taskPublicId. Un segundo árbol de rutas que dependiera del host habría significado dos copias de cada página. TanStack Router tiene una opción justo para este caso: createRouter({ rewrite: { input, output } }).
input convierte la URL del navegador en la URL que el router compara con sus rutas. En un host con exactamente una etiqueta bajo el dominio del portal, pone esa etiqueta delante de la ruta:
input: ({ url }) => {
const label = portalLabelFromHost(url.host, hosting.portalDomain);
if (!label) return url;
const base = `/p/${label}`;
if (url.pathname !== base && !url.pathname.startsWith(`${base}/`)) {
url.pathname = url.pathname === '/' ? base : `${base}${url.pathname}`;
}
return url;
},
Así, acme-store.bugdump.com/tasks/AS-1 coincide con /p/acme-store/tasks/AS-1. Una ruta que ya empieza con el /p/acme-store del propio host pasa sin cambios, porque navigate({ href }) también ejecuta el rewrite de entrada y la app sigue generando sus propias rutas internas. La comprobación compara el segmento completo: un simple startsWith('/p/acme-store') dejaría que /p/acme-storex/... mostrara el portal de otro proyecto en este host.
output va en sentido contrario, de la URL del router a la que muestra el navegador. Una ruta del portal en su propio host pierde el prefijo; una ruta a otro portal va al subdominio de ese portal; cualquier otra ruta en un host de portal, como /auth/login, vuelve a bugdump.com. Cuando cambia el origen, el router marca la ubicación como external y <Link> genera un href absoluto. Los <Link> del propio portal no necesitaron ningún cambio.
El rewrite solo se instala cuando está definida una variable de entorno, VITE_PORTAL_DOMAIN. Producción la define como bugdump.com; desarrollo y staging la dejan vacía y mantienen las rutas /p/, así que staging no necesita un certificado wildcard y los tests end-to-end se ejecutan contra las rutas de siempre.

Ese es el portal del proyecto de demostración. El router ve /p/acme-store; la barra de direcciones del visitante muestra acme-store.bugdump.com.
Trampa 1: la redirección que se redirigía a sí misma
Los enlaces antiguos tenían que seguir funcionando, así que la ruta /p/$projectSlug recibió un beforeLoad que responde con una redirección al subdominio. La primera versión redirigía siempre que la URL del navegador empezaba por /p/.
Entonces alguien abre acme-store.bugdump.com/p/acme-storex/tasks (en Kookee era una página de la hoja de ruta, con el mismo resultado). El rewrite de entrada la convierte en /p/acme-store/p/acme-storex/tasks, la ruta coincide con acme-store, la URL del navegador empieza por /p/ y la redirección apunta a la misma página en la que ya está. La corrección compara el slug de la URL del navegador con el de la propia ruta: portalRedirectUrl solo se dispara cuando location.publicHref muestra /p/acme-store y la ruta es acme-store. Cualquier otra ruta /p/ bajo un host de portal da un 404 allí donde se pidió.
La redirección es un 307, no un 301. Los navegadores guardan en caché las redirecciones permanentes, así que después de un rollback seguirían mandando a la gente a subdominios que ya no sirven portales. Además, el portal es noindex, así que una redirección permanente no aporta nada en los buscadores.
Trampa 2: el rewrite de salida no debe tocar lo que recibe
El primer rewrite de salida modificaba la URL que recibía y la devolvía. Después de la llamada, el router lee su propio href interno de ese mismo objeto, así que quitarle /p/acme-store cambiaba también la ruta interna. Los enlaces dentro del portal empezaron a llevar a las páginas de la propia app: en Kookee, la lista de feedback de un portal enlazaba al tablero de feedback del propio Kookee en kookee.dev.
Ahora cada rama que cambia la URL construye una nueva, y un test unitario pasa una URL, ejecuta el rewrite y comprueba que la URL sigue igual.
Trampa 3: www mandaba a los visitantes a otro sitio
El registro y la ruta wildcard mandan todos los subdominios al mismo Worker, www y admin incluidos. En Kookee, el rewrite de salida mantenía el /p/<label> propio de un host solo si el slug era válido, así que www.kookee.dev se convertía en /p/www dentro del router y acababa en kookee.dev/p/www. Ahora la entrada acepta cualquier etiqueta, siempre que sea una sola, la salida mantiene la etiqueta propia de un host en ese host, sea válida o no, y la ruta decide. Su beforeLoad lanza notFound() para un slug que ningún proyecto puede tener, antes de que se ejecute nada más:
beforeLoad: ({ params, location }) => {
if (!config.portalDomain) return;
if (projectSlugProblem(params.projectSlug) !== null) throw notFound();
const portal = portalRedirectUrl(params.projectSlug, location);
if (portal) throw redirect({ href: portal, statusCode: 307 });
},
Los parámetros de ruta llegan ya decodificados (percent-decoding), así que una redirección construida con un slug sin validar mandaría bugdump.com/p/evil.com%3F/x a https://evil.com?.bugdump.com/x, que el navegador interpreta como evil.com, y /p/api/... a api.bugdump.com. Esa comprobación rechaza un slug así antes de construir ninguna redirección, y portalPathUrl lo vuelve a rechazar.
Trampa 4: navigate() no puede salir del origen
<Link> resuelve una ubicación externa generando un enlace normal. router.navigate() no: mete la URL de otro origen en el historial y el navegador la rechaza con un SecurityError.
La cabecera del portal muestra la campana de notificaciones del visitante con sesión iniciada, y cada notificación llama a navigate() hacia el panel en bugdump.com. Esos 17 componentes ahora pasan por un pequeño helper que le pregunta al router adónde llevaría la navegación y hace una carga completa de la página cuando es otro origen:
export function navigateAnyHost(router: AnyRouter, options: NavigateOptions) {
const { publicHref } = router.buildLocation(options);
if (isOtherOrigin(publicHref)) {
window.location.assign(publicHref);
return;
}
void router.navigate(options);
}
Trampa 5: la página 404 se traía el panel consigo
El componente not-found de Kookee ya tenía en cuenta su grupo de portal; el de BugDump no, y lo encontré al revisar el plan, antes del lanzamiento. Por defecto, el componente not-found se envuelve en el layout del panel salvo que haya coincidido un grupo de rutas con layout propio, y el grupo del portal no estaba en esa lista. En un host de portal, cada ruta desconocida y cada etiqueta reservada como www habrían mostrado la cabecera y el pie del panel y, para un visitante con sesión iniciada, su paleta de comandos ⌘K. La paleta y el chat «Preguntar a la IA» del pie llaman a navigate() hacia páginas de bugdump.com, lo que lanza el SecurityError de la trampa 4. Ahora el grupo del portal está en la lista, así que un 404 en un host de portal ya no se trae el panel consigo.
El inicio de sesión se quedó en el dominio principal
La cookie de sesión ya tenía Domain=bugdump.com, así que llega a todos los subdominios y viaja con sus peticiones a api.bugdump.com. No hay traspaso de tokens: un visitante inicia sesión en bugdump.com y queda con la sesión iniciada en todos los portales.
La API necesitó dos cambios. CORS antes respondía solo a https://bugdump.com; ahora devuelve el origen de la petición cuando es el de un portal. Better Auth comprueba el origen de cada POST que lleva una cookie, así que cerrar sesión desde un portal fallaría con «Invalid origin». Un patrón como https://*.bugdump.com en trustedOrigins confiaría en todos los subdominios, cdn y staging incluidos, y el * de Better Auth también acepta puntos, así que también a.b.bugdump.com. En cambio, trustedOrigins puede ser una función de la petición, así que ahora añade el origen de la propia petición cuando lo acepta una regla, isPortalOrigin: el protocolo correcto, exactamente una etiqueta y un slug que un proyecto podría tener. CORS usa la misma regla.
Un slug ahora es una etiqueta DNS
Un slug que se convierte en subdominio tiene que ser una etiqueta de hostname válida: de 3 a 63 letras minúsculas, dígitos y guiones, sin guion en ninguno de los extremos y sin guiones en las posiciones tercera y cuarta, que IDNA reserva para etiquetas codificadas como xn-- (RFC 5890 y 5891). El 3 lo decidí yo: DNS permite un solo carácter, y ese mínimo deja libres para BugDump los nombres de una y dos letras.
Algunos nombres, además, tienen que seguir siendo de BugDump. La lista de reservados tiene más de 200, desde www, api, cdn y staging hasta status, docs, admin y reports, más cualquier cosa que empiece por bugdump-. Antes del lanzamiento ejecuté las mismas reglas como consulta SQL contra producción, para encontrar proyectos cuyo slug actual dejaría de valer. No devolvió ninguna fila.
Cloudflare: las rutas ganan a los Custom Domains
El sitio principal y staging son Workers conectados como Custom Domains. Para los portales, todos los hosts *.bugdump.com tienen que llegar al Worker de producción, y eso requiere dos cosas: un registro DNS wildcard, * AAAA 100:: con el proxy activado, y una ruta de Workers, *.bugdump.com/*. Los registros DNS explícitos mantienen la prioridad sobre el wildcard, y el certificado Universal ya cubre un nivel de subdominios.
Lo importante es el orden en que Cloudflare los aplica. Los Workers con ruta se ejecutan delante de los Custom Domains y de los orígenes con proxy normales, así que la ruta wildcard también habría atrapado api.bugdump.com, el CDN y staging. Cada uno recibió su propia ruta con el Worker desactivado, y gana el patrón más específico. Añadí esas rutas primero y comprobé que la API, el CDN y staging seguían respondiendo antes de añadir el wildcard.
Te puede interesar
Localizar un widget embebido: ¿idioma de la página o del navegador? — otra historia de desarrollo: elegir el idioma de un widget que se ejecuta en la página de otra persona.
OAuth en un servidor MCP: Claude y ChatGPT se conectan sin token — cómo el servidor MCP da acceso a apps que no tienen dónde pegar un token.
Por qué los IDs de bugs deberían empezar en 1 en cada proyecto — otra decisión sobre identificadores: tareas numeradas por proyecto, con una clave como BD-42.