Tenant-Subdomains in TanStack Start mit Router-Rewrites

tanstack router

Jedes BugDump-Projekt kann ein Portal veröffentlichen: eine öffentliche Seite, auf der seine Community sieht, welche Bugs gerade behoben werden, und für die abstimmt, die ihr wichtig sind. Bis zum 12. Oktober 2026 lag ein Portal unter bugdump.com/p/acme-store. Jetzt liegt es unter acme-store.bugdump.com, und keine einzige Route-Datei hat sich bewegt: TanStack Router schreibt die URL um, wenn sie hereinkommt und wenn sie hinausgeht, und die App denkt weiter in /p/acme-store/...-Pfaden.

Ich habe das zuerst für Kookee gebaut, meine Content-Plattform, und dann für BugDump, am selben Tag und mit denselben Versionen (@tanstack/react-router 1.170, @tanstack/react-start 1.168, Better Auth 1.7). Die ersten vier Fallen unten stammen aus dem Umbau von Kookee, die fünfte gab es nur bei BugDump. Keine davon fällt in einem Typecheck auf oder in einem Unit-Test, der nicht durch den Router geht.

Die Routen bleiben, die URL ändert sich

Die Seiten des Portals lagen schon unter einer Route, /p/$projectSlug: die Aufgabenliste auf ihrem Index, jede Aufgabe unter /p/$projectSlug/tasks/$taskPublicId. Ein zweiter Routenbaum, der nach dem Host unterscheidet, hätte zwei Kopien jeder Seite bedeutet. TanStack Router hat für diesen Fall stattdessen eine Option: createRouter({ rewrite: { input, output } }).

input macht aus der URL des Browsers die URL, die der Router auflöst. Auf einem Host mit genau einem Label unter der Portal-Domain setzt es dieses Label vor den Pfad:

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;
},

So landet acme-store.bugdump.com/tasks/AS-1 auf /p/acme-store/tasks/AS-1. Ein Pfad, der schon mit dem hosteigenen /p/acme-store beginnt, geht unverändert durch, denn auch navigate({ href }) durchläuft den Input-Rewrite, und die App erzeugt weiterhin ihre eigenen internen Pfade. Die Prüfung vergleicht das ganze Segment: Ein einfaches startsWith('/p/acme-store') würde /p/acme-storex/... das Portal eines anderen Projekts auf diesem Host rendern lassen.

output geht den umgekehrten Weg, von der URL des Routers zu der, die der Browser anzeigt. Ein Portal-Pfad auf seinem eigenen Host verliert das Präfix, ein Pfad zu einem anderen Portal geht auf dessen Subdomain, jeder andere Pfad auf einem Portal-Host, etwa /auth/login, geht zurück auf bugdump.com. Wenn sich der Ursprung ändert, markiert der Router die Location als external, und <Link> rendert ein absolutes href. Die <Link>s des Portals selbst brauchten keine Änderung.

Der Rewrite wird nur installiert, wenn die Umgebungsvariable VITE_PORTAL_DOMAIN gesetzt ist. In Produktion steht sie auf bugdump.com, Entwicklung und Staging lassen sie leer und behalten die /p/-Pfade. So braucht Staging kein Wildcard-Zertifikat, und die End-to-End-Tests laufen gegen dieselben Pfade wie immer.

Das Portal von Acme Store: öffentliche Aufgaben mit Status, Priorität, Anzahl der Berichte und Buttons zum Abstimmen

Das ist das Portal des Demo-Projekts. Der Router sieht /p/acme-store, die Adresszeile im Browser zeigt acme-store.bugdump.com.

Falle 1: der Redirect, der auf sich selbst umleitete

Alte Links sollten weiter funktionieren, also bekam die Route /p/$projectSlug ein beforeLoad, das mit einem Redirect auf die Subdomain antwortet. Die erste Version leitete immer dann um, wenn die URL im Browser mit /p/ begann.

Dann öffnet jemand acme-store.bugdump.com/p/acme-storex/tasks (bei Kookee war es eine Roadmap-Seite, mit demselben Ergebnis). Der Input-Rewrite macht daraus /p/acme-store/p/acme-storex/tasks, die Route matcht acme-store, die URL im Browser beginnt mit /p/, und der Redirect zeigt auf genau die Seite, auf der man schon ist. Der Fix vergleicht den Slug in der Browser-URL mit dem der Route: portalRedirectUrl greift nur, wenn location.publicHref /p/acme-store zeigt und die Route acme-store ist. Jeder andere /p/-Pfad unter einem Portal-Host ergibt einen 404, genau dort, wo er aufgerufen wurde.

Der Redirect ist ein 307, kein 301. Browser cachen permanente Redirects, nach einem Rollback würden sie also weiter auf Subdomains umleiten, die keine Portale mehr ausliefern. Und das Portal ist noindex, ein permanenter Redirect bringt in der Suche also nichts.

Falle 2: Der Output-Rewrite darf seine Eingabe nicht anfassen

Der erste Output-Rewrite änderte das URL-Objekt, das er bekam, und gab es zurück. Der Router liest nach dem Aufruf sein eigenes internes href aus genau diesem Objekt, also änderte das Entfernen von /p/acme-store auch den internen Pfad. Links im Portal führten plötzlich auf die eigenen Seiten der App: Bei Kookee verlinkte die Feedback-Liste eines Portals auf Kookees eigenes Feedback-Board auf kookee.dev.

Jeder Zweig, der die URL ändert, baut jetzt eine neue, und ein Unit-Test übergibt eine URL, führt den Rewrite aus und prüft, dass die URL unverändert bleibt.

Falle 3: www führte ganz woandershin

Der Wildcard-Eintrag und die Wildcard-Route schicken jede Subdomain an denselben Worker, auch www und admin. Bei Kookee ließ der Output-Rewrite das hosteigene /p/<label> nur bei einem gültigen Slug stehen. So wurde www.kookee.dev im Router zu /p/www, und wer die Seite öffnete, landete auf kookee.dev/p/www. Jetzt nimmt der Input jedes einzelne Label an, der Output lässt das eigene Label eines Hosts auf diesem Host, ob gültig oder nicht, und die Route entscheidet. Ihr beforeLoad wirft notFound() für einen Slug, den kein Projekt haben kann, bevor irgendetwas anderes läuft:

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 });
},

Route-Parameter kommen percent-decodiert an. Ein Redirect aus einem ungeprüften Slug würde also bugdump.com/p/evil.com%3F/x nach https://evil.com?.bugdump.com/x schicken, was ein Browser als evil.com liest, und /p/api/... nach api.bugdump.com. Der Guard lehnt einen solchen Slug ab, bevor irgendein Redirect entsteht, und portalPathUrl lehnt ihn noch einmal ab.

Falle 4: navigate() kann den Ursprung nicht verlassen

<Link> behandelt eine externe Location, indem es einen gewöhnlichen Link rendert. router.navigate() tut das nicht: Es schiebt die Cross-Origin-URL in die History, und der Browser verweigert das mit einem SecurityError.

Wer angemeldet ist, sieht im Header des Portals die Benachrichtigungsglocke, und jede Benachrichtigung ruft navigate() ins Dashboard auf bugdump.com auf. Diese 17 Komponenten laufen jetzt über einen kleinen Helper, der den Router fragt, wo die Navigation landen würde, und die Seite komplett neu lädt, wenn das Ziel auf einem anderen Ursprung liegt:

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);
}

Falle 5: Die 404-Seite brachte das Dashboard mit

Die Not-found-Komponente von Kookee berücksichtigte ihre Portal-Gruppe bereits, die von BugDump nicht. Ich habe das bei der Durchsicht des Plans gefunden, noch vor dem Release. Die Standard-Not-found-Komponente der App packt sich in das Layout des Dashboards, außer eine Route-Gruppe mit eigenem Layout hat gematcht, und die Portal-Gruppe stand nicht auf dieser Liste. Auf einem Portal-Host hätte jeder unbekannte Pfad und jedes reservierte Label wie www Header und Footer des Dashboards gerendert und, wenn jemand angemeldet ist, seine ⌘K-Befehlspalette. Die Palette und der Chat „KI fragen“ im Footer rufen navigate() auf Seiten von bugdump.com auf, was den SecurityError aus Falle 4 wirft. Die Portal-Gruppe steht jetzt auf der Liste, also bringt ein 404 auf einem Portal-Host das Dashboard nicht mehr mit.

Die Anmeldung blieb auf der Hauptdomain

Das Session-Cookie hatte schon Domain=bugdump.com. Es gilt also für jede Subdomain und geht mit deren Requests an api.bugdump.com. Es gibt keine Token-Übergabe: Wer sich auf bugdump.com anmeldet, ist auf jedem Portal angemeldet.

Die API brauchte zwei Änderungen. CORS ließ bisher nur https://bugdump.com zu, jetzt gibt es den Ursprung des Requests zurück, wenn er zu einem Portal gehört. Better Auth prüft den Ursprung jedes POST-Requests, der ein Cookie mitbringt, also wäre das Abmelden in einem Portal mit „Invalid origin“ gescheitert. Ein Muster wie https://*.bugdump.com in trustedOrigins würde jeder Subdomain vertrauen, auch cdn und staging, und der * von Better Auth matcht auch Punkte, also auch a.b.bugdump.com. trustedOrigins kann stattdessen eine Funktion des Requests sein. Sie fügt jetzt den Ursprung des Requests selbst hinzu, wenn eine Regel, isPortalOrigin, ihn akzeptiert: das richtige Protokoll, genau ein Label und ein Slug, den ein Projekt haben könnte. CORS nutzt dieselbe Regel.

Ein Slug ist jetzt ein DNS-Label

Ein Slug, der zur Subdomain wird, muss ein gültiges Hostname-Label sein: 3 bis 63 Kleinbuchstaben, Ziffern und Bindestriche, kein Bindestrich am Anfang oder Ende und keine Bindestriche an dritter und vierter Stelle, die IDNA für kodierte Labels wie xn-- reserviert (RFC 5890 und 5891). Die 3 habe ich festgelegt: DNS erlaubt ein Zeichen, und das Minimum hält Namen mit einem und zwei Buchstaben für BugDump frei.

Manche Namen müssen außerdem BugDump vorbehalten bleiben. Die Liste reservierter Namen hat über 200 Einträge, von www, api, cdn und staging bis status, docs, admin und reports, dazu alles, was mit bugdump- beginnt. Vor dem Release habe ich dieselben Regeln als SQL-Abfrage gegen die Produktion laufen lassen, um Projekte zu finden, deren bestehender Slug brechen würde. Sie lieferte keine einzige Zeile.

Cloudflare: Routen schlagen Custom Domains

Die Hauptseite und Staging sind Workers, die als Custom Domains angebunden sind. Damit Portale funktionieren, muss jeder Host unter *.bugdump.com den Produktions-Worker erreichen. Dafür braucht es zwei Dinge: einen Wildcard-DNS-Eintrag, * AAAA 100:: mit aktiviertem Proxy, und eine Workers-Route, *.bugdump.com/*. Explizite DNS-Einträge haben weiter Vorrang vor der Wildcard, und das Universal-Zertifikat deckt eine Ebene Subdomains bereits ab.

Entscheidend ist die Reihenfolge, in der Cloudflare sie anwendet. Route-Workers laufen vor Custom Domains und gewöhnlichen Origins hinter dem Proxy, die Wildcard-Route hätte also auch api.bugdump.com, das CDN und Staging abgefangen. Jeder davon bekam eine eigene Route mit deaktiviertem Worker, und das spezifischere Muster gewinnt. Diese Routen habe ich zuerst angelegt und geprüft, dass API, CDN und Staging noch antworten, bevor ich die Wildcard hinzugefügt habe.

Mehr zum Thema