TanStack Router 04 ⚛️ Route Trees — Building type-safe trees
The previous chapter covered the file conventions that map a directory structure to URLs. This chapter covers the route tree itself: the nested data structure that the router uses to match URLs and the generated types that make navigation type-safe. A route tree is not a flat list. It is a hierarchy of routes, where each route can have children, and the children render inside the parent’s <Outlet />. The @tanstack/router-plugin reads the src/routes/ directory and generates the routeTree.gen.ts file, which is a nested object that the createRouter function consumes. The critical detail is that the generated tree is the source of truth for the type system. The Link component’s to prop, the useParams hook’s return type, and the useSearch hook’s type are all inferred from the route tree. Without the tree, there is no type safety.
Key point: The route tree is built by nesting routes with addChildren. The file-based routing generates the tree automatically. The createRouter({ routeTree }) connects the tree to the router. The declare module '@tanstack/react-router' { interface Register { router: typeof router } } block registers the tree’s types globally, which enables the type-safe navigation and hooks. Without the registration, the to prop is a generic string, and the hooks return union types.
Why route trees require a specific mental model
The nested structure problem. A route tree is not a flat array. The /posts/$postId/edit route is not a top-level route. It is a child of /posts/$postId, which is a child of /posts, which is a child of the root route. The nesting is what allows the layout routes to wrap their children. The posts.tsx layout renders an <Outlet />, and the posts.$postId.tsx route renders inside it. The tree structure mirrors the URL structure, and the component tree mirrors the route tree.
The type inference problem. The router’s type system is driven by the route tree. The RoutePaths type is a union of all valid paths in the tree. The useParams hook returns the params for the specific route, but only if the route is registered. Without the declare module block, the to prop accepts any string, and a typo in the path is a runtime error instead of a compile-time error. The registration is the connection between the generated tree and the TypeScript compiler.
The code-based versus file-based problem. The file-based routing generates the tree automatically. The code-based routing builds the tree manually with createRoute and addChildren. The code-based approach is more explicit but requires more code. The file-based approach is the recommended default because the tree is always in sync with the files. The addChildren method accepts an array or an object. The object syntax is faster for TypeScript inference in large trees, but the file-based routing handles this automatically.
The lazy loading problem. In large applications, the route tree can be large, and the TypeScript inference can be slow. The getRouteApi function is the mechanism for accessing the route’s typed hooks without importing the full route configuration into a lazy chunk. The createLazyFileRoute is the lazy variant of createFileRoute. The tree is still the source of truth, but the type access is scoped to the route.
The trade-off. The file-based routing is the recommended path because the tree is generated. The code-based routing is the fallback for the applications that need programmatic route construction. The type safety is the differentiator. A router without types is a router that fails at runtime. A router with types is a router that fails at compile time.
a. The generated route tree and the registration
The routeTree.gen.ts file is generated by the plugin. It imports the route files and builds the nested tree.
// src/routeTree.gen.ts (generated, simplified)
import { Route as rootRoute } from './routes/__root'
import { Route as aboutRoute } from './routes/about'
import { Route as postsRoute } from './routes/posts'
import { Route as postsPostIdRoute } from './routes/posts.$postId'
const routeTree = rootRoute.addChildren([
aboutRoute,
postsRoute.addChildren([postsPostIdRoute]),
])
export { routeTree }
The tree is a nested structure. The postsRoute has the postsPostIdRoute as a child. The aboutRoute is a sibling of the postsRoute. The rootRoute is the parent of both. The addChildren method builds the hierarchy.
The createRouter function consumes the tree. The declare module block registers the router type globally.
// src/main.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
The registration is the mechanism that enables the type-safe Link, useParams, and useNavigate. Without it, the to prop is a string, and the hooks return unknown. With it, the to prop is a union of the valid paths, and the hooks return the typed params.
b. The Route.useParams and the typed access
The Route.useParams() method is the typed access to the params. It is available on the route object returned by createFileRoute.
// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
component: PostComponent,
})
function PostComponent() {
const { postId } = Route.useParams()
// postId is typed as string
return <div>Post: {postId}</div>
}
The Route.useParams() is the preferred pattern when the component is in the same file as the route definition. The from option is inferred automatically. The useParams({ from: '/posts/$postId' }) is the equivalent for the components that are in a different file.
The params can be parsed to a different type. The params.parse option in the route definition transforms the string params to the desired type.
export const Route = createFileRoute('/posts/$postId')({
params: {
parse: (params) => ({ postId: Number(params.postId) }),
stringify: (params) => ({ postId: String(params.postId) }),
},
component: PostComponent,
})
function PostComponent() {
const { postId } = Route.useParams()
// postId is typed as number
return <div>Post: {postId}</div>
}
The parse function is called when the route matches. The stringify function is called when the route is navigated to. The type system tracks the transformation, so the postId is a number in the component.
c. The type-safe navigation and the from option
The Link component’s to prop is typed against the route tree. The params prop is required for the dynamic segments.
import { Link } from '@tanstack/react-router'
<Link to="/posts/$postId" params={{ postId: '123' }}>
Post 123
</Link>
// TypeScript error: missing params
<Link to="/posts/$postId">Post</Link>
// TypeScript error: invalid path
<Link to="/invalid">Invalid</Link>
The to prop is a union of the valid paths. The params prop is required when the path has a dynamic segment. The search prop is typed against the validateSearch schema.
The useNavigate hook is the imperative version. The to and params are typed the same way.
const navigate = useNavigate()
navigate({ to: '/posts/$postId', params: { postId: '123' } })
// TypeScript error: missing params
navigate({ to: '/posts/$postId' })
The useSearch hook returns the typed search params. The from option narrows the type to the specific route.
// Without from: union of all search params
const search = useSearch()
// With from: specific to /posts
const search = useSearch({ from: '/posts' })
The from option is the mechanism for narrowing the type. Without it, the type is a union of all routes’ search params, which is less precise and slower to check.
Complete Example Session
# ============================================
# PART 1: GENERATED TREE
# ============================================
// src/routeTree.gen.ts
import { Route as rootRoute } from './routes/__root'
import { Route as aboutRoute } from './routes/about'
import { Route as postsRoute } from './routes/posts'
import { Route as postsPostIdRoute } from './routes/posts.$postId'
const routeTree = rootRoute.addChildren([
aboutRoute,
postsRoute.addChildren([postsPostIdRoute]),
])
export { routeTree }
# ============================================
# PART 2: REGISTER THE ROUTER
# ============================================
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
# ============================================
# PART 3: TYPED PARAMS
# ============================================
// src/routes/posts.$postId.tsx
const { postId } = Route.useParams()
// postId: string
# ============================================
# PART 4: PARSED PARAMS
# ============================================
export const Route = createFileRoute('/posts/$postId')({
params: {
parse: (params) => ({ postId: Number(params.postId) }),
stringify: (params) => ({ postId: String(params.postId) }),
},
component: PostComponent,
})
const { postId } = Route.useParams()
// postId: number
# ============================================
# PART 5: TYPED LINK
# ============================================
<Link to="/posts/$postId" params={{ postId: '123' }}>Post</Link>
# ============================================
# PART 6: TYPE ERROR ON MISSING PARAMS
# ============================================
<Link to="/posts/$postId">Post</Link>
// TypeScript error: missing params
# ============================================
# PART 7: TYPED SEARCH
# ============================================
import { z } from 'zod'
const searchSchema = z.object({
page: z.number().default(1),
})
export const Route = createFileRoute('/posts')({
validateSearch: searchSchema,
component: PostsComponent,
})
const { page } = Route.useSearch()
// page: number
# ============================================
# PART 8: NARROWED SEARCH
# ============================================
const search = useSearch({ from: '/posts' })
// search is typed for /posts only
# ============================================
# PART 9: CODE-BASED TREE
# ============================================
const rootRoute = createRootRoute()
const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/posts',
})
const routeTree = rootRoute.addChildren([postsRoute])
The nine parts trace the route tree: generated tree, registration, typed params, parsed params, typed link, type error, typed search, narrowed search, and code-based tree.
Quick Reference
Tree Building
| Method | Purpose |
|---|---|
addChildren | Add child routes |
getParentRoute | Link to parent (code-based) |
routeTree.gen.ts | Generated tree (file-based) |
Type Registration
| Block | Purpose |
|---|---|
declare module '@tanstack/react-router' | Global type registration |
interface Register { router: typeof router } | Router type |
Typed Hooks
| Hook | Type Source |
|---|---|
Route.useParams() | Route path params |
Route.useSearch() | validateSearch schema |
Route.useLoaderData() | loader return type |
useParams({ from }) | Specific route |
useSearch({ from }) | Specific route |
Path Params
| Option | Purpose |
|---|---|
params.parse | String to type |
params.stringify | Type to string |
Best Practices
✅ Do This:
declare module '@tanstack/react-router' {
interface Register { router: typeof router }
}
<!-- Register the router for type safety --> # ✅
const { postId } = Route.useParams()
<!-- Use Route.useParams in the route file --> # ✅
params: { parse: (p) => ({ id: Number(p.id) }) }
<!-- Parse params to the correct type --> # ✅
❌ Don’t Do This:
# Skip the registration
<!-- Link and hooks lose type safety --> # ❌
# Use useParams() without from
<!-- Returns union of all routes' params --> # ❌
# Assume params are numbers
<!-- They are strings from the URL --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
to accepts any string | No registration | Add declare module |
postId type is unknown | No from or wrong route | Use Route.useParams |
| Params are strings | No parse | Add params.parse |
| Search type is union | No from | Use useSearch({ from }) |
| Type inference slow | Large tree, no object syntax | Use object addChildren |
Real-World Examples
1. Generated Tree
import { routeTree } from './routeTree.gen'
2. Register Router
declare module '@tanstack/react-router' { interface Register { router: typeof router } }
3. Typed Params
const { postId } = Route.useParams()
4. Parsed Params
params: { parse: (p) => ({ id: Number(p.id) }) }
5. Typed Link
<Link to="/posts/$postId" params={{ postId: '1' }}>Post</Link>
6. Typed Search
const { page } = Route.useSearch()
7. Narrowed Search
const search = useSearch({ from: '/posts' })
8. Code-Based Tree
const routeTree = rootRoute.addChildren([postsRoute])
9. Object Syntax
rootRoute.addChildren({ postsRoute: postsRoute.addChildren({ postRoute }) })
10. Lazy Route
const routeApi = getRouteApi('/posts')
const { page } = routeApi.useSearch()
Visual
Route Tree Structure
┌──────────────────────────────────────────────┐
│ rootRoute │
│ ├── indexRoute (/) │
│ ├── aboutRoute (/about) │
│ └── postsRoute (/posts) │
│ └── postsPostIdRoute (/posts/$postId) │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ routeTree.gen.ts │
│ → rootRoute.addChildren([...]) │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ createRouter({ routeTree }) │
└──────────────────────────────────────────────┘
Type Registration Flow
┌──────────────────────────────────────────────┐
│ const router = createRouter({ routeTree }) │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ declare module { Register { router } } │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ <Link to="/posts/$postId" params={{...}}> │
│ → TypeScript validates the path and params │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Tree generation | routeTree.gen.ts (file-based) |
| Tree building | addChildren (code-based) |
| Router creation | createRouter({ routeTree }) |
| Type registration | declare module block |
| Params hook | Route.useParams() |
| Search hook | Route.useSearch() |
| Loader hook | Route.useLoaderData() |
| Params parsing | params.parse / params.stringify |
| Narrowed search | useSearch({ from }) |
Key takeaways:
- The route tree is the connection between the files and the router. The file-based routing generates the tree automatically. The
routeTree.gen.tsfile is the nested structure that thecreateRouterfunction consumes. - The type registration is mandatory for type safety. The
declare module '@tanstack/react-router' { interface Register { router: typeof router } }block registers the tree’s types globally. Without it, thetoprop is a string, and the hooks return union types. - The
Route.useParams()is the typed access. It returns the params for the specific route. Thefromoption is required when the component is in a different file. Theparams.parseoption transforms the string params to the desired type. - The
useSearch({ from })narrows the search type. Without thefromoption, the type is a union of all routes’ search params. With it, the type is specific to the route. - The code-based tree is the manual alternative. The
createRouteandaddChildrenmethods build the tree. The object syntax is faster for TypeScript inference in large trees.
Remember: The route tree is the hierarchy. The addChildren is the builder. The routeTree.gen.ts is the generated file. The createRouter is the consumer. The declare module is the type bridge. The Route.useParams is the typed access. The from is the narrowing. The exam tests the tree structure and the type registration.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!