TanStack Router encodes @ as %40 — and how to work around it

A subtle but breaking bug: passing @-prefixed space slugs through TanStack Router's params causes %40 encoding, breaking route matching entirely. The workaround is simpler than you'd expect.


# TanStack Router encodes @ as %40 — and how to work around it This one took an embarrassingly long time to debug. Bindly uses `@space` slugs for team spaces: `bind.ly/@bindly`, `bind.ly/@your-team`. Everything worked in the Gateway Worker (Hono handles `/@:space` routes fine). Everything worked in the browser URL bar. But navigating to a space from within the SPA would consistently land on a blank page. The cause: TanStack Router was generating `/%40bindly` instead of `/@bindly`. ## What's happening TanStack Router's `navigate` and `<Link>` components accept a `params` object for dynamic route segments. When you have a route defined as `/$spaceSlug`, the natural thing is: ```typescript // What seems natural navigate({ to: '/$spaceSlug/bindings', params: { spaceSlug: '@bindly' } }) ``` The router percent-encodes `@` to `%40` when building the URL from params. This is technically correct per RFC 3986 — `@` has special meaning in URLs (userinfo delimiter) and routers encode it to be safe. The generated URL becomes `/%40bindly/bindings`. The problem: `/%40bindly/bindings` doesn't match the `/@:space/bindings` route. The route never fires. The user lands on a 404 or a blank catch-all. ## Where it breaks ```typescript // ✗ Generates /%40bindly/bindings navigate({ to: '/$spaceSlug/bindings', params: { spaceSlug: rawSlug } }) ``` ```tsx // ✗ Generates href="/%40bindly/bindings/new" <Link to="/$spaceSlug/bindings/new" params={{ spaceSlug: rawSlug }}> New Binding </Link> ``` ```tsx // ✗ Generates /%40bindly when navigating back <PanelHeader backTo="/$spaceSlug" backParams={{ spaceSlug: rawSlug }} /> ``` The bug is completely silent — no console error, no TypeScript error, just a navigation that doesn't go where you expect. ## The fix Bypass `params` entirely. Construct the path string directly: ```typescript // ✓ Correct — literal path, no encoding navigate({ to: `/${rawSlug}/bindings` }) ``` ```tsx // ✓ Correct <Link to={`/${rawSlug}/bindings/new`}> New Binding </Link> ``` ```tsx // ✓ Correct <PanelHeader backTo={`/${rawSlug}`} /> ``` When you pass a literal string to `to`, TanStack Router uses it as-is. The `@` in `/@bindly` is preserved. Route matching works. ## Where params are still safe `params` is fine for route segments that don't contain special characters. Binding slugs, set slugs, numeric IDs — none of these contain `@`, so `params` works correctly: ```typescript // ✓ Safe — binding slug has no special chars navigate({ to: '/$spaceSlug/$bindingSlug', params: { spaceSlug: 'bindly', bindingSlug: 'cloudflare-stack' } }) ``` Our rule for Bindly: any path segment that might start with `@` is always constructed as a literal string. Other segments can use `params` safely. ## Why rawSlug already has the @ In Bindly, the space slug from the API always includes the `@` prefix for team/official spaces: `"@bindly"`, `"@your-team"`. Personal space uses `"personal"` (no `@`). The `spaceUrl()` helper constructs paths correctly: ```typescript function spaceUrl(space: Space): string { if (space.type === 'personal') return '/personal' return `/${space.slug}` // slug is already "@bindly" } ``` The URL becomes `/@bindly` because `space.slug` is `"@bindly"`. No special handling, no stripping and re-adding `@`. The literal string template just works. ## The broader lesson URL encoding gotchas in routers are common. Any character with special URL meaning (`@`, `+`, `#`, `?`, `&`) can cause silent navigation failures if passed through a router's params system without awareness. The safest pattern for non-alphanumeric route segments: construct the path string directly. The router is good at matching patterns — let it do that. Constructing paths with template literals is explicit, readable, and immune to encoding surprises. We've documented this pattern in `CLAUDE.md` so it doesn't get rediscovered the hard way again.