
# 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.