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 percent-encodes @ to %40 when passed via the params option
# 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.