Subdomínios por tenant no TanStack Start com reescrita de URL

tanstack router

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:

ts
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.

O portal da Acme Store: tarefas públicas com status, prioridade, número de relatórios e botões de voto

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:

ts
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:

ts
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