
Skill
router-core/type-safety
enforce type safety in TanStack Router
Description
Full type inference philosophy (never cast, never annotate inferred values), Register module declaration, from narrowing on hooks and Link, strict:false for shared components, getRouteApi for code-split typed access, addChildren with object syntax for TS perf, LinkProps and ValidateLinkOptions type utilities, as const satisfies pattern.
SKILL.md
Type Safety
TanStack Router is FULLY type-inferred. Params, search params, context, and loader data all flow through the route tree automatically. The #1 AI agent mistake is adding type annotations, casts, or generic parameters to values that are already inferred.
CRITICAL: NEVER use
as Type, explicit generic params,satisfieson hook returns, or type annotations on inferred values. Every cast masks real type errors and breaks the inference chain. CRITICAL: Do NOT confuse TanStack Router with Next.js or React Router. There is nogetServerSideProps, nouseSearchParams(), nouseLoaderData()fromreact-router-dom.
The ONE Required Type Annotation: Register
Without this, top-level exports like Link, useNavigate, useSearch have no type safety.
// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({ routeTree })
// THIS IS REQUIRED — the single type registration for the entire app
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
export default router
After registration, every Link, useNavigate, useSearch, useParams across the app is fully typed.
Types Flow Automatically
Route Hooks — No Annotation Needed
// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
validateSearch: (search: Record<string, unknown>) => ({
page: Number(search.page ?? 1),
}),
loader: async ({ params }) => {
// params.postId is already typed as string — do not annotate
const post = await fetchPost(params.postId)
return { post }
},
component: PostComponent,
})
function PostComponent() {
// ALL of these are fully inferred — do NOT add type annotations
const { postId } = Route.useParams()
// ^? string
const { page } = Route.useSearch()
// ^? number
const { post } = Route.useLoaderData()
// ^? { id: string; title: string; body: string }
return (
<div>
<h1>{post.title}</h1>
<p>Page {page}</p>
</div>
)
}
Context Flows Through the Tree
// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
interface RouterContext {
auth: { userId: string; role: 'admin' | 'user' } | null
}
// Note: createRootRouteWithContext is a FACTORY — call it TWICE: ()()
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
})
// src/routes/dashboard.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/dashboard')({
beforeLoad: ({ context }) => {
// context.auth is already typed as { userId: string; role: 'admin' | 'user' } | null
// NO annotation needed
if (!context.auth) throw redirect({ to: '/login' })
return { user: context.auth }
},
loader: ({ context }) => {
// context.user is typed as { userId: string; role: 'admin' | 'user' }
// This was added by beforeLoad above — fully inferred
return fetchDashboard(context.user.userId)
},
component: DashboardComponent,
})
function DashboardComponent() {
const data = Route.useLoaderData()
const { user } = Route.useRouteContext()
return <h1>Welcome {user.userId}</h1>
}
Narrowing with from
Without from, hooks return a union of ALL routes' types — slow for TypeScript and imprecise.
On Hooks
import { useSearch, useParams, useNavigate } from '@tanstack/react-router'
function PostSidebar() {
// WRONG — search is a union of ALL routes' search params
const search = useSearch()
// CORRECT — search is narrowed to /posts/$postId's search params
const search = useSearch({ from: '/posts/$postId' })
// ^? { page: number }
// CORRECT — params narrowed to this route
const { postId } = useParams({ from: '/posts/$postId' })
// CORRECT — navigate narrowed for relative paths
const navigate = useNavigate({ from: '/posts/$postId' })
}
On Link
import { Link } from '@tanstack/react-router'
// WRONG — search resolves to union of ALL routes' search params, slow TS check
<Link to=".." search={{ page: 0 }} />
// CORRECT — narrowed, fast TS check
<Link from="/posts/$postId" to=".." search={{ page: 0 }} />
// Also correct — Route.fullPath in route components
<Link from={Route.fullPath} to=".." search={{ page: 0 }} />
Shared Components: strict: false
When a component is used across multiple routes, use strict: false instead of from:
import { useSearch } from '@tanstack/react-router'
function GlobalSearch() {
// Returns union of all routes' search params — no runtime error if route doesn't match
const search = useSearch({ strict: false })
return <span>Query: {search.q ?? ''}</span>
}
Code-Split Files: getRouteApi
Use getRouteApi instead of importing Route to avoid pulling route config into the lazy chunk:
// src/routes/posts.lazy.tsx
import { createLazyFileRoute, getRouteApi } from '@tanstack/react-router'
const routeApi = getRouteApi('/posts')
export const Route = createLazyFileRoute('/posts')({
component: PostsComponent,
})
function PostsComponent() {
const data = routeApi.useLoaderData()
const { page } = routeApi.useSearch()
return <div>Page {page}</div>
}
TypeScript Performance
Use Object Syntax for addChildren in Large Route Trees
// SLOWER — tuple syntax
const routeTree = rootRoute.addChildren([
postsRoute.addChildren([postRoute, postsIndexRoute]),
indexRoute,
])
// FASTER — object syntax (TS checks objects faster than large tuples)
const routeTree = rootRoute.addChildren({
postsRoute: postsRoute.addChildren({ postRoute, postsIndexRoute }),
indexRoute,
})
With file-based routing the route tree is generated, so this is handled for you.
Avoid Returning Unused Inferred Types from Loaders
When using external caches like TanStack Query, don't let the router infer complex return types you never consume:
// SLOWER — TS infers the full ensureQueryData return type into the route tree
export const Route = createFileRoute('/posts/$postId')({
loader: ({ context: { queryClient }, params: { postId } }) =>
queryClient.ensureQueryData(postQueryOptions(postId)),
component: PostComponent,
})
// FASTER — void return, inference stays out of the route tree
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ context: { queryClient }, params: { postId } }) => {
await queryClient.ensureQueryData(postQueryOptions(postId))
},
component: PostComponent,
})
as const satisfies for Link Option Objects
Never use LinkProps as a variable type — it's an enormous union:
import type { LinkProps, RegisteredRouter } from '@tanstack/react-router'
// WRONG — LinkProps is a massive union, extremely slow TS check
const wrongProps: LinkProps = { to: '/posts' }
// CORRECT — infer a precise type, validate against LinkProps
const goodProps = { to: '/posts' } as const satisfies LinkProps
// EVEN BETTER — narrow LinkProps with generic params
const narrowedProps = {
to: '/posts',
} as const satisfies LinkProps<RegisteredRouter, string, '/posts'>
Type-Safe Link Option Arrays
import type { LinkProps } from '@tanstack/react-router'
export const navLinks = [
{ to: '/posts' },
{ to: '/posts/$postId', params: { postId: '1' } },
] as const satisfies ReadonlyArray<LinkProps>
// Use the precise inferred type, not LinkProps directly
export type NavLink = (typeof navLinks)[number]
Type Utilities for Generic Components
ValidateLinkOptions — Type-Safe Link Props in Custom Components
import {
Link,
type RegisteredRouter,
type ValidateLinkOptions,
} from '@tanstack/react-router'
interface NavItemProps<
TRouter extends RegisteredRouter = RegisteredRouter,
TOptions = unknown,
> {
label: string
linkOptions: ValidateLinkOptions<TRouter, TOptions>
}
export function NavItem<TRouter extends RegisteredRouter, TOptions>(
props: NavItemProps<TRouter, TOptions>,
): React.ReactNode
export function NavItem(props: NavItemProps): React.ReactNode {
return (
<li>
<Link {...props.linkOptions}>{props.label}</Link>
</li>
)
}
// Usage — fully type-safe
<NavItem label="Posts" linkOptions={{ to: '/posts' }} />
<NavItem label="Post" linkOptions={{ to: '/posts/$postId', params: { postId: '1' } }} />
ValidateNavigateOptions and ValidateRedirectOptions
Same pattern as ValidateLinkOptions above, for useNavigate and redirect. Declare a generic public overload plus a non-generic implementation signature so the call site stays narrowed and the body works without casts:
import {
useNavigate,
type RegisteredRouter,
type ValidateNavigateOptions,
} from '@tanstack/react-router'
export function useDelayedNavigate<
TRouter extends RegisteredRouter = RegisteredRouter,
TOptions = unknown,
>(
options: ValidateNavigateOptions<TRouter, TOptions>,
delayMs: number,
): () => void
export function useDelayedNavigate(
options: ValidateNavigateOptions,
delayMs: number,
) {
const navigate = useNavigate()
return () => {
setTimeout(() => navigate(options), delayMs)
}
}
ValidateRedirectOptions works identically — declare a generic overload accepting ValidateRedirectOptions<TRouter, TOptions> and an impl signature accepting ValidateRedirectOptions, then call redirect(options) in the body.
Render Props for Maximum Performance
Instead of accepting LinkProps, invert control so Link is narrowed at the call site:
function Card(props: { title: string; renderLink: () => React.ReactNode }) {
return (
<div>
<h2>{props.title}</h2>
{props.renderLink()}
</div>
)
}
// Link narrowed to exactly /posts — no union check
;<Card title="All Posts" renderLink={() => <Link to="/posts">View</Link>} />
Render Optimizations
Fine-Grained Selectors with select
function PostTitle() {
// Only re-renders when page changes, not when other search params change
const page = Route.useSearch({ select: ({ page }) => page })
return <span>Page {page}</span>
}
Structural Sharing
Preserve referential identity across re-renders for search params:
const router = createRouter({
routeTree,
defaultStructuralSharing: true, // Enable globally
})
// Or per-hook
const result = Route.useSearch({
select: (search) => ({ foo: search.foo, label: `Page ${search.foo}` }),
structuralSharing: true,
})
Structural sharing only works with JSON-compatible data. TypeScript will error if you return class instances with structuralSharing: true.
Common Mistakes
1. CRITICAL: Adding type annotations or casts to inferred values
// WRONG — casting masks real type errors
const search = useSearch({ from: '/posts' }) as { page: number }
// WRONG — unnecessary annotation
const params: { postId: string } = useParams({ from: '/posts/$postId' })
// WRONG — generic param on hook
const data = useLoaderData<{ posts: Post[] }>({ from: '/posts' })
// CORRECT — let inference work
const search = useSearch({ from: '/posts' })
const params = useParams({ from: '/posts/$postId' })
const data = useLoaderData({ from: '/posts' })
2. HIGH: Using un-narrowed LinkProps type
// WRONG — LinkProps is a massive union, causes severe TS slowdown
const myProps: LinkProps = { to: '/posts' }
// CORRECT — use as const satisfies for precise inference
const myProps = { to: '/posts' } as const satisfies LinkProps
3. HIGH: Not narrowing Link/useNavigate with from
// WRONG — search is a union of ALL routes, TS check grows with route count
<Link to=".." search={{ page: 0 }} />
// CORRECT — narrowed, fast check
<Link from={Route.fullPath} to=".." search={{ page: 0 }} />
4. CRITICAL (cross-skill): Missing router type registration
// WRONG — Link/useNavigate have no autocomplete, all paths are untyped strings
const router = createRouter({ routeTree })
// (no declare module)
// CORRECT — always register
const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
5. CRITICAL (cross-skill): Wrong-framework imports and file structure
Wrong-framework code looks plausible (it's React) but breaks the build or produces conflicting / routes at runtime.
// WRONG — react-router-dom and next/* are different libraries
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import Link from 'next/link'
import { useRouter, useParams } from 'next/navigation'
// CORRECT — all routing exports come from @tanstack/react-router
import {
Link,
Outlet,
useNavigate,
useRouter,
useLocation,
useParams,
redirect,
} from '@tanstack/react-router'
// WRONG file structures + APIs:
// src/pages/*.tsx with getServerSideProps / getStaticProps (Next.js Pages Router)
// app/layout.tsx + app/page.tsx (Next.js App Router)
// _app/index.tsx, pages/_app.tsx, pages/_document.tsx (Next.js custom App)
// loader/action exports (Remix)
// CORRECT — TanStack file-based routing at src/routes/*.tsx
export const Route = createFileRoute('/posts')({
loader: async () => { ... },
validateSearch: zodValidator(schema),
component: PostsComponent,
})
const search = Route.useSearch()
If a build error mentions react-router-dom, next/, pages/_app, or duplicate / routes, fix the import — don't paper over with type assertions.
See also: router-core (Register setup), router-core/navigation (from narrowing), router-core/code-splitting (getRouteApi).
More skills from the router repository
View all 30 skillscompositions/router-query
integrate TanStack Router with Query
Jul 16FrontendReactTanStackTanStack Routerlifecycle/migrate-from-nextjs
migrate Next.js applications to TanStack Start
Jul 16FrontendMigrationNext.jsTanStack Routerlifecycle/migrate-from-react-router
migrate applications to TanStack Router
Jul 16MigrationReact RouterTanStackTanStack Routerreact-router
implement TanStack Router in React applications
Jul 17FrontendReactRoutingTanStackreact-start
build applications with TanStack Start
Jul 16ReactTanStackTypeScriptreact-start/server-components
implement TanStack Start server components
Jul 17FrontendReactSSR
More from TanStack
View publisheraggregation
perform data aggregation in TanStack Table
table
Jul 26Data AnalysisFrontendTanStackapi-not-found
diagnose TanStack Table API errors
table
Jul 26DebuggingFrontendTanStackcell-selection
select rectangular cell ranges in tables
table
Jul 26Data AnalysisTanStackUI Componentsclient-vs-server
manage TanStack Table data pipelines
table
Jul 26Data PipelineFrontendPerformanceTanStackcolumn-faceting
build faceted filter UIs
table
Jul 26Data VisualizationFrontendTanStackcolumn-filtering
implement column filtering in TanStack Table
table
Jul 26Data AnalysisFrontendTanStack