Skip to main content

TenantRedirector

src/lib/clerk/TenantRedirector.jsx — client component rendered in layout.jsx on every page.

Consolidates four previously separate components into one: TenantSync, DemoOrgRedirect, NewTenantModal, and SlugChangeWarning.

Key concept: subdomain

getSubdomainClient() reads the hostname in the browser and returns the subdomain part, or null if there is none.

URLsubdomainWhat this page is
rat.gdnullRoot landing page — no org in the URL, user has not signed up yet or is being redirected
demo.rat.gd"demo"Shared demo landing page — same org as root, just with an explicit subdomain
acme.rat.gd"acme"A real tenant's subdomain — acme is the org's shortName in MongoDB

isDemoPage(subdomain) returns true for both null and "demo" because rat.gd and demo.rat.gd serve the exact same shared demo org. The server (getHostnameServer) maps both to shortName = "demo".

Reserved domains

Because an org's shortName becomes its subdomain (acmeacme.rat.gd), some names must never be usable as slugs. RESERVED_DOMAINS in src/constants/domains.ts is the blocklist, enforced client-side in CreateOrganizationForm via a Zod .refine() on shortName (rejects with the slugReserved message).

The list covers:

CategoryExamplesWhy reserved
Infrastructure & environmentdemo, dev, staging, docs, previewUsed (or plausibly used) as real subdomains of the platform — demo in particular is the shared demo org
Network & DNSwww, api, mail, cdn, ns1Conventional DNS/service hostnames
Auth & accountslogin, auth, sso, accountsPhishing-prone; may be needed for auth infrastructure
Admin & systemadmin, dashboard, portalImpersonation risk
Business & supportbilling, support, status, legalOfficial-looking pages
Clerk-relatedclerk, clkmail, clk._domainkeyClerk's DNS records live on these subdomains of the production domain
ccTLDs (2-letter)de, fr, uk, …Full IANA list; avoids confusing de.rat.gd-style URLs looking like locale/country mirrors
3-letter TLDscom, net, app, dev, …IANA list; same confusion/squatting concern
Generic / brand-conflictdefault, null, undefined, exampleSentinel values and placeholders that would break or mislead

Notes:

  • The check is exact-match against the lowercase slug (the slug regex already forces lowercase), so Demo can't slip through via casing.
  • The list is a readonly const array with a derived ReservedDomain type; add new entries in domains.ts and they're enforced automatically wherever the constant is used.
  • Enforcement is currently client-side only (form validation). The server does not re-check on org creation, so treat the list as UX guardrail plus first line of defense, not a security boundary.

Which effects run where

Effectrat.gd (null)demo.rat.gd ("demo")acme.rat.gd ("acme")
TenantSync — sync Clerk active org to subdomain✗ no org to sync to✗ demo is a shared org, not a personal tenant✓ sets Clerk active org to acme
DemoOrgRedirect — poll memberships and redirect to personal demo-NNNN✓ main use case: user just signed up✓ same — dev has no webhooks so polling is the only mechanism✗ user is already on their tenant
NewTenantModal — poll user metadata and offer to visit new tenant✓ new users land here via OrgNotAvailable after sign-up
SlugChangeWarning — warn that org slug cannot be changed

Decision tree

Effect 1 — TenantSync

Runs on: real tenant subdomains only (subdomain is set and not "demo")

Sets the Clerk active organization to match the subdomain. Without this, Clerk would retain the previously active org across navigation.

subdomain set AND subdomain !== "demo" AND orgsLoaded
→ find membership whose slug === subdomain
→ if found and not already active: setActive({ organization: id })

Effect 2 — DemoOrgRedirect

Runs on: isDemoPage — both root domain (rat.gd) and demo subdomain (demo.rat.gd)

In production, a Clerk webhook creates a personal demo-NNNN org for the user and fires a redirect. In dev there are no webhooks, so this effect polls Clerk memberships directly as the only redirect mechanism.

isDemoPage AND orgsLoaded
→ demoAttemptsRef reset to 0 ← Bug A fix (was accumulating across re-runs)
→ if memberships already contain demo-NNNN slug:
redirect immediately to buildTenantUrl("demo-NNNN")
→ else:
start interval (2s, max 15 attempts)
on each tick: check memberships for demo-NNNN, redirect if found
NO revalidate() call ← Bug B fix (caused infinite effect restart loop)

Effect 3 — NewTenantModal

Runs on: all pages (no subdomain guard) — must run on demo.rat.gd too because OrgNotAvailable sends new users there after sign-up

Polls user.publicMetadata.newTenantSlug, set by the webhook after a new tenant org is created. Only polls for users created within the last 5 minutes.

userLoaded
→ if publicMetadata.newTenantSlug already set:
show modal immediately
→ else if user is recent (< 5 min old):
start interval (2s, max 15 attempts)
on each tick: user.reload(), check newTenantSlug, show modal if found

Render priority

Only one piece of UI is rendered at a time, in this order:

  1. NewTenantModal — shown on any page when newTenantSlug is set

    • "Visit" → clears metadata flag + redirects to demo-NNNN subdomain
    • "Dismiss" → clears metadata flag + closes modal
  2. SlugChangeWarning — shown on real tenant subdomains only (subdomain && subdomain !== "demo")

    • Displayed when activeOrg.publicMetadata.slugChangeWarning is set
    • "OK" → clears server-side flag + reloads org from Clerk
  3. null — nothing to display (root domain, demo subdomain with no pending actions)


Bug fixes included

BugSymptomFix
Bug A — premature bail-outattemptsRef carried over across effect re-runs triggered by userMemberships changes, hitting MAX_ATTEMPTS before a demo org was ever foundReset demoAttemptsRef.current = 0 at top of effect
Bug B — infinite restart looprevalidate() inside the interval triggered a userMemberships update → effect re-ran → interval restarted → loopRemoved revalidate() entirely; Clerk re-renders naturally when new data arrives