Cada projeto do BugDump pode publicar um portal, uma página pública onde os usuários dele veem quais bugs estão sendo corrigidos e votam nos que importam para eles. Até 12 de outubro de 2026, um portal ficava em bugdump.com/p/acme-store. Agora fica em acme-store.bugdump.com, e nenhum arquivo de rota saiu do lugar: o TanStack Router reescreve a URL na entrada e na saída, então o app continua pensando em caminhos /p/acme-store/....
Fiz isso primeiro para o Kookee, minha plataforma de conteúdo, e depois para o BugDump, no mesmo dia e com as mesmas versões (@tanstack/react-router 1.170, @tanstack/react-start 1.168, Better Auth 1.7). As quatro primeiras armadilhas abaixo vêm da implementação no Kookee; a quinta só o BugDump teve. Nenhuma delas é pega pela checagem de tipos nem por um teste unitário que não passe pelo roteador.
As rotas ficam, a URL muda
As páginas do portal já ficavam sob uma única rota, /p/$projectSlug, com a lista de tarefas no índice e cada tarefa em /p/$projectSlug/tasks/$taskPublicId. Uma segunda árvore de rotas baseada no host significaria duas cópias de cada página. Em vez disso, o TanStack Router tem uma opção para esse caso: createRouter({ rewrite: { input, output } }).
input transforma a URL do navegador na URL que o roteador compara com as rotas. Num host com exatamente um rótulo abaixo do domínio dos portais, ele coloca esse rótulo na frente do caminho:
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;
},
Assim, acme-store.bugdump.com/tasks/AS-1 corresponde a /p/acme-store/tasks/AS-1. Um caminho que já começa com o /p/acme-store do próprio host passa sem alteração, porque navigate({ href }) também executa a reescrita de entrada, e o app continua gerando os próprios caminhos internos. A verificação compara o segmento inteiro: um simples startsWith('/p/acme-store') deixaria /p/acme-storex/... renderizar o portal de outro projeto neste host.
output faz o caminho inverso, da URL do roteador para a que o navegador mostra. Um caminho de portal no próprio host perde o prefixo; um caminho para outro portal vai para o subdomínio desse portal; qualquer outro caminho num host de portal, como /auth/login, volta para bugdump.com. Quando a origem muda, o roteador marca a localização como external, e o <Link> renderiza um href absoluto. Os <Link>s do próprio portal não precisaram de nenhuma mudança.
A reescrita só é instalada quando uma variável de ambiente, VITE_PORTAL_DOMAIN, está definida. Em produção ela vale bugdump.com; desenvolvimento e staging a deixam vazia e mantêm os caminhos /p/, então o staging não precisa de certificado curinga e os testes end-to-end rodam nos mesmos caminhos de sempre.

Esse é o portal do projeto de demonstração. O roteador vê /p/acme-store; a barra de endereço do visitante mostra acme-store.bugdump.com.
Armadilha 1: o redirect que redirecionava para si mesmo
Os links antigos precisavam continuar funcionando, então a rota /p/$projectSlug ganhou um beforeLoad que responde com um redirect para o subdomínio. A primeira versão redirecionava sempre que a URL do navegador começava com /p/.
Aí alguém abre acme-store.bugdump.com/p/acme-storex/tasks (no Kookee era uma página de roteiro, com o mesmo resultado). A reescrita de entrada transforma isso em /p/acme-store/p/acme-storex/tasks, a rota casa com acme-store, a URL do navegador começa com /p/, e o redirect aponta para a página em que o visitante já está. A correção compara o slug na URL do navegador com o slug da própria rota: portalRedirectUrl só dispara quando location.publicHref mostra /p/acme-store e a rota é acme-store. Qualquer outro caminho /p/ num host de portal dá 404 ali mesmo, onde foi pedido.
O redirect é 307, não 301. Navegadores guardam redirects permanentes em cache, então, depois de um rollback, continuariam mandando as pessoas para subdomínios que já não servem portais. E o portal é noindex, então um redirect permanente não ganha nada na busca.
Armadilha 2: a reescrita de saída não pode mexer no que recebe
A primeira reescrita de saída editava o URL que recebia e o devolvia. O roteador lê o próprio href interno desse mesmo objeto depois da chamada, então tirar /p/acme-store dele mudava também o caminho interno. Os links dentro do portal passaram a apontar para as páginas do próprio app: no Kookee, a lista de feedback de um portal levava ao quadro de feedback do próprio Kookee em kookee.dev.
Agora todo ramo que muda a URL cria uma nova, e um teste unitário passa uma URL, executa a reescrita e confere que a URL continua igual.
Armadilha 3: o www mandava visitantes para outro lugar
O registro DNS e a rota curinga mandam todo subdomínio para o mesmo Worker, inclusive www e admin. No Kookee, a reescrita de saída só mantinha o /p/<label> do próprio host quando o slug era válido, então www.kookee.dev virava /p/www dentro do roteador e era mandado para kookee.dev/p/www. Agora a entrada aceita qualquer rótulo único, a saída mantém o rótulo do host no próprio host, válido ou não, e quem decide é a rota. O beforeLoad dela lança notFound() para um slug que nenhum projeto pode ter, antes de qualquer outra coisa:
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 });
},
Os parâmetros de rota chegam já decodificados do percent-encoding, então um redirect montado a partir de um slug não verificado mandaria bugdump.com/p/evil.com%3F/x para https://evil.com?.bugdump.com/x, que o navegador lê como evil.com, e /p/api/... para api.bugdump.com. A proteção recusa um slug desses antes de montar qualquer redirect, e portalPathUrl recusa de novo.
Armadilha 4: navigate() não sai da origem
O <Link> lida com uma localização externa renderizando um link comum. O router.navigate() não: ele empurra a URL de outra origem para o histórico, e o navegador recusa com um SecurityError.
O cabeçalho do portal mostra o sino de notificações do visitante logado, e cada notificação chama navigate() para o painel em bugdump.com. Esses 17 componentes agora passam por um pequeno helper que pergunta ao roteador onde a navegação vai parar e recarrega a página inteira quando o destino é outra origem:
export function navigateAnyHost(router: AnyRouter, options: NavigateOptions) {
const { publicHref } = router.buildLocation(options);
if (isOtherOrigin(publicHref)) {
window.location.assign(publicHref);
return;
}
void router.navigate(options);
}
Armadilha 5: a página 404 trazia o painel junto
O componente de not-found do Kookee já levava em conta o grupo do portal; o do BugDump não, e eu percebi isso revisando o plano, antes do lançamento. Por padrão, esse componente se envolve no layout do painel, a menos que tenha casado um grupo de rotas com layout próprio, e o grupo do portal não estava nessa lista. Num host de portal, todo caminho desconhecido, e todo rótulo reservado como www, teria renderizado o cabeçalho e o rodapé do painel e, para um visitante logado, a paleta de comandos do ⌘K. A paleta e o chat Perguntar à IA do rodapé chamam navigate() para páginas em bugdump.com, o que dispara o SecurityError da armadilha 4. Agora o grupo do portal está na lista, e um 404 num host de portal não traz mais o painel junto.
O login ficou no domínio principal
O cookie de sessão já tinha Domain=bugdump.com, então ele chega a todos os subdomínios e vai junto nas requisições deles para api.bugdump.com. Não há repasse de token: o visitante entra em bugdump.com e já está logado em todos os portais.
A API precisou de duas mudanças. O CORS respondia só para https://bugdump.com; agora devolve a origem da requisição quando ela é de um portal. O Better Auth verifica a origem de todo POST que leva cookie, então sair da conta num portal falharia com «Invalid origin». Um padrão como https://*.bugdump.com em trustedOrigins confiaria em todos os subdomínios, inclusive cdn e staging, e o * do Better Auth também casa com pontos, ou seja, a.b.bugdump.com também passaria. Em vez disso, trustedOrigins pode ser uma função da requisição, então agora ele acrescenta a origem da própria requisição quando uma regra, isPortalOrigin, a aceita: o protocolo certo, exatamente um rótulo e um slug que um projeto poderia ter. O CORS usa a mesma regra.
Um slug agora é um rótulo DNS
Um slug que vira subdomínio precisa ser um rótulo de hostname válido: de 3 a 63 letras minúsculas, dígitos e hifens, sem hífen em nenhuma das pontas e sem hifens na terceira e na quarta posições, que o IDNA reserva para rótulos codificados como xn-- (RFC 5890 e 5891). O 3 é escolha minha: o DNS aceita um caractere, e o mínimo deixa os nomes de uma e duas letras livres para o BugDump.
Alguns nomes também precisam ficar com o BugDump. A lista de reservados tem mais de 200, de www, api, cdn e staging a status, docs, admin e reports, além de tudo que começa com bugdump-. Antes do lançamento, rodei as mesmas regras como uma consulta SQL em produção, para achar projetos cujo slug atual quebraria. A consulta não retornou nenhuma linha.
Cloudflare: rotas passam na frente de Custom Domains
O site principal e o staging são Workers ligados como Custom Domains. Um portal precisa que todo host *.bugdump.com chegue ao Worker de produção, e isso exige duas coisas: um registro DNS curinga, * AAAA 100:: com o proxy ligado, e uma rota de Workers, *.bugdump.com/*. Registros DNS explícitos continuam tendo prioridade sobre o curinga, e o certificado Universal já cobre um nível de subdomínios.
O que importa é a ordem em que o Cloudflare aplica isso. Workers de rota rodam na frente de Custom Domains e de origens comuns com proxy, então a rota curinga também pegaria api.bugdump.com, a CDN e o staging. Cada um deles ganhou uma rota própria com o Worker desativado, e o padrão mais específico vence. Adicionei essas rotas primeiro e conferi que a API, a CDN e o staging continuavam respondendo antes de adicionar o curinga.
Leia também
O nome de máquina que também era o nome de exibição — uma chave estável que acabou servindo também de texto na tela, e o que isso custou quando as prioridades ficaram editáveis.
Localizando um widget incorporado: idioma da página ou do navegador? — outra história de implementação: escolher o idioma de um widget que roda na página de outra pessoa.
OAuth no servidor MCP: Claude e ChatGPT se conectam sem token — como o servidor MCP faz login de apps que não têm onde colar um token.