Dynamic Routes and Splats

Lesson 3 — $param segments and $ catch-alls

Your mobile app needs detail screens — /posts/42, /users/ana — and maybe a file browser that swallows any depth like /files/docs/readme. Static routes can't do that. This lesson closes the routing fundamentals by teaching the two dynamic tools: the dynamic segment $param and the splat $.

Dynamic Segments: The $ Prefix

A folder or file whose name starts with $ captures that URL segment into a param. The text after $ is the param's name:

routes/ ├── __root.tsx ├── posts/ │ ├── route.tsx → /posts (list + layout) │ └── $postId/ │ └── route.tsx → /posts/42 └── users/ └── $username/ └── route.tsx → /users/ana

The generated route path keeps the $: /posts/$postId. When the URL /posts/42 is visited, the router matches it and fills in postId: "42".

⚡ Key insight The $ is the whole convention. $postId means "capture this segment into a param named postId". The name is up to you — $id, $slug, $username all work.

Multiple dynamic segments

Dynamic segments work at every level of the path. Stack them for nested resources:

routes/ └── posts/ └── $postId/ └── $revisionId/ └── route.tsx → /posts/42/7 (two params)

URL /posts/42/7 produces params { postId: "42", revisionId: "7" }.

Reading Params in Code

Params are available in two places — the loader (for data fetching) and the component (for rendering):

// routes/posts/$postId/route.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({
  // 1. In a loader — params come as an argument
  loader: ({ params }) => fetchPost(params.postId),
  // 2. In a component — use the Route hook
  component: PostComponent,
})

function PostComponent() {
  const { postId } = Route.useParams()
  return <article>Post #{postId}</article>
}
Type safety win Because the file is named $postId, TanStack's generated route tree knows postId is a param. params.postId is typed as string — and params.foobar is a compile error. This is the payoff over Next.js, where params typing is manual.

Splat Routes: The Catch-All $

A folder/file named exactly $ is a splat — it captures any remaining URL segments, from that point to the end. The captured tail lands in the special _splat property:

routes/ └── files/ └── $/ └── route.tsx → /files/$ (catch-all)

Visiting /files/docs/readme matches this route. The captured path is stored under the _splat key:

// routes/files/$/route.tsx
export const Route = createFileRoute('/files/$')({
  component: FileViewer,
})

function FileViewer() {
  const { _splat } = Route.useParams()
  // _splat === 'docs/readme'
  return <p>Viewing: {_splat}</p>
}
⚠️ Read this carefully The splat folder is just $not _$. The _ prefix means "pathless layout" (Lesson 2), a completely different thing. A splat is a plain $ segment; its captured value lives in params._splat. (In v1 the value is also mirrored under the key * for backwards compatibility — removed in v2. Prefer _splat.)

Why $ and not *?

The TanStack authors chose $ over the conventional * because asterisks don't play nicely with filenames and CLI tools — exactly the friction you'd hit in a file-based router. So everywhere you'd mentally reach for * in Next.js, write $ in TanStack.

Next.js → TanStack Quick Map

Next.jsTanStack (directory)Captures
app/posts/[id]/page.tsxroutes/posts/$id/route.tsxparams.id
app/users/[username]/page.tsxroutes/users/$username/route.tsxparams.username
app/files/[...slug]/page.tsxroutes/files/$/route.tsxparams._splat

Two translations to internalize: square brackets become $, and [...slug] (catch-all) becomes a bare $ folder whose value is _splat, not a named param.

Practice Exercise

You're building a mobile app with these screens:

  1. Post detail/posts/42, with a shared /posts list layout above it
  2. User profile/users/ana
  3. Document viewer — any depth under /files/... (e.g. /files/2024/invoices/q3.pdf)

Sketch the directory route tree. Which folders are dynamic? Which is a splat? Then reveal the solution.

Show solution
routes/ ├── __root.tsx ├── posts/ │ ├── route.tsx → /posts (shared list layout) │ └── $postId/ │ └── route.tsx → /posts/42 — params.postId ├── users/ │ └── $username/ │ └── route.tsx → /users/ana — params.username └── files/ └── $/ └── route.tsx → /files/2024/invoices/q3.pdf — params._splat

Why this works:

Quick Quiz

1. Which folder name captures a dynamic postId segment?

postId/
$postId/
_postId/
The $ prefix marks a dynamic segment. Whatever follows the $ is the param name — here postId.

2. What route path does routes/posts/$postId/route.tsx produce?

/posts/postId
/posts/$postId
/posts/$
The $ is preserved in the generated path. The route is registered as /posts/$postId, then matched against real URLs like /posts/42.

3. How do you read postId inside the route component?

const postId = props.postId
const { postId } = Route.useParams()
const postId = useParams(postId)
Each generated route exposes a typed Route.useParams() hook. Destructure the named param — it's typed as string.

4. Where are params available inside a loader?

In the request object
In the params argument
In the context object
Loaders receive ({ params }) as their argument, so you write loader: ({ params }) => fetchPost(params.postId).

5. Which folder is a splat (catch-all) route?

_$
$
*
A segment named exactly $ is the splat. Not _$ — the _ prefix is a pathless layout, a different feature.

6. /files/docs/readme hits routes/files/$/route.tsx. What is params._splat?

/files/docs/readme
docs/readme
readme
The splat captures everything after its own segment, as a single string. So docs/readme — the leading /files/ is the matched parent path.

7. Under which key does TanStack v1 store the splat value?

Under the splat key
Under the _splat key
Under the catch key
The splat tail is stored under the special _splat property. (v1 also mirrors it as * for backwards compatibility — removed in v2.)

8. Why does TanStack use $ instead of * for splats?

* is reserved for params
* breaks filenames and CLIs
* is invalid in a URL
Asterisks don't play nice with filesystems and shell tools — exactly the friction a file-based router wants to avoid. So TanStack uses $.

9. Next.js app/files/[...slug]/page.tsx maps to which TanStack file?

routes/files/_$/route.tsx
routes/files/$/route.tsx
routes/files/*/route.tsx
Next.js catch-all [...slug] becomes a bare $ folder. The captured value lands in params._splat, not a named param.

10. Route /posts/$postId/$revisionId — how many params are captured?

Exactly one param
Exactly two params
Exactly zero params
Each $-prefixed segment captures its own param. /posts/42/7 yields { postId: "42", revisionId: "7" } — both typed.
← Lesson 2: Pathless Routes & Groups Lesson 4: Capstone Mobile Tree →
🎓 Stuck on something?
Ask your teacher — when to use a splat vs a dynamic segment, how params stay type-safe, or how to structure detail screens for your specific mobile app.

Primary source: TanStack Router — Routing Concepts (Dynamic Route Segments & Splat / Catch-All sections). Read it after the quiz to deepen your understanding.