Chaque projet BugDump peut publier un portail, une page publique où ses utilisateurs voient quels bugs sont en cours de correction et votent pour ceux qui les intéressent. Jusqu'au 12 octobre 2026, un portail se trouvait à l'adresse bugdump.com/p/acme-store. Il est désormais à acme-store.bugdump.com, et pas un seul fichier de route n'a bougé : TanStack Router réécrit l'URL à l'arrivée et au départ, si bien que l'app raisonne toujours en chemins /p/acme-store/....
Je l'ai d'abord construit pour Kookee, ma plateforme de contenu, puis pour BugDump, le même jour et avec les mêmes versions (@tanstack/react-router 1.170, @tanstack/react-start 1.168, Better Auth 1.7). Les quatre premiers pièges ci-dessous viennent du chantier Kookee ; le cinquième, seul BugDump l'avait. Aucun ne fait échouer une vérification de types ni un test unitaire qui ne passe pas par le routeur.
Les routes restent, l'URL change
Les pages du portail vivaient déjà sous une seule route, /p/$projectSlug, avec la liste des tâches à son index et chaque tâche à /p/$projectSlug/tasks/$taskPublicId. Un second arbre de routes indexé sur l'hôte aurait voulu dire deux copies de chaque page. TanStack Router a une option prévue pour ce cas : createRouter({ rewrite: { input, output } }).
input transforme l'URL du navigateur en URL que le routeur fait correspondre à ses routes. Sur un hôte qui a exactement un label sous le domaine des portails, il place ce label devant le chemin :
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;
},
Ainsi, acme-store.bugdump.com/tasks/AS-1 correspond à /p/acme-store/tasks/AS-1. Un chemin qui commence déjà par le /p/acme-store de l'hôte passe tel quel, car navigate({ href }) exécute lui aussi le rewrite d'entrée, et l'app produit toujours ses propres chemins internes. La vérification compare le segment entier : un simple startsWith('/p/acme-store') laisserait /p/acme-storex/... afficher le portail d'un autre projet sur cet hôte.
output fait le chemin inverse, de l'URL du routeur à celle qu'affiche le navigateur. Un chemin de portail sur son propre hôte perd le préfixe ; un chemin vers un autre portail part vers le sous-domaine de ce portail ; tout autre chemin sur un hôte de portail, comme /auth/login, retourne sur bugdump.com. Quand l'origine change, le routeur marque la location comme external, et <Link> produit un href absolu. Les <Link> du portail lui-même n'ont pas eu à changer.
Le rewrite n'est installé que si une variable d'environnement, VITE_PORTAL_DOMAIN, est définie. La production la règle sur bugdump.com ; le développement et le staging la laissent vide et gardent les chemins /p/, si bien que le staging n'a pas besoin de certificat wildcard et que les tests end-to-end tournent sur les chemins qu'ils ont toujours utilisés.

C'est le portail du projet de démo. Le routeur voit /p/acme-store ; la barre d'adresse du visiteur affiche acme-store.bugdump.com.
Piège 1 : la redirection qui redirigeait vers elle-même
Les anciens liens devaient continuer de fonctionner, donc la route /p/$projectSlug a reçu un beforeLoad qui répond par une redirection vers le sous-domaine. La première version redirigeait dès que l'URL du navigateur commençait par /p/.
Puis quelqu'un ouvre acme-store.bugdump.com/p/acme-storex/tasks (dans Kookee, c'était une page de feuille de route, avec le même résultat). Le rewrite d'entrée en fait /p/acme-store/p/acme-storex/tasks, la route correspond à acme-store, l'URL du navigateur commence par /p/, et la redirection pointe vers la page où l'on se trouve déjà. Le correctif compare le slug de l'URL du navigateur avec celui de la route : portalRedirectUrl ne se déclenche que lorsque location.publicHref montre /p/acme-store et que la route est acme-store. Tout autre chemin /p/ sous un hôte de portail donne une 404 là où il a été demandé.
La redirection est une 307, pas une 301. Les navigateurs mettent en cache les redirections permanentes : après un rollback, ils continueraient d'envoyer les gens vers des sous-domaines qui ne servent plus de portails. Et comme le portail est en noindex, une redirection permanente n'apporte rien côté référencement.
Piège 2 : le rewrite de sortie ne doit pas toucher à ce qu'il reçoit
Le premier rewrite de sortie modifiait l'objet URL qu'il recevait et le renvoyait. Le routeur lit son propre href interne dans ce même objet après l'appel, donc retirer /p/acme-store de cet objet changeait aussi le chemin interne. Les liens du portail se sont mis à mener aux pages de l'app elle-même : dans Kookee, la liste des retours d'un portail renvoyait vers le tableau des retours de Kookee lui-même, sur kookee.dev.
Chaque branche qui modifie l'URL en construit désormais une nouvelle, et un test unitaire passe une URL au rewrite, l'exécute et vérifie que l'URL n'a pas changé.
Piège 3 : www envoyait les visiteurs ailleurs
L'enregistrement DNS et la route wildcard envoient chaque sous-domaine vers le même Worker, www et admin compris. Dans Kookee, le rewrite de sortie ne gardait le /p/<label> d'un hôte en place que pour un slug valide, donc www.kookee.dev devenait /p/www dans le routeur et était renvoyé vers kookee.dev/p/www. Désormais, l'entrée accepte tout hôte à un seul label, la sortie garde le label d'un hôte sur cet hôte, valide ou non, et c'est la route qui décide. Son beforeLoad lève notFound() pour un slug qu'aucun projet ne peut avoir, avant que quoi que ce soit d'autre ne s'exécute :
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 });
},
Les paramètres de route arrivent déjà décodés, donc une redirection construite à partir d'un slug non vérifié enverrait bugdump.com/p/evil.com%3F/x vers https://evil.com?.bugdump.com/x, qu'un navigateur lit comme evil.com, et /p/api/... vers api.bugdump.com. La garde refuse un tel slug avant de construire la moindre redirection, et portalPathUrl le refuse une seconde fois.
Piège 4 : navigate() ne peut pas quitter l'origine
<Link> gère une location externe en produisant un lien ordinaire. router.navigate(), non : il pousse l'URL d'une autre origine dans l'historique, et le navigateur refuse avec une SecurityError.
L'en-tête du portail affiche la cloche de notifications du visiteur connecté, et chaque notification appelle navigate() vers le tableau de bord sur bugdump.com. Ces 17 composants passent désormais par un petit helper qui demande au routeur où la navigation aboutirait et recharge la page entière quand il s'agit d'une autre origine :
export function navigateAnyHost(router: AnyRouter, options: NavigateOptions) {
const { publicHref } = router.buildLocation(options);
if (isOtherOrigin(publicHref)) {
window.location.assign(publicHref);
return;
}
void router.navigate(options);
}
Piège 5 : la page 404 embarquait le tableau de bord
Le composant not-found de Kookee comptait déjà son groupe de portail ; celui de BugDump, non, et je l'ai découvert en relisant le plan, avant la mise en production. Mon composant not-found par défaut s'enveloppe dans le layout du tableau de bord, sauf si un groupe de routes doté de son propre layout a correspondu, et le groupe du portail ne figurait pas dans cette liste. Sur un hôte de portail, chaque chemin inconnu, et chaque label réservé comme www, aurait affiché l'en-tête et le pied de page du tableau de bord et, pour un visiteur connecté, sa palette de commandes ⌘K. La palette et le chat « Demander à l'IA » du pied de page appellent navigate() vers des pages de bugdump.com, ce qui déclenche la SecurityError du piège 4. Le groupe du portail est maintenant dans la liste, donc une 404 sur un hôte de portail n'embarque plus le tableau de bord.
La connexion est restée sur le domaine principal
Le cookie de session avait déjà Domain=bugdump.com : il atteint donc chaque sous-domaine et accompagne leurs requêtes vers api.bugdump.com. Pas de passage de jeton d'un domaine à l'autre : un visiteur se connecte sur bugdump.com et il est connecté sur tous les portails.
L'API a demandé deux changements. CORS ne répondait qu'à https://bugdump.com ; il renvoie désormais l'origine de la requête quand c'est celle d'un portail. Better Auth vérifie l'origine de chaque POST qui porte un cookie, donc une déconnexion depuis un portail échouerait avec « Invalid origin ». Un motif comme https://*.bugdump.com dans trustedOrigins ferait confiance à tous les sous-domaines, cdn et staging compris, et le * de Better Auth accepte aussi les points, donc a.b.bugdump.com également. trustedOrigins peut aussi être une fonction de la requête. Il ajoute donc désormais l'origine de la requête elle-même quand la règle isPortalOrigin l'accepte : le bon protocole, exactement un label et un slug qu'un projet pourrait avoir. CORS utilise la même règle.
Un slug est désormais un label DNS
Un slug qui devient un sous-domaine doit être un label de nom d'hôte valide : de 3 à 63 lettres minuscules, chiffres et tirets, sans tiret à l'une ou l'autre extrémité, et sans tirets en troisième et quatrième positions, que l'IDNA réserve aux labels encodés comme xn-- (RFC 5890 et 5891). Le 3, c'est mon choix : le DNS accepte un seul caractère, et ce minimum garde les noms d'une ou deux lettres libres pour BugDump.
Certains noms doivent aussi rester à BugDump. La liste des noms réservés en compte plus de 200, de www, api, cdn et staging à status, docs, admin et reports, plus tout ce qui commence par bugdump-. Avant la mise en production, j'ai exécuté les mêmes règles sous forme de requête SQL sur la base de production, pour trouver les projets dont le slug existant ne passerait plus. Elle n'a renvoyé aucune ligne.
Cloudflare : les routes passent avant les Custom Domains
Le site principal et le staging sont des Workers rattachés comme Custom Domains. Un portail a besoin que chaque hôte *.bugdump.com atteigne le Worker de production, ce qui demande deux choses : un enregistrement DNS wildcard, * AAAA 100:: avec le proxy activé, et une route Workers, *.bugdump.com/*. Les enregistrements DNS explicites gardent la priorité sur le wildcard, et le certificat Universal couvre déjà un niveau de sous-domaines.
Ce qui compte, c'est l'ordre dans lequel Cloudflare les applique. Les Workers sur route passent devant les Custom Domains et les simples origines proxifiées, donc la route wildcard aurait aussi capté api.bugdump.com, le CDN et le staging. Chacun a reçu sa propre route avec le Worker désactivé, et le motif le plus spécifique l'emporte. J'ai ajouté ces routes en premier et vérifié que l'API, le CDN et le staging répondaient toujours avant d'ajouter le wildcard.
Pour aller plus loin
Localiser un widget intégré : langue de la page ou du navigateur ? — une autre histoire de construction : choisir la langue d'un widget qui tourne sur la page de quelqu'un d'autre.
OAuth pour un serveur MCP : Claude et ChatGPT se connectent sans jeton — comment le serveur MCP connecte des apps qui n'ont nulle part où coller un jeton.
Des widgets intégrés qui suivent le mode sombre de votre application — un autre problème de code qui tourne chez quelqu'un d'autre : suivre le bouton de thème de l'app qui héberge le widget.