Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/variadic-path-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/router-core': minor
'@tanstack/router-generator': patch
'@tanstack/eslint-plugin-router': patch
---

Add non-terminal variadic path parameters: `{...$param}` matches zero or more URL segments in the middle of a path and binds them as `Array<string>` (each segment decoded independently). Unlike the terminal `$` splat, routes can continue after a variadic segment, enabling paths where a variable-depth hierarchy sits between fixed parts of the URL (`/$bucket/{...$folders}/$file/versions/$versionId`). Matching is non-greedy (the variadic consumes as few segments as the rest of the pattern allows) and ranks below optional parameters and above the terminal wildcard. Consecutive variadic segments (and a variadic followed by a wildcard) must be separated by at least one static segment, since an unanchored boundary between two unbounded segments would leave the first structurally empty. Variadic segments cannot carry a prefix or suffix. A route path supports at most three variadic segments.
91 changes: 91 additions & 0 deletions docs/router/guide/path-params.md
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,97 @@ function PostsComponent() {

<!-- ::end:framework -->

## Variadic Path Parameters

A regular path param matches exactly one segment. A variadic path parameter, written `{...$paramName}`, matches **zero or more** segments and provides them back to you as an array. The path can keep going after it, so it also works between fixed parts of the URL:

- `files/{...$path}/preview`
- `$bucket/{...$folders}/$file`

Let's build a route for files that can be nested in arbitrarily deep folders:

<!-- ::start:framework -->

# React

```tsx title="src/routes/$bucket/{...$folders}/$file.tsx"
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/$bucket/{...$folders}/$file')({
loader: ({ params }) => {
// params.folders is an Array<string>
return fetchFile([params.bucket, ...params.folders, params.file])
},
component: FileComponent,
})

function FileComponent() {
const { bucket, folders, file } = Route.useParams()
return <div>{[bucket, ...folders, file].join(' / ')}</div>
}
```

# Solid

```tsx title="src/routes/$bucket/{...$folders}/$file.tsx"
import { createFileRoute } from '@tanstack/solid-router'

export const Route = createFileRoute('/$bucket/{...$folders}/$file')({
loader: ({ params }) => {
// params.folders is an Array<string>
return fetchFile([params.bucket, ...params.folders, params.file])
},
component: FileComponent,
})

function FileComponent() {
const params = Route.useParams()
return (
<div>
{[params().bucket, ...params().folders, params().file].join(' / ')}
</div>
)
}
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<!-- ::end:framework -->

This one route matches `/media/kyoto.jpg`, `/media/photos/kyoto.jpg` and `/media/photos/2026/kyoto.jpg`, binding `folders` to `[]`, `['photos']` and `['photos', '2026']` respectively. The array is never `undefined` since a variadic that matched nothing gives you an empty array. Matching is non-greedy, so the variadic consumes as few segments as the rest of the path allows, and static, required or optional segments in the same position always win over it. Each matched segment is percent-decoded on its own, so an encoded `/` inside a folder name stays inside that folder's value.

When navigating, pass an array for the variadic param. An empty (or omitted) array drops the segment entirely:

```tsx
function Component() {
return (
<div>
{/* navigates to /media/photos/2026/kyoto.jpg */}
<Link
to="/$bucket/{...$folders}/$file"
params={{
bucket: 'media',
folders: ['photos', '2026'],
file: 'kyoto.jpg',
}}
>
Kyoto Photo
</Link>

{/* navigates to /media/kyoto.jpg */}
<Link
to="/$bucket/{...$folders}/$file"
params={{ bucket: 'media', folders: [], file: 'kyoto.jpg' }}
>
Kyoto Photo (root)
</Link>
</div>
)
}
```

A route path can contain up to three variadic segments, as long as consecutive ones are separated by at least one static segment; they resolve left to right, each consuming as little as possible. Prefixes and suffixes are not supported on variadic segments, and a variadic must be separated from a splat by at least one static segment.

> 🧠 Use a variadic parameter when segments _between_ two known parts of the URL vary in depth. If you just want to capture "the rest of the URL" with no routes after it, a [splat route](../routing/routing-concepts.md#splat--catch-all-routes) (`$`) is simpler.

## Internationalization (i18n) with Optional Path Parameters

Optional path parameters are excellent for implementing internationalization (i18n) routing patterns. You can use prefix patterns to handle multiple languages while maintaining clean, SEO-friendly URLs.
Expand Down
2 changes: 2 additions & 0 deletions docs/router/routing/route-matching.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ When TanStack Router processes your route tree, all of your routes are automatic
- Index Route
- Static Routes (most specific to least specific)
- Dynamic Routes (longest to shortest)
- Optional Parameter Routes
- Variadic Parameter Routes
- Splat/Wildcard Routes

Consider the following pseudo route tree:
Expand Down
52 changes: 52 additions & 0 deletions docs/router/routing/routing-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,58 @@ This route matches `/posts`, `/posts/tech`, and `/posts/tech/hello-world`.

> 🧠 Routes with optional parameters are ranked lower in priority than exact matches, ensuring that more specific routes like `/posts/featured` are matched before `/posts/{-$category}`.

## Variadic Path Parameters

Variadic path parameters match **zero or more** URL segments in the middle of a path and bind them as an `Array<string>`. They use the `{...$paramName}` syntax.

<!-- ::start:framework -->

# React

```tsx title="src/routes/files.{...$path}.preview.tsx"
// `{...$path}` matches zero or more segments, so this route matches
// `/files/preview`, `/files/docs/preview` and `/files/a/b/c/preview`
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/files/{...$path}/preview')({
component: PreviewComponent,
})

function PreviewComponent() {
const { path } = Route.useParams()

// `path` is an Array<string>: [] for `/files/preview`,
// ['a', 'b', 'c'] for `/files/a/b/c/preview`
return <div>Previewing {path.join('/') || 'the root folder'}</div>
}
```

# Solid

```tsx title="src/routes/files.{...$path}.preview.tsx"
// `{...$path}` matches zero or more segments, so this route matches
// `/files/preview`, `/files/docs/preview` and `/files/a/b/c/preview`
import { createFileRoute } from '@tanstack/solid-router'

export const Route = createFileRoute('/files/{...$path}/preview')({
component: PreviewComponent,
})

function PreviewComponent() {
const { path } = Route.useParams()

// `path` is an Array<string>: [] for `/files/preview`,
// ['a', 'b', 'c'] for `/files/a/b/c/preview`
return <div>Previewing {path().join('/') || 'the root folder'}</div>
}
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<!-- ::end:framework -->

A variadic segment consumes as few segments as the rest of the pattern allows, and each matched segment is percent-decoded independently. A route path may contain up to three variadic segments as long as consecutive ones are separated by at least one static segment. Prefixes and suffixes are not supported on variadic segments. See [Path Params](../guide/path-params.md#variadic-path-parameters) for details.

> 🧠 Variadic parameter routes are ranked below dynamic and optional parameter routes and above splat routes: `/docs/$section/end` and `/docs/{-$section}/end` both win over `/docs/{...$rest}/end` for `/docs/guide/end`.

## Layout Routes

Layout routes are used to wrap child routes with additional components and logic. They are useful for:
Expand Down
17 changes: 9 additions & 8 deletions docs/start/framework/react/migrate-from-next-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,14 +338,15 @@ Now that you have migrated the basic structure of your Next.js application to Ta

### Routing Concepts

| Route Example | Next.js | TanStack Start |
| ------------------------------ | ---------------------------------- | ------------------------- |
| Root Layout | `src/app/layout.tsx` | `src/app/__root.tsx` |
| `/` (Home Page) | `src/app/page.tsx` | `src/app/index.tsx` |
| `/posts` (Static Route) | `src/app/posts/page.tsx` | `src/app/posts.tsx` |
| `/posts/[slug]` (Dynamic) | `src/app/posts/[slug]/page.tsx` | `src/app/posts/$slug.tsx` |
| `/posts/[...slug]` (Catch-All) | `src/app/posts/[...slug]/page.tsx` | `src/app/posts/$.tsx` |
| `/api/endpoint` (API Route) | `src/app/api/endpoint/route.ts` | `src/app/api/endpoint.ts` |
| Route Example | Next.js | TanStack Start |
| ----------------------------------------- | ------------------------------------ | ------------------------------ |
| Root Layout | `src/app/layout.tsx` | `src/app/__root.tsx` |
| `/` (Home Page) | `src/app/page.tsx` | `src/app/index.tsx` |
| `/posts` (Static Route) | `src/app/posts/page.tsx` | `src/app/posts.tsx` |
| `/posts/[slug]` (Dynamic) | `src/app/posts/[slug]/page.tsx` | `src/app/posts/$slug.tsx` |
| `/posts/[...slug]` (Catch-All) | `src/app/posts/[...slug]/page.tsx` | `src/app/posts/$.tsx` |
| `/posts/[[...slug]]` (Optional Catch-All) | `src/app/posts/[[...slug]]/page.tsx` | `src/app/posts.{...$slug}.tsx` |
| `/api/endpoint` (API Route) | `src/app/api/endpoint/route.ts` | `src/app/api/endpoint.ts` |

Learn more about the [Routing Concepts](/router/latest/docs/framework/react/routing/routing-concepts).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,28 @@ describe('extractParamsFromSegment', () => {
})
})

it('should extract variadic {...$param} format', () => {
const result = extractParamsFromSegment('{...$items}')
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
fullParam: '...$items',
paramName: 'items',
isOptional: false,
isValid: true,
})
})

it('should mark invalid variadic param names', () => {
const result = extractParamsFromSegment('{...$123invalid}')
expect(result).toHaveLength(1)
expect(result[0]?.isValid).toBe(false)
expect(result[0]?.paramName).toBe('123invalid')
})

it('should skip a nameless variadic {...$}', () => {
expect(extractParamsFromSegment('{...$}')).toEqual([])
})

it('should mark invalid param names', () => {
const result = extractParamsFromSegment('$123invalid')
expect(result).toHaveLength(1)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { VALID_PARAM_NAME_REGEX } from './constants'

export interface ExtractedParam {
/** The full param string including $ prefix (e.g., "$userId", "-$optional") */
/** The full param string including $ prefix (e.g., "$userId", "-$optional", "...$items") */
fullParam: string
/** The param name without $ prefix (e.g., "userId", "optional") */
paramName: string
Expand Down Expand Up @@ -51,17 +51,17 @@ export function extractParamsFromSegment(
return params
}

// Pattern 2: Braces pattern {$paramName} or {-$paramName} with optional prefix/suffix
// Match patterns like: prefix{$param}suffix, {$param}, {-$param}
const bracePattern = /\{(-?\$)([^}]*)\}/g
// Pattern 2: Braces pattern {$paramName}, {-$paramName} or {...$paramName}
// Match patterns like: prefix{$param}suffix, {$param}, {-$param}, {...$param}
const bracePattern = /\{((?:-|\.\.\.)?\$)([^}]*)\}/g
let match

while ((match = bracePattern.exec(segment)) !== null) {
const prefix = match[1] // "$" or "-$"
const paramName = match[2] // The param name after $ or -$
const prefix = match[1] // "$", "-$", or "...$"
const paramName = match[2] // The param name after $, -$, or ...$

if (!paramName) {
// This is a wildcard {$} or {-$}, skip
// This is a wildcard {$}, {-$}, or {...$}, skip
continue
}

Expand Down
45 changes: 33 additions & 12 deletions packages/router-core/src/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,36 +34,53 @@ export interface ParsePathParamsResult<
in out TRequired,
in out TOptional,
in out TRest,
in out TVariadic = never,
> {
required: TRequired
optional: TOptional
rest: TRest
variadic: TVariadic
}

export type AnyParsePathParamsResult = ParsePathParamsResult<
string,
string,
string,
string
>

export type ParsePathParamsBoundaryStart<T extends string> =
T extends `${infer TLeft}{-${infer TRight}`
T extends `${infer TLeft}{...${infer TRight}`
? ParsePathParamsResult<
ParsePathParams<TLeft>['required'],
| ParsePathParams<TLeft>['optional']
| ParsePathParams<TRight>['required']
| ParsePathParams<TRight>['optional'],
ParsePathParams<TRight>['rest']
ParsePathParams<TRight>['rest'],
| ParsePathParams<TLeft>['variadic']
| ParsePathParams<TRight>['required']
| ParsePathParams<TRight>['variadic']
>
: T extends `${infer TLeft}{${infer TRight}`
: T extends `${infer TLeft}{-${infer TRight}`
? ParsePathParamsResult<
| ParsePathParams<TLeft>['required']
| ParsePathParams<TRight>['required'],
ParsePathParams<TLeft>['required'],
| ParsePathParams<TLeft>['optional']
| ParsePathParams<TRight>['required']
| ParsePathParams<TRight>['optional'],
ParsePathParams<TRight>['rest']
ParsePathParams<TRight>['rest'],
| ParsePathParams<TLeft>['variadic']
| ParsePathParams<TRight>['variadic']
>
: never
: T extends `${infer TLeft}{${infer TRight}`
? ParsePathParamsResult<
| ParsePathParams<TLeft>['required']
| ParsePathParams<TRight>['required'],
| ParsePathParams<TLeft>['optional']
| ParsePathParams<TRight>['optional'],
ParsePathParams<TRight>['rest'],
| ParsePathParams<TLeft>['variadic']
| ParsePathParams<TRight>['variadic']
>
: never

export type ParsePathParamsSymbol<T extends string> =
T extends `${string}$${infer TRight}`
Expand All @@ -73,12 +90,14 @@ export type ParsePathParamsSymbol<T extends string> =
? ParsePathParamsResult<
ParsePathParams<TRest>['required'],
'_splat' | ParsePathParams<TRest>['optional'],
ParsePathParams<TRest>['rest']
ParsePathParams<TRest>['rest'],
ParsePathParams<TRest>['variadic']
>
: ParsePathParamsResult<
TParam | ParsePathParams<TRest>['required'],
ParsePathParams<TRest>['optional'],
ParsePathParams<TRest>['rest']
ParsePathParams<TRest>['rest'],
ParsePathParams<TRest>['variadic']
>
: never
: TRight extends ''
Expand All @@ -93,7 +112,8 @@ export type ParsePathParamsBoundaryEnd<T extends string> =
| ParsePathParams<TRight>['required'],
| ParsePathParams<TLeft>['optional']
| ParsePathParams<TRight>['optional'],
ParsePathParams<TRight>['rest']
ParsePathParams<TRight>['rest'],
ParsePathParams<TLeft>['variadic'] | ParsePathParams<TRight>['variadic']
>
: never

Expand All @@ -104,7 +124,8 @@ export type ParsePathParamsEscapeStart<T extends string> =
| ParsePathParams<TRight>['required'],
| ParsePathParams<TLeft>['optional']
| ParsePathParams<TRight>['optional'],
ParsePathParams<TRight>['rest']
ParsePathParams<TRight>['rest'],
ParsePathParams<TLeft>['variadic'] | ParsePathParams<TRight>['variadic']
>
: never

Expand Down
Loading