Каждый проект в BugDump может опубликовать портал — публичную страницу, где его пользователи видят, какие баги сейчас исправляются, и голосуют за те, что важны им. До 12 октября 2026 года портал жил по адресу bugdump.com/p/acme-store. Теперь он живёт на acme-store.bugdump.com, и ни один файл роутов не сдвинулся: TanStack Router переписывает URL на входе и на выходе, так что приложение по-прежнему мыслит путями /p/acme-store/....
Сначала я сделал это для Kookee, моей контент-платформы, а потом для BugDump — в тот же день и на тех же версиях (@tanstack/react-router 1.170, @tanstack/react-start 1.168, Better Auth 1.7). Первые четыре ловушки ниже — из работы над Kookee, пятая была только в BugDump. Ни одна из них не роняет проверку типов или юнит-тест, который не проходит через роутер.
Роуты остаются, URL меняется
Страницы портала и так жили под одним роутом, /p/$projectSlug: список задач на индексной странице, каждая задача — на /p/$projectSlug/tasks/$taskPublicId. Второе дерево роутов с ключом по хосту означало бы две копии каждой страницы. Вместо этого у TanStack Router есть опция ровно для такого случая: createRouter({ rewrite: { input, output } }).
input превращает URL браузера в URL, с которым сверяется роутер. Если у хоста под доменом порталов ровно одна DNS-метка, input ставит эту метку перед путём:
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;
},
Так acme-store.bugdump.com/tasks/AS-1 совпадает с /p/acme-store/tasks/AS-1. Путь, который уже начинается с собственного /p/acme-store этого хоста, проходит без изменений: navigate({ href }) тоже прогоняет input-rewrite, а приложение по-прежнему строит свои внутренние пути. Проверка сравнивает сегмент целиком: простой startsWith('/p/acme-store') позволил бы /p/acme-storex/... отрисовать на этом хосте портал другого проекта.
output работает в обратную сторону: из URL роутера делает тот, что показывает браузер. Путь портала на его собственном хосте теряет префикс; путь к другому порталу уходит на поддомен того портала; любой другой путь на хосте портала, например /auth/login, возвращается на bugdump.com. Когда меняется origin, роутер помечает location как external, и <Link> рендерит абсолютный href. Собственные <Link> портала менять не пришлось.
Rewrite подключается, только когда задана переменная окружения VITE_PORTAL_DOMAIN. В продакшене она равна bugdump.com; в разработке и на стейджинге она пустая, и пути остаются с /p/. Поэтому стейджингу не нужен wildcard-сертификат, а end-to-end-тесты ходят по тем же путям, что и всегда.

Это портал демо-проекта. Роутер видит /p/acme-store, а в адресной строке посетителя — acme-store.bugdump.com.
Ловушка 1: редирект, который вёл сам на себя
Старые ссылки должны были работать и дальше, поэтому роут /p/$projectSlug получил beforeLoad, который отвечает редиректом на поддомен. Первая версия редиректила всякий раз, когда URL браузера начинался с /p/.
И тут кто-то открывает acme-store.bugdump.com/p/acme-storex/tasks (в Kookee это была страница дорожной карты, с тем же результатом). Input-rewrite превращает это в /p/acme-store/p/acme-storex/tasks, роут совпадает с acme-store, URL браузера начинается с /p/, и редирект указывает на ту самую страницу, на которой посетитель уже находится. Исправление сравнивает слаг в URL браузера со слагом самого роута: portalRedirectUrl срабатывает, только когда location.publicHref показывает /p/acme-store, а роут — acme-store. Любой другой путь /p/ на хосте портала отдаёт 404 там же, где его запросили.
Редирект — 307, а не 301. Браузеры кешируют постоянные редиректы, так что после отката они продолжали бы отправлять людей на поддомены, которые уже не отдают порталы. А портал закрыт noindex, так что постоянный редирект ничего не даёт и в поиске.
Ловушка 2: output-rewrite не должен трогать свой вход
Первый output-rewrite правил полученный URL и его же возвращал. После вызова роутер читает свой внутренний href из того же объекта, поэтому, когда из него вырезался /p/acme-store, менялся и внутренний путь. Ссылки внутри портала стали вести на страницы самого приложения: в Kookee список обратной связи на портале ссылался на доску обратной связи самого Kookee на kookee.dev.
Теперь каждая ветка, которая меняет URL, создаёт новый объект, а юнит-тест передаёт URL, прогоняет rewrite и проверяет, что URL не изменился.
Ловушка 3: www уводил посетителей не туда
Wildcard-запись и роут отправляют каждый поддомен в один и тот же Worker, включая www и admin. В Kookee output-rewrite оставлял собственный /p/<label> хоста на месте только для допустимого слага, поэтому www.kookee.dev внутри роутера становился /p/www и отправлялся на kookee.dev/p/www. Теперь input принимает любую одиночную метку, output оставляет собственную метку хоста на его хосте, допустимая она или нет, а решает роут. Его beforeLoad бросает notFound() для слага, которого не может быть ни у одного проекта, раньше всего остального:
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 });
},
Параметры роута приходят уже percent-декодированными, поэтому редирект, собранный из непроверенного слага, отправил бы bugdump.com/p/evil.com%3F/x на https://evil.com?.bugdump.com/x, который браузер читает как evil.com, а /p/api/... — на api.bugdump.com. Проверка отклоняет такой слаг до того, как собирается редирект, а portalPathUrl отклоняет его ещё раз.
Ловушка 4: navigate() не может покинуть origin
<Link> на внешний location рендерит обычную ссылку. router.navigate() так не делает: он кладёт URL с другого origin в history, и браузер отказывает с SecurityError.
В шапке портала висит колокольчик уведомлений вошедшего посетителя, и каждое уведомление вызывает navigate() в дашборд на bugdump.com. Эти 17 компонентов теперь идут через маленький хелпер, который спрашивает у роутера, куда приведёт навигация, и делает полную загрузку страницы, если это другой origin:
export function navigateAnyHost(router: AnyRouter, options: NavigateOptions) {
const { publicHref } = router.buildLocation(options);
if (isOtherOrigin(publicHref)) {
window.location.assign(publicHref);
return;
}
void router.navigate(options);
}
Ловушка 5: страница 404 притащила с собой дашборд
Компонент not-found в Kookee уже учитывал группу роутов портала, а в BugDump — нет. Я заметил это, когда проверял план, ещё до релиза. Компонент not-found по умолчанию оборачивает себя в лейаут дашборда, если не совпала группа роутов со своим лейаутом, а группы портала в этом списке не было. На хосте портала любой неизвестный путь и любая зарезервированная метка вроде www отрисовали бы шапку и подвал дашборда, а для вошедшего посетителя — ещё и палитру команд ⌘K. Палитра и чат «Спросить ИИ» в подвале вызывают navigate() на страницы bugdump.com, а это та самая SecurityError из ловушки 4. Теперь группа портала в списке, и 404 на хосте портала больше не тащит за собой дашборд.
Вход остался на основном домене
У сессионной куки и так был Domain=bugdump.com, поэтому она доходит до каждого поддомена и уходит с их запросами на api.bugdump.com. Передавать токен не нужно: посетитель входит на bugdump.com и сразу авторизован на каждом портале.
В API понадобилось два изменения. Раньше CORS отвечал только https://bugdump.com; теперь он возвращает origin запроса, если это origin портала. Better Auth проверяет origin каждого POST-запроса с кукой, так что выход из аккаунта на портале падал бы с «Invalid origin». Шаблон вроде https://*.bugdump.com в trustedOrigins доверял бы каждому поддомену, включая cdn и staging, а * в Better Auth совпадает и с точками, то есть ещё и с a.b.bugdump.com. Но trustedOrigins может быть функцией от запроса, и теперь она добавляет собственный origin запроса, когда его принимает одно правило, isPortalOrigin: правильный протокол, ровно одна метка и слаг, который может быть у проекта. CORS использует то же правило.
Слаг теперь — DNS-метка
Слаг, который становится поддоменом, должен быть допустимой меткой имени хоста: от 3 до 63 строчных латинских букв, цифр и дефисов, без дефиса на концах и без дефисов на третьей и четвёртой позициях — IDNA резервирует их для закодированных меток вроде xn-- (RFC 5890 и 5891). Тройку выбрал я: DNS разрешает и один символ, а минимум оставляет одно- и двухбуквенные имена за BugDump.
Некоторые имена тоже должны остаться за BugDump. В списке зарезервированных их больше 200: от www, api, cdn и staging до status, docs, admin и reports, плюс всё, что начинается с bugdump-. Перед релизом я прогнал те же правила SQL-запросом по продакшен-базе, чтобы найти проекты, чей текущий слаг сломается. Запрос не вернул ни одной строки.
Cloudflare: роуты главнее Custom Domains
Основной сайт и стейджинг — это Workers, подключённые как Custom Domains. Порталу нужно, чтобы каждый хост *.bugdump.com попадал в продакшен-Worker, а для этого нужны две вещи: wildcard-запись DNS * AAAA 100:: с включённым прокси и Workers route *.bugdump.com/*. Явные DNS-записи по-прежнему приоритетнее wildcard, а Universal-сертификат и так покрывает один уровень поддоменов.
Важно, в каком порядке Cloudflare их применяет. Workers на роутах срабатывают раньше Custom Domains и обычных проксируемых origin-серверов, поэтому wildcard-роут перехватил бы и api.bugdump.com, и CDN, и стейджинг. Каждому из них я дал собственный роут с отключённым Worker, а более конкретный шаблон побеждает. Сначала я добавил эти роуты и проверил, что API, CDN и стейджинг отвечают, и только потом добавил wildcard.
Читайте также
Почему нумерация багов в каждом проекте должна начинаться с 1 — откуда в адресах задач ключи вроде AS-1 и почему у каждого проекта своя нумерация.
Локализация встраиваемого виджета: язык страницы или язык браузера? — ещё одна история из разработки: как выбрать язык для виджета, который работает на чужой странице.
OAuth на MCP-сервере: Claude и ChatGPT подключаются без токена — как MCP-сервер авторизует приложения, в которые некуда вставить токен.