Repository: TanStack/router
Pull request #2677 (2024-10-30 20:32:51)

feat(react-router): allow unencoded characters in dynamic path segments
Currently any path params with a "@" character are URI encoded. Modern browsers handle this fine and it is a common pattern (e.g. youtube.com/@exampleChannel) to have them unencoded in a path.

nx-cloud[bot]: # ☁️ Nx Cloud Report

CI is running for commit 75f7c268aa43d7137328f8cbbf5e79994059264b.

[📂 Click to track the progress, see the status, the terminal output, and the build insights.](https://cloud.nx.app/cipes/6723172d00cb764d73bcf7f0?utm_source=pull-request&utm_medium=comment)


---

Sent with 💌 from [NxCloud.](https://nx.app?utm_source=pull-request&utm_medium=comment)
pkg-pr-new[bot]: 
[Open in Stackblitz](https://pkg.pr.new/template/f778da83-dc91-4fdf-89bc-dd56bd12e4b9)
<details><summary><b>More templates</b></summary><p>

- [tanstack-router-react-example-basic](https://pkg.pr.new/template/3c3bb94d-1858-4722-9811-9d9b570ba423)
- [tanstack-router-react-example-authenticated-routes](https://pkg.pr.new/template/bcbee975-efa8-458f-9118-f69d1185b1d0)
- [tanstack-router-react-example-basic-default-search-params](https://pkg.pr.new/template/10ed540e-8a5c-49f8-8ef8-9ef59a3e1216)
- [tanstack-router-react-example-basic-file-based](https://pkg.pr.new/template/e36373c6-436b-412c-bc40-0948560dff78)
- [tanstack-router-react-example-basic-file-based-codesplitting](https://pkg.pr.new/template/e97ddf85-0428-4779-acae-9ec42a501a16)
- [tanstack-router-react-example-react-query](https://pkg.pr.new/template/0a1c0593-03b0-4f3d-ac75-7cd8f912039a)
- [tanstack-router-react-example-basic-react-query-file-based](https://pkg.pr.new/template/60b225c1-bdf2-4482-817f-1f3838685fe1)
- [tanstack-router-react-example-basic-ssr-file-based](https://pkg.pr.new/template/c7fe12ea-44a5-469f-9ab3-90e743908bfe)
- [tanstack-router-react-example-basic-ssr-streaming-file-based](https://pkg.pr.new/template/6edebaea-2c6e-4248-868d-4a9efbc7c964)
- [tanstack-router-react-example-basic-virtual-file-based](https://pkg.pr.new/template/3aadf9c5-20d3-41c5-9a54-ed7f10afad55)
- [tanstack-router-react-example-basic-virtual-inside-file-based](https://pkg.pr.new/template/70b2365e-8f6a-4047-9954-370b029a179d)
- [tanstack-router-react-example-deferred-data](https://pkg.pr.new/template/43a5ca89-854f-4f6b-b44d-a6d6cc7914b3)
- [tanstack-router-react-example-kitchen-sink](https://pkg.pr.new/template/9e288475-38d8-41a8-97f8-bfb23cd3ed82)
- [tanstack-router-react-example-kitchen-sink-file-based](https://pkg.pr.new/template/4b1dbe2a-9667-4d60-ad9f-1cec812f1ff8)
- [tanstack-router-react-example-kitchen-sink-react-query](https://pkg.pr.new/template/599a6de3-325b-44ad-8af0-2ab25d3720f3)
- [tanstack-router-react-example-kitchen-sink-react-query-file-based](https://pkg.pr.new/template/02188183-538f-4939-ae87-40150f4c6b37)
- [tanstack-router-react-example-large-file-based](https://pkg.pr.new/template/89dc3d3c-3986-4a0f-940c-c7d824ee4f63)
- [tanstack-router-react-example-location-masking](https://pkg.pr.new/template/7d520582-a3cb-46e0-824b-c97b0dd4488b)
- [tanstack-router-react-example-navigation-blocking](https://pkg.pr.new/template/778b9a37-6524-4a70-9175-4dd17c92fbce)
- [tanstack-router-react-example-quickstart](https://pkg.pr.new/template/d73db23d-fe21-4e01-beb9-5cbb2b307d6f)
- [tanstack-router-react-example-quickstart-esbuild-file-based](https://pkg.pr.new/template/3f84c8f9-41cf-485e-8f3d-11115798a0b0)
- [tanstack-router-react-example-quickstart-file-based](https://pkg.pr.new/template/3ac40d96-d919-450a-b6ac-afd2f6b45e61)
- [tanstack-router-react-example-quickstart-rspack-file-based](https://pkg.pr.new/template/1de18dcd-f6fd-4fe3-a33a-a14c18b3409b)
- [tanstack-router-react-example-quickstart-webpack-file-based](https://pkg.pr.new/template/cc10a1f7-d398-4550-8a01-5b1f80dd9eb5)
- [router-monorepo-react-query](https://pkg.pr.new/template/cea5da96-3635-4ec7-93ff-fca4033d2713)
- [router-mono-simple](https://pkg.pr.new/template/a19f01fc-efc3-4b4b-be9c-273ee0b79d17)
- [tanstack-router-react-example-scroll-restoration](https://pkg.pr.new/template/e2f7d0c3-fecc-4d19-a5e0-7c3631caedc4)
- [tanstack-router-search-validator-adapters](https://pkg.pr.new/template/2d075b85-1737-4b82-8f59-d27ecde1dc53)
- [tanstack-start-example-basic](https://pkg.pr.new/template/79b679f8-e02a-418d-8096-2143bc9411d9)
- [tanstack-start-example-basic-auth](https://pkg.pr.new/template/2b294491-e29c-428f-b3b4-c9c7984f6afc)
- [tanstack-start-example-basic-react-query](https://pkg.pr.new/template/87623631-1d2d-47fa-97e4-08cec2097f3e)
- [tanstack-start-example-basic-rsc](https://pkg.pr.new/template/be1d082e-f149-4e00-a33c-90bff49a3294)
- [tanstack-start-example-clerk-basic](https://pkg.pr.new/template/b8e3c33e-6d16-4a2a-a5c9-47afe2c85ddc)
- [tanstack-start-example-convex-trellaux](https://pkg.pr.new/template/5e4601b6-0986-4c92-9912-08c1afd537bc)
- [tanstack-start-example-counter](https://pkg.pr.new/template/19e236ca-8883-483f-a9d6-27ed2ab1ae8e)
- [tanstack-start-example-supabase-basic](https://pkg.pr.new/template/32dbfba5-b968-4b26-b6e8-527558f4f06f)
- [tanstack-start-example-trellaux](https://pkg.pr.new/template/eb247d02-cd38-48f0-84f9-acc3a65d20d1)
- [tanstack-router-react-example-with-framer-motion](https://pkg.pr.new/template/84715ed0-c945-46e3-858a-28a044f0d69b)
- [tanstack-router-react-example-with-trpc](https://pkg.pr.new/template/cf8bf025-ef7c-40cb-a1d7-20abb4c2881d)
- [tanstack-router-react-example-with-trpc-react-query](https://pkg.pr.new/template/1225edb6-0a9c-41d0-b71d-1692a4ee87ed)

</p></details>
      
    


<details><summary><b>@tanstack/create-router</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/create-router@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/eslint-plugin-router</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/eslint-plugin-router@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/react-cross-context</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/react-cross-context@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/history</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/history@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/react-router</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/react-router@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/react-router-with-query</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/react-router-with-query@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/router-arktype-adapter</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/router-arktype-adapter@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/router-cli</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/router-cli@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/router-devtools</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/router-devtools@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/router-generator</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/router-generator@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/router-plugin</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/router-plugin@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/router-valibot-adapter</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/router-valibot-adapter@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/router-vite-plugin</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/router-vite-plugin@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/router-zod-adapter</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/router-zod-adapter@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/start</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/start@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/start-vite-plugin</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/start-vite-plugin@2677
```

</p></details>
      
    

<details><summary><b>@tanstack/virtual-file-routes</b></summary><p>

```
pnpm add https://pkg.pr.new/@tanstack/virtual-file-routes@2677
```

</p></details>
      
    

_commit: <a href="https://github.com/TanStack/router/runs/32319134077"><code>75f7c26</code></a>_

schiller-manuel: should we maybe expose this as a config option to the user? some might want different characters, some might want no characters etc?
SeanCassiere: > should we maybe expose this as a config option to the user? some might want different characters, some might want no characters etc?

That's a good idea!

@alma-lp would this be something you'd be able to implement?
alma-lp: > > should we maybe expose this as a config option to the user? some might want different characters, some might want no characters etc?
> 
> That's a good idea!
> 
> @alma-lp would this be something you'd be able to implement?

I should. I'll take a look.
schiller-manuel: please add a test that configures router with some allowed chars, navigate to a route by setting a path param to contain one of the allowed chars and then check if the url is not encoded
CanRau: Interesting. this is a completely different approach, but seemingly much simpler, than adding prefix/suffix support, tho if I understand it correctly would actually be sufficient for my usecase as we use /@username routes as well.

Just for reference, I added some thoughts here https://github.com/TanStack/router/issues/707#issuecomment-2433814404
schiller-manuel: @CanRau  this does not allow prefixes. this just prevents path params containing e.g. "@" to be urlencoded. the path param will still be the full string "@foo"
CanRau: Yea just realized that it wouldn't help with route matching 😔 which we need as we have those /@username paths at the root next to other dynamic and non-dynamic routes 😬 
SeanCassiere: > Yea just realized that it wouldn't help with route matching 😔 which we need as we have those /@username paths at the root next to other dynamic and non-dynamic routes 😬

To confirm, are we merging this 'as-is' and working on the other part as a separate issue? or is there more work happening on this PR?

Patch:
diff --git a/docs/framework/react/api/router/RouterOptionsType.md b/docs/framework/react/api/router/RouterOptionsType.md
index be87b6b7e..d2fee5f02 100644
--- a/docs/framework/react/api/router/RouterOptionsType.md
+++ b/docs/framework/react/api/router/RouterOptionsType.md
@@ -286,3 +286,9 @@ const router = createRouter({
 - Optional
 - Defaults to `never`
 - Configures how trailing slashes are treated. `'always'` will add a trailing slash if not present, `'never'` will remove the trailing slash if present and `'preserve'` will not modify the trailing slash.
+
+### `pathParamsAllowedCharacters` property
+
+- Type: `Array<';' | ':' | '@' | '&' | '=' | '+' | '$' | ','>`
+- Optional
+- Configures which URI characters are allowed in path params that would ordinarily be escaped by encodeURIComponent.
diff --git a/docs/framework/react/guide/path-params.md b/docs/framework/react/guide/path-params.md
index 71655ffa1..ba2d77df0 100644
--- a/docs/framework/react/guide/path-params.md
+++ b/docs/framework/react/guide/path-params.md
@@ -110,3 +110,19 @@ function Component() {
 ```
 
 Notice that the function style is useful when you need to persist params that are already in the URL for other routes. This is because the function style will receive the current params as an argument, allowing you to modify them as needed and return the final params object.
+
+## Allowed Characters
+
+By default, path params are escaped with `encodeURIComponent`. If you want to allow other valid URI characters (e.g. `@` or `+`), you can specify that in your [RouterOptions](../api/router/RouterOptionsType.md#pathparamsallowedcharacters-property)
+
+Example usage:
+
+```tsx
+const router = createRouter({
+  ...
+  pathParamsAllowedCharacters: ['@']
+})
+```
+
+The following is the list of accepted allowed characters:
+`;` `:` `@` `&` `=` `+` `$` `,`
diff --git a/packages/react-router/src/path.ts b/packages/react-router/src/path.ts
index 53c3ffe00..cf4b99b9c 100644
--- a/packages/react-router/src/path.ts
+++ b/packages/react-router/src/path.ts
@@ -204,6 +204,8 @@ interface InterpolatePathOptions {
   params: Record<string, unknown>
   leaveWildcards?: boolean
   leaveParams?: boolean
+  // Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params
+  decodeCharMap?: Map<string, string>
 }
 
 export function interpolatePath({
@@ -211,6 +213,7 @@ export function interpolatePath({
   params,
   leaveWildcards,
   leaveParams,
+  decodeCharMap,
 }: InterpolatePathOptions) {
   const interpolatedPathSegments = parsePathname(path)
   const encodedParams: any = {}
@@ -222,7 +225,9 @@ export function interpolatePath({
       // the splat/catch-all routes shouldn't have the '/' encoded out
       encodedParams[key] = isValueString ? encodeURI(value) : value
     } else {
-      encodedParams[key] = isValueString ? encodeURIComponent(value) : value
+      encodedParams[key] = isValueString
+        ? encodePathParam(value, decodeCharMap)
+        : value
     }
   }
 
@@ -247,6 +252,16 @@ export function interpolatePath({
   )
 }
 
+function encodePathParam(value: string, decodeCharMap?: Map<string, string>) {
+  let encoded = encodeURIComponent(value)
+  if (decodeCharMap) {
+    for (const [encodedChar, char] of decodeCharMap) {
+      encoded = encoded.replaceAll(encodedChar, char)
+    }
+  }
+  return encoded
+}
+
 export function matchPathname(
   basepath: string,
   currentPathname: string,
diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts
index 6b2e693e3..24e47c122 100644
--- a/packages/react-router/src/router.ts
+++ b/packages/react-router/src/router.ts
@@ -464,6 +464,15 @@ export interface RouterOptions<
      */
     strict?: boolean
   }
+  /**
+   * Configures which URI characters are allowed in path params that would ordinarily be escaped by encodeURIComponent.
+   *
+   * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#pathparamsallowedcharacters-property)
+   * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/path-params#allowed-characters)
+   */
+  pathParamsAllowedCharacters?: Array<
+    ';' | ':' | '@' | '&' | '=' | '+' | '$' | ','
+  >
 }
 
 export interface RouterErrorSerializer<TSerializedError> {
@@ -711,6 +720,7 @@ export class Router<
   routesByPath!: RoutesByPath<TRouteTree>
   flatRoutes!: Array<AnyRoute>
   isServer!: boolean
+  pathParamsDecodeCharMap?: Map<string, string>
 
   /**
    * @deprecated Use the `createRouter` function instead
@@ -768,6 +778,15 @@ export class Router<
 
     this.isServer = this.options.isServer ?? typeof document === 'undefined'
 
+    this.pathParamsDecodeCharMap = this.options.pathParamsAllowedCharacters
+      ? new Map(
+          this.options.pathParamsAllowedCharacters.map((char) => [
+            encodeURIComponent(char),
+            char,
+          ]),
+        )
+      : undefined
+
     if (
       !this.basepath ||
       (newOptions.basepath && newOptions.basepath !== previousOptions.basepath)
@@ -1187,6 +1206,7 @@ export class Router<
       const interpolatedPath = interpolatePath({
         path: route.fullPath,
         params: routeParams,
+        decodeCharMap: this.pathParamsDecodeCharMap,
       })
 
       const matchId =
@@ -1194,6 +1214,7 @@ export class Router<
           path: route.id,
           params: routeParams,
           leaveWildcards: true,
+          decodeCharMap: this.pathParamsDecodeCharMap,
         }) + loaderDepsHash
 
       // Waste not, want not. If we already have a match for this route,
@@ -1431,6 +1452,7 @@ export class Router<
               const interpolatedPath = interpolatePath({
                 path: route.fullPath,
                 params: matchedRoutesResult?.routeParams ?? {},
+                decodeCharMap: this.pathParamsDecodeCharMap,
               })
               const pathname = joinPaths([this.basepath, interpolatedPath])
               return pathname === fromPath
@@ -1467,6 +1489,7 @@ export class Router<
         params: nextParams ?? {},
         leaveWildcards: false,
         leaveParams: opts.leaveParams,
+        decodeCharMap: this.pathParamsDecodeCharMap,
       })
 
       let search = fromSearch
diff --git a/packages/router-generator/src/config.ts b/packages/router-generator/src/config.ts
index 629688bbd..ec7ddeb9b 100644
--- a/packages/router-generator/src/config.ts
+++ b/packages/router-generator/src/config.ts
@@ -45,6 +45,9 @@ export const configSchema = z.object({
   autoCodeSplitting: z.boolean().optional(),
   indexToken: z.string().optional().default('index'),
   routeToken: z.string().optional().default('route'),
+  pathParamsAllowedCharacters: z
+    .array(z.enum([';', ':', '@', '&', '=', '+', '$', ',']))
+    .optional(),
   customScaffolding: z
     .object({
       routeTemplate: z


Test patch:
diff --git a/packages/react-router/tests/path.test.ts b/packages/react-router/tests/path.test.ts
index 5e3f559b4..78fa6d347 100644
--- a/packages/react-router/tests/path.test.ts
+++ b/packages/react-router/tests/path.test.ts
@@ -309,9 +309,28 @@ describe('interpolatePath', () => {
       params: { id: 0 },
       result: '/users/0',
     },
+    {
+      name: 'should interpolate the path with URI component encoding',
+      path: '/users/$id',
+      params: { id: '?#@john+smith' },
+      result: '/users/%3F%23%40john%2Bsmith',
+    },
+    {
+      name: 'should interpolate the path without URI encoding characters in decodeCharMap',
+      path: '/users/$id',
+      params: { id: '?#@john+smith' },
+      result: '/users/%3F%23@john+smith',
+      decodeCharMap: new Map(
+        ['@', '+'].map((char) => [encodeURIComponent(char), char]),
+      ),
+    },
   ].forEach((exp) => {
     it(exp.name, () => {
-      const result = interpolatePath({ path: exp.path, params: exp.params })
+      const result = interpolatePath({
+        path: exp.path,
+        params: exp.params,
+        decodeCharMap: exp.decodeCharMap,
+      })
       expect(result).toBe(exp.result)
     })
   })
diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx
index 68a27dbcf..affb22b90 100644
--- a/packages/react-router/tests/router.test.tsx
+++ b/packages/react-router/tests/router.test.tsx
@@ -306,6 +306,37 @@ describe('encoding: URL param segment for /posts/$slug', () => {
       'framework/react/guide/file-based-routing tanstack',
     )
   })
+
+  it('params.slug should be encoded in the final URL', async () => {
+    const { router } = createTestRouter({
+      history: createMemoryHistory({ initialEntries: ['/'] }),
+    })
+
+    await router.load()
+    render(<RouterProvider router={router} />)
+
+    await act(() =>
+      router.navigate({ to: '/posts/$slug', params: { slug: '@jane' } }),
+    )
+
+    expect(router.state.location.pathname).toBe('/posts/%40jane')
+  })
+
+  it('params.slug should be encoded in the final URL except characters in pathParamsAllowedCharacters', async () => {
+    const { router } = createTestRouter({
+      history: createMemoryHistory({ initialEntries: ['/'] }),
+      pathParamsAllowedCharacters: ['@'],
+    })
+
+    await router.load()
+    render(<RouterProvider router={router} />)
+
+    await act(() =>
+      router.navigate({ to: '/posts/$slug', params: { slug: '@jane' } }),
+    )
+
+    expect(router.state.location.pathname).toBe('/posts/@jane')
+  })
 })
 
 describe('encoding: URL splat segment for /$', () => {