Each BugDump project can publish a portal, a public page where its users see which bugs are being fixed and vote for the ones they care about. Until October 12, 2026 a portal lived at bugdump.com/p/acme-store. Now it lives at acme-store.bugdump.com, and not one route file moved: TanStack Router rewrites the URL as it comes in and as it goes out, so the app still thinks in /p/acme-store/... paths.
We built it first for Kookee, our content platform, and then for BugDump, on the same day and the same versions (@tanstack/react-router 1.170, @tanstack/react-start 1.168, Better Auth 1.7). The first four traps below come from Kookee's build; the fifth only BugDump had. None of them fails a type check or a unit test that doesn't go through the router.
The routes stay, the URL changes
The portal's pages already lived under one route, /p/$projectSlug, with the task list at its index and each task at /p/$projectSlug/tasks/$taskPublicId. A second route tree keyed on the host would have meant two copies of every page. TanStack Router has an option for this case instead: createRouter({ rewrite: { input, output } }).
input turns the browser's URL into the URL the router matches. On a host with exactly one label under the portal domain, it puts that label in front of the path:
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;
},
So acme-store.bugdump.com/tasks/AS-1 matches /p/acme-store/tasks/AS-1. A path that already starts with the host's own /p/acme-store passes through untouched, because navigate({ href }) runs the input rewrite too, and the app still produces its own internal paths. The check compares the whole segment: a plain startsWith('/p/acme-store') would let /p/acme-storex/... render another project's portal on this host.
output goes the other way, from the router's URL to the one the browser shows. A portal path on its own host loses the prefix; a path to another portal goes to that portal's subdomain; any other path on a portal host, such as /auth/login, goes back to bugdump.com. When the origin changes, the router marks the location external, and <Link> renders an absolute href. The portal's own <Link>s needed no change.
The rewrite is installed only when an environment variable, VITE_PORTAL_DOMAIN, is set. Production sets it to bugdump.com; development and staging leave it empty and keep /p/ paths, so staging needs no wildcard certificate and the end-to-end tests run against the paths they always used.

That is the demo project's portal. The router sees /p/acme-store; the visitor's address bar shows acme-store.bugdump.com.
Trap 1: the redirect that redirected to itself
Old links had to keep working, so the /p/$projectSlug route got a beforeLoad that answers with a redirect to the subdomain. The first version redirected whenever the browser's URL started with /p/.
Then someone opens acme-store.bugdump.com/p/acme-storex/tasks (in Kookee it was a roadmap page, with the same result). The input rewrite makes that /p/acme-store/p/acme-storex/tasks, the route matches acme-store, the browser's URL starts with /p/, and the redirect points at the page it is already on. The fix compares the slug in the browser's URL with the route's own: portalRedirectUrl fires only when location.publicHref shows /p/acme-store and the route is acme-store. Any other /p/ path under a portal host is a 404 where it was asked for.
The redirect is a 307, not a 301. Browsers cache permanent redirects, so after a rollback they would keep sending people to subdomains that no longer serve portals, and the portal is noindex, so a permanent redirect gains nothing in search.
Trap 2: the output rewrite must not touch its input
The first output rewrite edited the URL it was given and returned it. The router reads its own internal href from that same object after the call, so stripping /p/acme-store from it changed the internal path too. Links inside the portal started resolving to the app's own pages: in Kookee, a portal's feedback list linked to Kookee's own feedback board on kookee.dev.
Every branch that changes the URL now builds a new one, and a unit test passes in a URL, runs the rewrite and checks that the URL is unchanged.
Trap 3: www sent visitors somewhere else
The wildcard record and route send every subdomain to the same Worker, www and admin included. In Kookee, the output rewrite kept a host's own /p/<label> in place only for a valid slug, so www.kookee.dev became /p/www inside the router and was sent to kookee.dev/p/www. Now the input takes any single label, the output keeps a host's own label on its host whether valid or not, and the route decides. Its beforeLoad throws notFound() for a slug no project can have, before anything else runs:
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 });
},
Route params arrive percent-decoded, so a redirect built from an unchecked slug would send bugdump.com/p/evil.com%3F/x to https://evil.com?.bugdump.com/x, which a browser reads as evil.com, and /p/api/... to api.bugdump.com. The guard refuses such a slug before any redirect is built, and portalPathUrl refuses it again.
Trap 4: navigate() cannot leave the origin
<Link> handles an external location by rendering a plain link. router.navigate() does not: it pushes the cross-origin URL into history, and the browser refuses with a SecurityError.
The portal's header shows the signed-in visitor's notification bell, and every notification calls navigate() into the dashboard on bugdump.com. Those 17 components now go through a small helper that asks the router where the navigation would end up and does a full page load when that is another 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);
}
Trap 5: the 404 page brought the dashboard with it
Kookee's not-found component already counted its portal group; BugDump's did not, and we found it while reviewing the plan, before release. Our default not-found component wraps itself in the dashboard's layout unless a route group with its own layout matched, and the portal group was not on that list. On a portal host, every unknown path, and every reserved label like www, would have rendered the dashboard's header and footer and, for a signed-in visitor, its ⌘K command palette. The palette and the footer's Ask AI chat call navigate() into pages on bugdump.com, which throws trap 4's SecurityError. The portal group is on the list now, so a 404 on a portal host no longer brings the dashboard with it.
Signing in stayed on the main domain
The session cookie already had Domain=bugdump.com, so it reaches every subdomain and goes with their requests to api.bugdump.com. There is no token handoff: a visitor signs in on bugdump.com and is signed in on every portal.
The API needed two changes. CORS used to answer only https://bugdump.com; it now returns the request's origin when it is a portal's. Better Auth checks the origin of every POST that carries a cookie, so signing out from a portal would fail with "Invalid origin". A pattern like https://*.bugdump.com in trustedOrigins would trust every subdomain, cdn and staging included, and Better Auth's * also matches dots, so a.b.bugdump.com too. trustedOrigins can be a function of the request instead, so it now adds the request's own origin when one rule, isPortalOrigin, accepts it: the right protocol, exactly one label, and a slug a project could have. CORS uses the same rule.
A slug is now a DNS label
A slug that becomes a subdomain has to be a valid hostname label: 3 to 63 lowercase letters, digits and hyphens, no hyphen at either end, and no hyphens in the third and fourth places, which IDNA reserves for encoded labels such as xn-- (RFC 5890 and 5891). The 3 is ours: DNS allows one character, and the minimum keeps one- and two-letter names free for BugDump.
Some names also have to stay ours. The reserved list has over 200 of them, from www, api, cdn and staging to status, docs, admin and reports, plus anything starting with bugdump-. Before the release we ran the same rules as a SQL query against production, to find projects whose existing slug would break. It returned no rows.
Cloudflare: routes beat Custom Domains
The main site and staging are Workers attached as Custom Domains. A portal needs every *.bugdump.com host to reach the production Worker, which takes two things: a wildcard DNS record, * AAAA 100:: with the proxy on, and a Workers route, *.bugdump.com/*. Explicit DNS records keep priority over the wildcard, and the Universal certificate already covers one level of subdomains.
What matters is the order Cloudflare applies them in. Route Workers run in front of Custom Domains and plain proxied origins, so the wildcard route would also have caught api.bugdump.com, the CDN and staging. Each of those got its own route with the Worker disabled, and the more specific pattern wins. We added those first and checked that the API, the CDN and staging still answered before adding the wildcard.
Continue reading
Setting up the public portal — what the portal shows, the slug rules, and which tasks and report details are public.
Localizing an Embedded Widget: Page Language or Browser Language? — another build story: picking a language for a widget that runs on someone else's page.
MCP Server OAuth: Letting Claude and ChatGPT Connect Without a Token — how the MCP server signs in apps that have nowhere to paste a token.