first commit

This commit is contained in:
2026-03-02 21:16:26 +01:00
commit 841f43285e
179 changed files with 33193 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
---
name: tanstack-router-best-practices
description: TanStack Router best practices for type-safe routing, data loading, search params, and navigation. Activate when building React applications with complex routing needs.
---
# TanStack Router Best Practices
Comprehensive guidelines for implementing TanStack Router patterns in React applications. These rules optimize type safety, data loading, navigation, and code organization.
## When to Apply
- Setting up application routing
- Creating new routes and layouts
- Implementing search parameter handling
- Configuring data loaders
- Setting up code splitting
- Integrating with TanStack Query
- Refactoring navigation patterns
## Rule Categories by Priority
| Priority | Category | Rules | Impact |
|----------|----------|-------|--------|
| CRITICAL | Type Safety | 4 rules | Prevents runtime errors and enables refactoring |
| CRITICAL | Route Organization | 5 rules | Ensures maintainable route structure |
| HIGH | Router Config | 1 rule | Global router defaults |
| HIGH | Data Loading | 6 rules | Optimizes data fetching and caching |
| HIGH | Search Params | 5 rules | Enables type-safe URL state |
| HIGH | Error Handling | 1 rule | Handles 404 and errors gracefully |
| MEDIUM | Navigation | 5 rules | Improves UX and accessibility |
| MEDIUM | Code Splitting | 3 rules | Reduces bundle size |
| MEDIUM | Preloading | 3 rules | Improves perceived performance |
| LOW | Route Context | 3 rules | Enables dependency injection |
## Quick Reference
### Type Safety (Prefix: `ts-`)
- `ts-register-router` — Register router type for global inference
- `ts-use-from-param` — Use `from` parameter for type narrowing
- `ts-route-context-typing` — Type route context with createRootRouteWithContext
- `ts-query-options-loader` — Use queryOptions in loaders for type inference
### Router Config (Prefix: `router-`)
- `router-default-options` — Configure router defaults (scrollRestoration, defaultErrorComponent, etc.)
### Route Organization (Prefix: `org-`)
- `org-file-based-routing` — Prefer file-based routing for conventions
- `org-route-tree-structure` — Follow hierarchical route tree patterns
- `org-pathless-layouts` — Use pathless routes for shared layouts
- `org-index-routes` — Understand index vs layout routes
- `org-virtual-routes` — Understand virtual file routes
### Data Loading (Prefix: `load-`)
- `load-use-loaders` — Use route loaders for data fetching
- `load-loader-deps` — Define loaderDeps for cache control
- `load-ensure-query-data` — Use ensureQueryData with TanStack Query
- `load-deferred-data` — Split critical and non-critical data
- `load-error-handling` — Handle loader errors appropriately
- `load-parallel` — Leverage parallel route loading
### Search Params (Prefix: `search-`)
- `search-validation` — Always validate search params
- `search-type-inheritance` — Leverage parent search param types
- `search-middleware` — Use search param middleware
- `search-defaults` — Provide sensible defaults
- `search-custom-serializer` — Configure custom search param serializers
### Error Handling (Prefix: `err-`)
- `err-not-found` — Handle not-found routes properly
### Navigation (Prefix: `nav-`)
- `nav-link-component` — Prefer Link component for navigation
- `nav-active-states` — Configure active link states
- `nav-use-navigate` — Use useNavigate for programmatic navigation
- `nav-relative-paths` — Understand relative path navigation
- `nav-route-masks` — Use route masks for modal URLs
### Code Splitting (Prefix: `split-`)
- `split-lazy-routes` — Use .lazy.tsx for code splitting
- `split-critical-path` — Keep critical config in main route file
- `split-auto-splitting` — Enable autoCodeSplitting when possible
### Preloading (Prefix: `preload-`)
- `preload-intent` — Enable intent-based preloading
- `preload-stale-time` — Configure preload stale time
- `preload-manual` — Use manual preloading strategically
### Route Context (Prefix: `ctx-`)
- `ctx-root-context` — Define context at root route
- `ctx-before-load` — Extend context in beforeLoad
- `ctx-dependency-injection` — Use context for dependency injection
## How to Use
Each rule file in the `rules/` directory contains:
1. **Explanation** — Why this pattern matters
2. **Bad Example** — Anti-pattern to avoid
3. **Good Example** — Recommended implementation
4. **Context** — When to apply or skip this rule
## Full Reference
See individual rule files in `rules/` directory for detailed guidance and code examples.

View File

@@ -0,0 +1,172 @@
# ctx-root-context: Define Context at Root Route
## Priority: LOW
## Explanation
Use `createRootRouteWithContext` to define typed context that flows through your entire route tree. This enables dependency injection for things like query clients, auth state, and services.
## Bad Example
```tsx
// No context - importing globals directly
// routes/__root.tsx
import { createRootRoute } from '@tanstack/react-router'
import { queryClient } from '@/lib/query-client' // Global import
export const Route = createRootRoute({
component: RootComponent,
})
// routes/posts.tsx
import { queryClient } from '@/lib/query-client' // Import again
export const Route = createFileRoute('/posts')({
loader: async () => {
// Using global - harder to test, couples to implementation
return queryClient.ensureQueryData(postQueries.list())
},
})
```
## Good Example
```tsx
// routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import { QueryClient } from '@tanstack/react-query'
// Define the context interface
interface RouterContext {
queryClient: QueryClient
auth: {
user: User | null
isAuthenticated: boolean
}
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
})
function RootComponent() {
return (
<>
<Header />
<main>
<Outlet />
</main>
<Footer />
</>
)
}
// router.tsx - Provide context when creating router
import { createRouter } from '@tanstack/react-router'
import { QueryClient } from '@tanstack/react-query'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { routeTree } from './routeTree.gen'
export function getRouter(auth: RouterContext['auth'] = { user: null, isAuthenticated: false }) {
const queryClient = new QueryClient()
const router = createRouter({
routeTree,
context: {
queryClient,
auth,
},
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
scrollRestoration: true,
})
setupRouterSsrQueryIntegration({ router, queryClient })
return router
}
// routes/posts.tsx - Use context in loaders
export const Route = createFileRoute('/posts')({
loader: async ({ context: { queryClient } }) => {
// Context is typed and injected
return queryClient.ensureQueryData(postQueries.list())
},
})
```
## Good Example: Auth-Protected Routes
```tsx
// routes/__root.tsx
interface RouterContext {
queryClient: QueryClient
auth: AuthState
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
})
// routes/_authenticated.tsx - Layout route for protected pages
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
component: AuthenticatedLayout,
})
// routes/_authenticated/dashboard.tsx
export const Route = createFileRoute('/_authenticated/dashboard')({
loader: async ({ context: { queryClient, auth } }) => {
// We know user is authenticated from parent beforeLoad
return queryClient.ensureQueryData(
dashboardQueries.forUser(auth.user!.id)
)
},
})
```
## Extending Context with beforeLoad
```tsx
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
beforeLoad: async ({ context, params }) => {
// Extend context with route-specific data
const post = await fetchPost(params.postId)
return {
post, // Available to this route and children
}
},
loader: async ({ context }) => {
// context now includes 'post' from beforeLoad
const comments = await fetchComments(context.post.id)
return { comments }
},
})
```
## Context vs. Loader Data
| Context | Loader Data |
|---------|-------------|
| Available in beforeLoad, loader, and component | Only available in component |
| Set at router creation or in beforeLoad | Returned from loader |
| Good for services, clients, auth | Good for route-specific data |
| Flows down to all children | Specific to route |
## Context
- Type the context interface in `createRootRouteWithContext<T>()`
- Provide context when calling `createRouter({ context: {...} })`
- Context flows from root to all nested routes
- Use `beforeLoad` to extend context for specific subtrees
- Enables testability - inject mocks in tests
- Avoids global imports and singletons

View File

@@ -0,0 +1,194 @@
# err-not-found: Handle Not-Found Routes Properly
## Priority: HIGH
## Explanation
Configure `notFoundComponent` to handle 404 errors gracefully. TanStack Router provides not-found handling at multiple levels: root, route-specific, and programmatic via `notFound()`. Proper configuration prevents blank screens and improves UX.
## Bad Example
```tsx
// No not-found handling - shows blank screen or error
const router = createRouter({
routeTree,
// Missing defaultNotFoundComponent
})
// Or throwing generic error
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
if (!post) {
throw new Error('Not found') // Generic error, not proper 404
}
return post
},
})
```
## Good Example: Root-Level Not Found
```tsx
// routes/__root.tsx
export const Route = createRootRoute({
component: RootComponent,
notFoundComponent: GlobalNotFound,
})
function GlobalNotFound() {
return (
<div className="not-found">
<h1>404 - Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<Link to="/">Go Home</Link>
</div>
)
}
// router.tsx - Can also set default
const router = createRouter({
routeTree,
defaultNotFoundComponent: () => (
<div>
<h1>404</h1>
<Link to="/">Return Home</Link>
</div>
),
})
```
## Good Example: Route-Specific Not Found
```tsx
// routes/posts/$postId.tsx
import { createFileRoute, notFound } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
if (!post) {
throw notFound() // Proper 404 handling
}
return post
},
notFoundComponent: PostNotFound, // Custom 404 for this route
component: PostPage,
})
function PostNotFound() {
const { postId } = Route.useParams()
return (
<div>
<h1>Post Not Found</h1>
<p>No post exists with ID: {postId}</p>
<Link to="/posts">Browse all posts</Link>
</div>
)
}
```
## Good Example: Not Found with Data
```tsx
export const Route = createFileRoute('/users/$username')({
loader: async ({ params }) => {
const user = await fetchUser(params.username)
if (!user) {
throw notFound({
// Pass data to notFoundComponent
data: {
username: params.username,
suggestions: await fetchSimilarUsernames(params.username),
},
})
}
return user
},
notFoundComponent: UserNotFound,
})
function UserNotFound() {
const { data } = Route.useMatch()
return (
<div>
<h1>User @{data?.username} not found</h1>
{data?.suggestions?.length > 0 && (
<div>
<p>Did you mean:</p>
<ul>
{data.suggestions.map((username) => (
<li key={username}>
<Link to="/users/$username" params={{ username }}>
@{username}
</Link>
</li>
))}
</ul>
</div>
)}
</div>
)
}
```
## Good Example: Catch-All Route
```tsx
// routes/$.tsx - Catch-all splat route
export const Route = createFileRoute('/$')({
component: CatchAllNotFound,
})
function CatchAllNotFound() {
const { _splat } = Route.useParams()
return (
<div>
<h1>Page Not Found</h1>
<p>No page exists at: /{_splat}</p>
<Link to="/">Go to homepage</Link>
</div>
)
}
```
## Good Example: Nested Not Found Bubbling
```tsx
// Not found bubbles up through route tree
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
notFoundComponent: PostsNotFound, // Catches child 404s too
})
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
if (!post) throw notFound()
return post
},
// No notFoundComponent - bubbles to parent
})
// routes/posts/$postId/comments.tsx
export const Route = createFileRoute('/posts/$postId/comments')({
loader: async ({ params }) => {
const comments = await fetchComments(params.postId)
if (!comments) throw notFound() // Bubbles to /posts notFoundComponent
return comments
},
})
```
## Context
- `notFound()` throws a special error caught by nearest `notFoundComponent`
- Not found bubbles up the route tree if not handled locally
- Use `defaultNotFoundComponent` on router for global fallback
- Pass data to `notFound({ data })` for contextual 404 pages
- Catch-all routes (`/$`) can handle truly unknown paths
- Different from error boundaries - specifically for 404 cases

View File

@@ -0,0 +1,153 @@
# load-ensure-query-data: Use ensureQueryData with TanStack Query
## Priority: HIGH
## Explanation
When integrating TanStack Router with TanStack Query, use `queryClient.ensureQueryData()` in loaders instead of `prefetchQuery()`. This respects the cache, awaits data if missing, and returns the data for potential use.
## Bad Example
```tsx
// Using prefetchQuery - doesn't return data, can't await stale check
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// prefetchQuery never throws, swallows errors
queryClient.prefetchQuery({
queryKey: ['posts', params.postId],
queryFn: () => fetchPost(params.postId),
})
// No await - might not complete before render
// No return value to use
},
})
// Fetching directly - bypasses TanStack Query cache
export const Route = createFileRoute('/posts')({
loader: async () => {
const posts = await fetchPosts() // Not cached
return { posts }
},
})
```
## Good Example
```tsx
// Define queryOptions for reuse
const postQueryOptions = (postId: string) =>
queryOptions({
queryKey: ['posts', postId],
queryFn: () => fetchPost(postId),
staleTime: 5 * 60 * 1000, // 5 minutes
})
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// ensureQueryData:
// - Returns cached data if fresh
// - Fetches and caches if missing or stale
// - Awaits completion
// - Throws on error (caught by error boundary)
await queryClient.ensureQueryData(postQueryOptions(params.postId))
},
component: PostPage,
})
function PostPage() {
const { postId } = Route.useParams()
// Data guaranteed to exist from loader
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
return <PostContent post={post} />
}
```
## Good Example: Multiple Parallel Queries
```tsx
export const Route = createFileRoute('/dashboard')({
loader: async ({ context: { queryClient } }) => {
// Parallel data fetching
await Promise.all([
queryClient.ensureQueryData(statsQueries.overview()),
queryClient.ensureQueryData(activityQueries.recent()),
queryClient.ensureQueryData(notificationQueries.unread()),
])
},
})
```
## Good Example: Dependent Queries
```tsx
export const Route = createFileRoute('/users/$userId/posts')({
loader: async ({ params, context: { queryClient } }) => {
// First query needed for second
const user = await queryClient.ensureQueryData(
userQueries.detail(params.userId)
)
// Dependent query uses result
await queryClient.ensureQueryData(
postQueries.byAuthor(user.id)
)
},
})
```
## Router Configuration for TanStack Query
```tsx
// router.tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute default
},
},
})
export const router = createRouter({
routeTree,
context: { queryClient },
// Let TanStack Query manage caching
defaultPreloadStaleTime: 0,
// SSR: Dehydrate query cache
dehydrate: () => ({
queryClientState: dehydrate(queryClient),
}),
// SSR: Hydrate on client
hydrate: (dehydrated) => {
hydrate(queryClient, dehydrated.queryClientState)
},
// Wrap with QueryClientProvider
Wrap: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
})
```
## ensureQueryData vs prefetchQuery vs fetchQuery
| Method | Returns | Throws | Awaits | Use Case |
|--------|---------|--------|--------|----------|
| `ensureQueryData` | Data | Yes | Yes | Route loaders (recommended) |
| `prefetchQuery` | void | No | Yes | Background prefetching |
| `fetchQuery` | Data | Yes | Yes | When you need data immediately |
## Context
- `ensureQueryData` is the recommended method for route loaders
- Respects `staleTime` - won't refetch fresh cached data
- Errors propagate to route error boundaries
- Use `queryOptions()` factory for type-safe, reusable query definitions
- Set `defaultPreloadStaleTime: 0` to let TanStack Query manage cache
- Pair with `useSuspenseQuery` in components for guaranteed data

View File

@@ -0,0 +1,190 @@
# load-parallel: Leverage Parallel Route Loading
## Priority: MEDIUM
## Explanation
TanStack Router loads nested route data in parallel, not sequentially. Structure your routes and loaders to maximize parallelization and avoid creating artificial waterfalls.
## Bad Example
```tsx
// Creating waterfall with dependent beforeLoad
export const Route = createFileRoute('/dashboard')({
beforeLoad: async () => {
const user = await fetchUser() // 200ms
const permissions = await fetchPermissions(user.id) // 200ms
const preferences = await fetchPreferences(user.id) // 200ms
// Total: 600ms (sequential)
return { user, permissions, preferences }
},
})
// Or nesting data dependencies incorrectly
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: async () => {
const posts = await fetchPosts() // 300ms
return { posts }
},
})
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
// Waits for parent to complete first - waterfall!
const post = await fetchPost(params.postId) // +200ms
return { post }
},
})
```
## Good Example: Parallel in Single Loader
```tsx
export const Route = createFileRoute('/dashboard')({
beforeLoad: async () => {
// All requests start simultaneously
const [user, config] = await Promise.all([
fetchUser(), // 200ms
fetchAppConfig(), // 150ms
])
// Total: 200ms (parallel)
return { user, config }
},
loader: async ({ context }) => {
// These also run in parallel with each other
const [stats, activity, notifications] = await Promise.all([
fetchDashboardStats(context.user.id),
fetchRecentActivity(context.user.id),
fetchNotifications(context.user.id),
])
return { stats, activity, notifications }
},
})
```
## Good Example: Parallel Nested Routes
```tsx
// Parent and child loaders run in PARALLEL
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: async () => {
// This runs...
const categories = await fetchCategories()
return { categories }
},
})
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
// ...at the SAME TIME as this!
const post = await fetchPost(params.postId)
const comments = await fetchComments(params.postId)
return { post, comments }
},
})
// Navigation to /posts/123:
// - Both loaders start simultaneously
// - Total time = max(categoriesTime, postTime + commentsTime)
// - NOT categoriesTime + postTime + commentsTime
```
## Good Example: With TanStack Query
```tsx
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: async ({ context: { queryClient } }) => {
// These all start in parallel
await Promise.all([
queryClient.ensureQueryData(postQueries.list()),
queryClient.ensureQueryData(categoryQueries.all()),
])
},
})
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// Runs in parallel with parent loader
await Promise.all([
queryClient.ensureQueryData(postQueries.detail(params.postId)),
queryClient.ensureQueryData(commentQueries.forPost(params.postId)),
])
},
})
```
## Good Example: Streaming Non-Critical Data
```tsx
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// Critical data - await
const post = await queryClient.ensureQueryData(
postQueries.detail(params.postId)
)
// Non-critical - start but don't await (stream in later)
queryClient.prefetchQuery(commentQueries.forPost(params.postId))
queryClient.prefetchQuery(relatedQueries.forPost(params.postId))
return { post }
},
component: PostPage,
})
function PostPage() {
const { post } = Route.useLoaderData()
const { postId } = Route.useParams()
// Critical data ready immediately
// Non-critical loads in component with loading state
const { data: comments, isLoading } = useQuery(
commentQueries.forPost(postId)
)
return (
<article>
<PostContent post={post} />
{isLoading ? <CommentsSkeleton /> : <Comments data={comments} />}
</article>
)
}
```
## Route Loading Timeline
```
Navigation to /posts/123
Without parallelization:
├─ beforeLoad (parent) ████████
├─ loader (parent) ████████
├─ beforeLoad (child) ████
├─ loader (child) ████████
└─ Render █
With parallelization:
├─ beforeLoad (parent) ████████
├─ beforeLoad (child) ████
├─ loader (parent) ████████
├─ loader (child) ████████████
└─ Render █
```
## Context
- Nested route loaders run in parallel by default
- `beforeLoad` runs before `loader` (for auth, context setup)
- Use `Promise.all` for parallel fetches within a single loader
- Parent context is available in child loaders (after beforeLoad)
- Prefetch non-critical data without awaiting for streaming
- Monitor network tab to verify parallelization

View File

@@ -0,0 +1,148 @@
# load-use-loaders: Use Route Loaders for Data Fetching
## Priority: HIGH
## Explanation
Route loaders execute before the route renders, enabling data to be ready when the component mounts. This prevents loading waterfalls, enables preloading, and integrates with the router's caching layer.
## Bad Example
```tsx
// Fetching in component - creates waterfall
function PostsPage() {
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
// Route renders, THEN data fetches, THEN UI updates
fetchPosts().then((data) => {
setPosts(data)
setLoading(false)
})
}, [])
if (loading) return <Loading />
return <PostList posts={posts} />
}
// No preloading possible - user sees loading state on navigation
```
## Good Example
```tsx
// routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts')({
loader: async () => {
const posts = await fetchPosts()
return { posts }
},
component: PostsPage,
})
function PostsPage() {
// Data is ready when component mounts - no loading state needed
const { posts } = Route.useLoaderData()
return <PostList posts={posts} />
}
```
## Good Example: With Parameters
```tsx
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
// params are type-safe and guaranteed to exist
const post = await fetchPost(params.postId)
const comments = await fetchComments(params.postId)
return { post, comments }
},
component: PostDetailPage,
})
function PostDetailPage() {
const { post, comments } = Route.useLoaderData()
const { postId } = Route.useParams()
return (
<article>
<h1>{post.title}</h1>
<PostContent content={post.content} />
<CommentList comments={comments} />
</article>
)
}
```
## Good Example: With TanStack Query
```tsx
// routes/posts/$postId.tsx
import { queryOptions } from '@tanstack/react-query'
const postQueryOptions = (postId: string) =>
queryOptions({
queryKey: ['posts', postId],
queryFn: () => fetchPost(postId),
})
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// Ensure data is in cache before render
await queryClient.ensureQueryData(postQueryOptions(params.postId))
},
component: PostDetailPage,
})
function PostDetailPage() {
const { postId } = Route.useParams()
// useSuspenseQuery because loader guarantees data exists
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
return <PostContent post={post} />
}
```
## Loader Context Properties
```tsx
export const Route = createFileRoute('/posts')({
loader: async ({
params, // Route path parameters
context, // Route context (queryClient, auth, etc.)
abortController, // For cancelling stale requests
cause, // 'enter' | 'preload' | 'stay'
deps, // Dependencies from loaderDeps
preload, // Boolean: true if preloading
}) => {
// Use abortController for fetch cancellation
const response = await fetch('/api/posts', {
signal: abortController.signal,
})
// Different behavior for preload vs navigation
if (preload) {
// Lighter data for preload
return { posts: await response.json() }
}
// Full data for actual navigation
const posts = await response.json()
const stats = await fetchStats()
return { posts, stats }
},
})
```
## Context
- Loaders run during route matching, before component render
- Supports parallel loading across nested routes
- Enables preloading on link hover/focus
- Built-in stale-while-revalidate caching
- For complex caching needs, integrate with TanStack Query
- Use `beforeLoad` for auth checks and redirects

View File

@@ -0,0 +1,178 @@
# nav-link-component: Prefer Link Component for Navigation
## Priority: MEDIUM
## Explanation
Use the `<Link>` component for navigation instead of `useNavigate()` when possible. Links render proper `<a>` tags with valid `href` attributes, enabling right-click → open in new tab, better SEO, and accessibility.
## Bad Example
```tsx
// Using onClick with navigate - loses standard link behavior
function PostCard({ post }: { post: Post }) {
const navigate = useNavigate()
return (
<div
onClick={() => navigate({ to: '/posts/$postId', params: { postId: post.id } })}
className="post-card"
>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</div>
)
}
// Problems:
// - No right-click → open in new tab
// - No cmd/ctrl+click for new tab
// - Not announced as link to screen readers
// - No valid href for SEO
```
## Good Example
```tsx
import { Link } from '@tanstack/react-router'
function PostCard({ post }: { post: Post }) {
return (
<Link
to="/posts/$postId"
params={{ postId: post.id }}
className="post-card"
>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</Link>
)
}
// Benefits:
// - Renders <a href="/posts/123">
// - Right-click menu works
// - Cmd/Ctrl+click opens new tab
// - Screen readers announce as link
// - Preloading works on hover
```
## Good Example: With Search Params
```tsx
function FilteredLink() {
return (
<Link
to="/products"
search={{ category: 'electronics', sort: 'price' }}
>
View Electronics
</Link>
)
}
// Preserving existing search params
function SortLink({ sort }: { sort: 'asc' | 'desc' }) {
return (
<Link
to="." // Current route
search={(prev) => ({ ...prev, sort })}
>
Sort {sort === 'asc' ? 'Ascending' : 'Descending'}
</Link>
)
}
```
## Good Example: With Active States
```tsx
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Link
to={to}
activeProps={{
className: 'nav-link-active',
'aria-current': 'page',
}}
inactiveProps={{
className: 'nav-link',
}}
activeOptions={{
exact: true, // Only active on exact match
}}
>
{children}
</Link>
)
}
// Or use render props for more control
function CustomNavLink({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Link to={to}>
{({ isActive }) => (
<span className={isActive ? 'text-blue-600 font-bold' : 'text-gray-600'}>
{children}
{isActive && <CheckIcon className="ml-2" />}
</span>
)}
</Link>
)
}
```
## Good Example: With Preloading
```tsx
function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<Link
to="/posts/$postId"
params={{ postId: post.id }}
preload="intent" // Preload on hover/focus
preloadDelay={100} // Wait 100ms before preloading
>
{post.title}
</Link>
</li>
))}
</ul>
)
}
```
## When to Use useNavigate Instead
```tsx
// 1. After form submission
const createPost = useMutation({
mutationFn: submitPost,
onSuccess: (data) => {
navigate({ to: '/posts/$postId', params: { postId: data.id } })
},
})
// 2. After authentication
async function handleLogin(credentials: Credentials) {
await login(credentials)
navigate({ to: '/dashboard' })
}
// 3. Programmatic redirects
useEffect(() => {
if (!isAuthenticated) {
navigate({ to: '/login', search: { redirect: location.pathname } })
}
}, [isAuthenticated])
```
## Context
- `<Link>` renders actual `<a>` tags with proper `href`
- Supports all standard link behaviors (middle-click, cmd+click, etc.)
- Enables preloading on hover/focus
- Better for SEO - crawlers can follow links
- Reserve `useNavigate` for side effects and programmatic navigation
- Use `<Navigate>` component for immediate redirects on render

View File

@@ -0,0 +1,197 @@
# nav-route-masks: Use Route Masks for Modal URLs
## Priority: LOW
## Explanation
Route masks let you display one URL while internally routing to another. This is useful for modals, sheets, and overlays where you want a shareable URL that shows the modal, but navigating there directly should show the full page.
## Bad Example
```tsx
// Modal without proper URL handling
function PostList() {
const [selectedPost, setSelectedPost] = useState<string | null>(null)
return (
<div>
{posts.map(post => (
<div key={post.id} onClick={() => setSelectedPost(post.id)}>
{post.title}
</div>
))}
{selectedPost && (
<Modal onClose={() => setSelectedPost(null)}>
<PostDetail postId={selectedPost} />
</Modal>
)}
</div>
)
}
// Problems:
// - URL doesn't change when modal opens
// - Can't share link to modal
// - Back button doesn't close modal
// - Refresh loses modal state
```
## Good Example: Route Masks for Modal
```tsx
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
component: PostList,
})
function PostList() {
const posts = usePosts()
return (
<div>
{posts.map(post => (
<Link
key={post.id}
to="/posts/$postId"
params={{ postId: post.id }}
mask={{
to: '/posts',
// URL shows /posts but routes to /posts/$postId
}}
>
{post.title}
</Link>
))}
<Outlet /> {/* Modal renders here */}
</div>
)
}
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
component: PostModal,
})
function PostModal() {
const { postId } = Route.useParams()
const navigate = useNavigate()
return (
<Modal onClose={() => navigate({ to: '/posts' })}>
<PostDetail postId={postId} />
</Modal>
)
}
// User clicks post:
// - URL stays /posts (masked)
// - PostModal renders
// - Share link goes to /posts/$postId (real URL)
// - Direct navigation to /posts/$postId shows full page (no mask)
```
## Good Example: With Search Params
```tsx
function PostList() {
return (
<div>
{posts.map(post => (
<Link
key={post.id}
to="/posts/$postId"
params={{ postId: post.id }}
mask={{
to: '/posts',
search: { modal: post.id }, // /posts?modal=123
}}
>
{post.title}
</Link>
))}
</div>
)
}
```
## Good Example: Programmatic Navigation with Mask
```tsx
function PostCard({ post }: { post: Post }) {
const navigate = useNavigate()
const openInModal = () => {
navigate({
to: '/posts/$postId',
params: { postId: post.id },
mask: {
to: '/posts',
},
})
}
const openFullPage = () => {
navigate({
to: '/posts/$postId',
params: { postId: post.id },
// No mask - shows real URL
})
}
return (
<div>
<h3>{post.title}</h3>
<button onClick={openInModal}>Quick View</button>
<button onClick={openFullPage}>Full Page</button>
</div>
)
}
```
## Good Example: Unmask on Interaction
```tsx
function PostModal() {
const { postId } = Route.useParams()
const navigate = useNavigate()
const expandToFullPage = () => {
// Navigate to real URL, removing mask
navigate({
to: '/posts/$postId',
params: { postId },
// No mask = real URL
replace: true, // Replace history entry
})
}
return (
<Modal>
<PostDetail postId={postId} />
<button onClick={expandToFullPage}>
Expand to full page
</button>
</Modal>
)
}
```
## Route Mask Behavior
| Scenario | URL Shown | Actual Route |
|----------|-----------|--------------|
| Click masked link | Masked URL | Real route |
| Share/copy URL | Real URL | Real route |
| Direct navigation | Real URL | Real route |
| Browser refresh | Depends on URL in bar | Matches URL |
| Back button | Previous URL | Previous route |
## Context
- Masks are client-side only - shared URLs are the real route
- Direct navigation to real URL bypasses mask (shows full page)
- Back button navigates through history correctly
- Use for modals, side panels, quick views
- Masks can include different search params
- Consider UX: users expect shared URLs to work

View File

@@ -0,0 +1,133 @@
# org-virtual-routes: Understand Virtual File Routes
## Priority: LOW
## Explanation
Virtual routes are automatically generated placeholder routes in the route tree when you have a `.lazy.tsx` file without a corresponding main route file. They provide the minimal configuration needed to anchor lazy-loaded components.
## Bad Example
```tsx
// Creating unnecessary boilerplate main route files
// routes/settings.tsx - Just to have a file
export const Route = createFileRoute('/settings')({
// Empty - no loader, no beforeLoad, nothing
})
// routes/settings.lazy.tsx - Actual component
export const Route = createLazyFileRoute('/settings')({
component: SettingsPage,
})
// The main file is unnecessary boilerplate
```
## Good Example: Let Virtual Routes Handle It
```tsx
// Delete routes/settings.tsx entirely!
// routes/settings.lazy.tsx - Only file needed
export const Route = createLazyFileRoute('/settings')({
component: SettingsPage,
pendingComponent: SettingsLoading,
errorComponent: SettingsError,
})
function SettingsPage() {
return <div>Settings Content</div>
}
// TanStack Router auto-generates a virtual route:
// {
// path: '/settings',
// // Minimal config to anchor the lazy file
// }
```
## Good Example: When You DO Need Main Route File
```tsx
// routes/dashboard.tsx - Need this for loader/beforeLoad
export const Route = createFileRoute('/dashboard')({
beforeLoad: async ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' })
}
},
loader: async ({ context: { queryClient } }) => {
await queryClient.ensureQueryData(dashboardQueries.stats())
},
// Component is in lazy file
})
// routes/dashboard.lazy.tsx
export const Route = createLazyFileRoute('/dashboard')({
component: DashboardPage,
pendingComponent: DashboardSkeleton,
})
// Main file IS needed here because we have loader/beforeLoad
```
## Decision Guide
| Route Has... | Need Main File? | Use Virtual? |
|--------------|-----------------|--------------|
| Only component | No | Yes |
| loader | Yes | No |
| beforeLoad | Yes | No |
| validateSearch | Yes | No |
| loaderDeps | Yes | No |
| Just pendingComponent/errorComponent | No | Yes |
## Good Example: File Structure with Virtual Routes
```
routes/
├── __root.tsx # Always needed
├── index.tsx # Has loader
├── about.lazy.tsx # Virtual route (no main file)
├── contact.lazy.tsx # Virtual route (no main file)
├── dashboard.tsx # Has beforeLoad (auth)
├── dashboard.lazy.tsx # Component
├── posts.tsx # Has loader
├── posts.lazy.tsx # Component
├── posts/
│ ├── $postId.tsx # Has loader
│ └── $postId.lazy.tsx # Component
└── settings/
├── index.lazy.tsx # Virtual route
├── profile.lazy.tsx # Virtual route
└── security.tsx # Has beforeLoad (requires re-auth)
```
## Good Example: Generated Route Tree
```tsx
// routeTree.gen.ts (auto-generated)
import { Route as rootRoute } from './routes/__root'
import { Route as aboutLazyRoute } from './routes/about.lazy' // Virtual parent
export const routeTree = rootRoute.addChildren([
// Virtual route created for about.lazy.tsx
createRoute({
path: '/about',
getParentRoute: () => rootRoute,
}).lazy(() => import('./routes/about.lazy').then(m => m.Route)),
// Regular route with explicit main file
dashboardRoute.addChildren([...]),
])
```
## Context
- Virtual routes reduce boilerplate for simple pages
- Only works with file-based routing
- Auto-generated in `routeTree.gen.ts`
- Main route file needed for any "critical path" config
- Critical: loader, beforeLoad, validateSearch, loaderDeps, context
- Non-critical (can be in lazy): component, pendingComponent, errorComponent
- Check generated route tree to verify virtual routes

View File

@@ -0,0 +1,133 @@
# preload-intent: Enable Intent-Based Preloading
## Priority: MEDIUM
## Explanation
Configure `defaultPreload: 'intent'` to preload routes when users hover or focus links. This loads data before the click, making navigation feel instant.
## Bad Example
```tsx
// No preloading configured - data loads after click
const router = createRouter({
routeTree,
// No defaultPreload - user waits after every navigation
})
// Each navigation shows loading state
function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<Link to="/posts/$postId" params={{ postId: post.id }}>
{post.title}
</Link>
{/* Click → wait for data → render */}
</li>
))}
</ul>
)
}
```
## Good Example
```tsx
// router.tsx - Enable preloading by default
const router = createRouter({
routeTree,
defaultPreload: 'intent', // Preload on hover/focus
defaultPreloadDelay: 50, // Wait 50ms before starting
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
// Links automatically preload on hover
function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<Link to="/posts/$postId" params={{ postId: post.id }}>
{post.title}
</Link>
{/* Hover → preload starts → click → instant navigation */}
</li>
))}
</ul>
)
}
```
## Preload Options
```tsx
// Router-level defaults
const router = createRouter({
routeTree,
defaultPreload: 'intent', // 'intent' | 'render' | 'viewport' | false
defaultPreloadDelay: 50, // ms before preload starts
defaultPreloadStaleTime: 30000, // 30s - how long preloaded data stays fresh
})
// Link-level overrides
<Link
to="/heavy-page"
preload={false} // Disable for this specific link
>
Heavy Page
</Link>
<Link
to="/critical-page"
preload="render" // Preload immediately when Link renders
>
Critical Page
</Link>
```
## Preload Strategies
| Strategy | Behavior | Use Case |
|----------|----------|----------|
| `'intent'` | Preload on hover/focus | Default for most links |
| `'render'` | Preload when Link mounts | Critical next pages |
| `'viewport'` | Preload when Link enters viewport | Below-fold content |
| `false` | No preloading | Heavy, rarely-visited pages |
## Good Example: With TanStack Query Integration
```tsx
// When using TanStack Query, disable router cache
const router = createRouter({
routeTree,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0, // Let TanStack Query manage cache
context: {
queryClient,
},
})
// Route loader uses TanStack Query
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// ensureQueryData respects TanStack Query's staleTime
await queryClient.ensureQueryData(postQueries.detail(params.postId))
},
})
```
## Context
- Preloading loads route code AND executes loaders
- `preloadDelay` prevents excessive requests on quick mouse movements
- Preloaded data is garbage collected after `preloadStaleTime`
- Works with both router caching and external caching (TanStack Query)
- Mobile: Consider `'viewport'` since hover isn't available
- Monitor network tab to verify preloading works correctly

View File

@@ -0,0 +1,163 @@
# router-default-options: Configure Router Default Options
## Priority: HIGH
## Explanation
TanStack Router's `createRouter` accepts several default options that apply globally. Configure these for consistent behavior across your application including error handling, scroll restoration, and performance optimizations.
## Bad Example
```tsx
// Minimal router - missing useful defaults
const router = createRouter({
routeTree,
context: { queryClient },
})
// Each route must handle its own errors
// No scroll restoration on navigation
// No preloading configured
```
## Good Example: Full Configuration
```tsx
import { QueryClient } from '@tanstack/react-query'
import { createRouter } from '@tanstack/react-router'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { routeTree } from './routeTree.gen'
import { DefaultCatchBoundary } from '@/components/DefaultCatchBoundary'
import { DefaultNotFound } from '@/components/DefaultNotFound'
export function getRouter() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 2,
},
},
})
const router = createRouter({
routeTree,
context: { queryClient, user: null },
// Preloading
defaultPreload: 'intent', // Preload on hover/focus
defaultPreloadStaleTime: 0, // Let Query manage freshness
// Error handling
defaultErrorComponent: DefaultCatchBoundary,
defaultNotFoundComponent: DefaultNotFound,
// UX
scrollRestoration: true, // Restore scroll on back/forward
// Performance
defaultStructuralSharing: true, // Optimize re-renders
})
setupRouterSsrQueryIntegration({
router,
queryClient,
})
return router
}
```
## Good Example: DefaultCatchBoundary Component
```tsx
// components/DefaultCatchBoundary.tsx
import { ErrorComponent, useRouter } from '@tanstack/react-router'
export function DefaultCatchBoundary({ error }: { error: Error }) {
const router = useRouter()
return (
<div className="error-container">
<h1>Something went wrong</h1>
<ErrorComponent error={error} />
<button onClick={() => router.invalidate()}>
Try again
</button>
</div>
)
}
```
## Good Example: DefaultNotFound Component
```tsx
// components/DefaultNotFound.tsx
import { Link } from '@tanstack/react-router'
export function DefaultNotFound() {
return (
<div className="not-found-container">
<h1>404 - Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<Link to="/">Go home</Link>
</div>
)
}
```
## Router Options Reference
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `defaultPreload` | `false \| 'intent' \| 'render' \| 'viewport'` | `false` | When to preload routes |
| `defaultPreloadStaleTime` | `number` | `30000` | How long preloaded data stays fresh (ms) |
| `defaultErrorComponent` | `Component` | Built-in | Global error boundary |
| `defaultNotFoundComponent` | `Component` | Built-in | Global 404 page |
| `scrollRestoration` | `boolean` | `false` | Restore scroll on navigation |
| `defaultStructuralSharing` | `boolean` | `true` | Optimize loader data re-renders |
## Good Example: Route-Level Overrides
```tsx
// Routes can override defaults
export const Route = createFileRoute('/admin')({
// Custom error handling for admin section
errorComponent: AdminErrorBoundary,
notFoundComponent: AdminNotFound,
// Disable preload for sensitive routes
preload: false,
})
```
## Good Example: With Pending Component
```tsx
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
defaultErrorComponent: DefaultCatchBoundary,
defaultNotFoundComponent: DefaultNotFound,
scrollRestoration: true,
// Show during route transitions
defaultPendingComponent: () => (
<div className="loading-bar" />
),
defaultPendingMinMs: 200, // Min time to show pending UI
defaultPendingMs: 1000, // Delay before showing pending UI
})
```
## Context
- Set `defaultPreloadStaleTime: 0` when using TanStack Query
- `scrollRestoration: true` improves back/forward navigation UX
- `defaultStructuralSharing` prevents unnecessary re-renders
- Route-level options override router defaults
- Error/NotFound components receive route context
- Pending components help with perceived performance

View File

@@ -0,0 +1,198 @@
# search-custom-serializer: Configure Custom Search Param Serializers
## Priority: LOW
## Explanation
By default, TanStack Router serializes search params as JSON. For cleaner URLs or compatibility with external systems, you can provide custom serializers using libraries like `qs`, `query-string`, or your own implementation.
## Bad Example
```tsx
// Default JSON serialization creates ugly URLs
// URL: /products?filters=%7B%22category%22%3A%22electronics%22%2C%22inStock%22%3Atrue%7D
// Or manually parsing/serializing inconsistently
function ProductList() {
const searchParams = new URLSearchParams(window.location.search)
const filters = JSON.parse(searchParams.get('filters') || '{}')
// Inconsistent with router's handling
}
```
## Good Example: Using JSURL for Compact URLs
```tsx
import { createRouter } from '@tanstack/react-router'
import JSURL from 'jsurl2'
const router = createRouter({
routeTree,
search: {
// Custom serializer for compact, URL-safe encoding
serialize: (search) => JSURL.stringify(search),
parse: (searchString) => JSURL.parse(searchString) || {},
},
})
// URL: /products?~(category~'electronics~inStock~true)
// Much shorter than JSON!
```
## Good Example: Using query-string for Flat Params
```tsx
import { createRouter } from '@tanstack/react-router'
import queryString from 'query-string'
const router = createRouter({
routeTree,
search: {
serialize: (search) =>
queryString.stringify(search, {
arrayFormat: 'bracket',
skipNull: true,
}),
parse: (searchString) =>
queryString.parse(searchString, {
arrayFormat: 'bracket',
parseBooleans: true,
parseNumbers: true,
}),
},
})
// URL: /products?category=electronics&inStock=true&tags[]=sale&tags[]=new
// Traditional query string format
```
## Good Example: Using qs for Nested Objects
```tsx
import { createRouter } from '@tanstack/react-router'
import qs from 'qs'
const router = createRouter({
routeTree,
search: {
serialize: (search) =>
qs.stringify(search, {
encodeValuesOnly: true,
arrayFormat: 'brackets',
}),
parse: (searchString) =>
qs.parse(searchString, {
ignoreQueryPrefix: true,
decoder(value) {
// Parse booleans and numbers
if (value === 'true') return true
if (value === 'false') return false
if (/^-?\d+$/.test(value)) return parseInt(value, 10)
return value
},
}),
},
})
// URL: /products?filters[category]=electronics&filters[price][min]=100&filters[price][max]=500
```
## Good Example: Base64 for Complex State
```tsx
import { createRouter } from '@tanstack/react-router'
const router = createRouter({
routeTree,
search: {
serialize: (search) => {
if (Object.keys(search).length === 0) return ''
const json = JSON.stringify(search)
return btoa(json) // Base64 encode
},
parse: (searchString) => {
if (!searchString) return {}
try {
return JSON.parse(atob(searchString)) // Base64 decode
} catch {
return {}
}
},
},
})
// URL: /products?eyJjYXRlZ29yeSI6ImVsZWN0cm9uaWNzIn0
// Opaque but compact
```
## Good Example: Hybrid Approach
```tsx
// Some params as regular query, complex ones as JSON
import { createRouter } from '@tanstack/react-router'
const router = createRouter({
routeTree,
search: {
serialize: (search) => {
const { filters, ...simple } = search
const params = new URLSearchParams()
// Simple values as regular params
Object.entries(simple).forEach(([key, value]) => {
if (value !== undefined) {
params.set(key, String(value))
}
})
// Complex filters as JSON
if (filters && Object.keys(filters).length > 0) {
params.set('filters', JSON.stringify(filters))
}
return params.toString()
},
parse: (searchString) => {
const params = new URLSearchParams(searchString)
const result: Record<string, unknown> = {}
params.forEach((value, key) => {
if (key === 'filters') {
result.filters = JSON.parse(value)
} else if (value === 'true') {
result[key] = true
} else if (value === 'false') {
result[key] = false
} else if (/^-?\d+$/.test(value)) {
result[key] = parseInt(value, 10)
} else {
result[key] = value
}
})
return result
},
},
})
// URL: /products?page=1&sort=price&filters={"category":"electronics","inStock":true}
```
## Serializer Comparison
| Library | URL Style | Best For |
|---------|-----------|----------|
| Default (JSON) | `?data=%7B...%7D` | TypeScript safety |
| jsurl2 | `?~(key~'value)` | Compact, readable |
| query-string | `?key=value&arr[]=1` | Traditional APIs |
| qs | `?obj[nested]=value` | Deep nesting |
| Base64 | `?eyJrZXkiOiJ2YWx1ZSJ9` | Opaque, compact |
## Context
- Custom serializers apply globally to all routes
- Route-level `validateSearch` still works after parsing
- Consider URL length limits (~2000 chars for safe cross-browser)
- SEO: Search engines may not understand custom formats
- Bookmarkability: Users can't easily modify opaque URLs
- Debugging: JSON is easier to read in browser devtools

View File

@@ -0,0 +1,158 @@
# search-validation: Always Validate Search Params
## Priority: HIGH
## Explanation
Search params come from the URL - user-controlled input that must be validated. Use `validateSearch` to parse, validate, and provide defaults. This ensures type safety and prevents runtime errors from malformed URLs.
## Bad Example
```tsx
// No validation - trusting URL input directly
export const Route = createFileRoute('/products')({
component: ProductsPage,
})
function ProductsPage() {
// Accessing raw search params without validation
const searchParams = new URLSearchParams(window.location.search)
const page = parseInt(searchParams.get('page') || '1') // Could be NaN
const sort = searchParams.get('sort') as 'asc' | 'desc' // Could be anything
// Runtime errors possible if URL is malformed
return <ProductList page={page} sort={sort} />
}
```
## Good Example: Manual Validation
```tsx
export const Route = createFileRoute('/products')({
validateSearch: (search: Record<string, unknown>) => {
return {
page: Number(search.page) || 1,
sort: search.sort === 'desc' ? 'desc' : 'asc',
category: typeof search.category === 'string' ? search.category : undefined,
minPrice: Number(search.minPrice) || undefined,
maxPrice: Number(search.maxPrice) || undefined,
}
},
component: ProductsPage,
})
function ProductsPage() {
// Fully typed, validated search params
const { page, sort, category, minPrice, maxPrice } = Route.useSearch()
// page: number (default 1)
// sort: 'asc' | 'desc' (default 'asc')
// category: string | undefined
}
```
## Good Example: With Zod
```tsx
import { z } from 'zod'
const productSearchSchema = z.object({
page: z.number().min(1).catch(1),
limit: z.number().min(1).max(100).catch(20),
sort: z.enum(['name', 'price', 'date']).catch('name'),
order: z.enum(['asc', 'desc']).catch('asc'),
category: z.string().optional(),
search: z.string().optional(),
minPrice: z.number().min(0).optional(),
maxPrice: z.number().min(0).optional(),
})
type ProductSearch = z.infer<typeof productSearchSchema>
export const Route = createFileRoute('/products')({
validateSearch: (search) => productSearchSchema.parse(search),
component: ProductsPage,
})
function ProductsPage() {
const search = Route.useSearch()
// search: ProductSearch - fully typed with defaults
return (
<ProductList
page={search.page}
limit={search.limit}
sort={search.sort}
order={search.order}
filters={{
category: search.category,
search: search.search,
priceRange: search.minPrice && search.maxPrice
? [search.minPrice, search.maxPrice]
: undefined,
}}
/>
)
}
```
## Good Example: With Valibot
```tsx
import * as v from 'valibot'
import { valibotSearchValidator } from '@tanstack/router-valibot-adapter'
const searchSchema = v.object({
page: v.fallback(v.number(), 1),
query: v.fallback(v.string(), ''),
filters: v.fallback(
v.array(v.string()),
[]
),
})
export const Route = createFileRoute('/search')({
validateSearch: valibotSearchValidator(searchSchema),
component: SearchPage,
})
```
## Updating Search Params
```tsx
function ProductFilters() {
const navigate = useNavigate()
const search = Route.useSearch()
const updateFilters = (newFilters: Partial<ProductSearch>) => {
navigate({
to: '.', // Current route
search: (prev) => ({
...prev,
...newFilters,
page: 1, // Reset to page 1 when filters change
}),
})
}
return (
<div>
<select
value={search.sort}
onChange={(e) => updateFilters({ sort: e.target.value as ProductSearch['sort'] })}
>
<option value="name">Name</option>
<option value="price">Price</option>
<option value="date">Date</option>
</select>
</div>
)
}
```
## Context
- Search params are user input - never trust them unvalidated
- Use `.catch()` in Zod or `fallback()` in Valibot for graceful defaults
- Validation runs on every navigation - keep it fast
- Search params are inherited by child routes
- Use `search` updater function to preserve other params

View File

@@ -0,0 +1,148 @@
# split-lazy-routes: Use .lazy.tsx for Code Splitting
## Priority: MEDIUM
## Explanation
Split route components into `.lazy.tsx` files to reduce initial bundle size. The main route file keeps critical configuration (path, loaders, search validation), while lazy files contain components that load on-demand.
## Bad Example
```tsx
// routes/dashboard.tsx - Everything in one file
import { createFileRoute } from '@tanstack/react-router'
import { HeavyChartLibrary } from 'heavy-chart-library'
import { ComplexDataGrid } from 'complex-data-grid'
import { AnalyticsWidgets } from './components/AnalyticsWidgets'
export const Route = createFileRoute('/dashboard')({
loader: async ({ context }) => {
return context.queryClient.ensureQueryData(dashboardQueries.stats())
},
component: DashboardPage, // Entire component in main bundle
})
function DashboardPage() {
// Heavy components loaded even if user never visits dashboard
return (
<div>
<HeavyChartLibrary data={useLoaderData()} />
<ComplexDataGrid />
<AnalyticsWidgets />
</div>
)
}
```
## Good Example
```tsx
// routes/dashboard.tsx - Only critical config
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/dashboard')({
loader: async ({ context }) => {
return context.queryClient.ensureQueryData(dashboardQueries.stats())
},
// No component - it's in the lazy file
})
// routes/dashboard.lazy.tsx - Lazy-loaded component
import { createLazyFileRoute } from '@tanstack/react-router'
import { HeavyChartLibrary } from 'heavy-chart-library'
import { ComplexDataGrid } from 'complex-data-grid'
import { AnalyticsWidgets } from './components/AnalyticsWidgets'
export const Route = createLazyFileRoute('/dashboard')({
component: DashboardPage,
pendingComponent: DashboardSkeleton,
errorComponent: DashboardError,
})
function DashboardPage() {
const data = Route.useLoaderData()
return (
<div>
<HeavyChartLibrary data={data} />
<ComplexDataGrid />
<AnalyticsWidgets />
</div>
)
}
function DashboardSkeleton() {
return <div className="dashboard-skeleton">Loading dashboard...</div>
}
function DashboardError({ error }: { error: Error }) {
return <div>Failed to load dashboard: {error.message}</div>
}
```
## What Goes Where
```tsx
// Main route file (routes/example.tsx)
// - path configuration (implicit from file location)
// - validateSearch
// - beforeLoad (auth checks, redirects)
// - loader (data fetching)
// - loaderDeps
// - context manipulation
// - Static route data
// Lazy file (routes/example.lazy.tsx)
// - component
// - pendingComponent
// - errorComponent
// - notFoundComponent
```
## Using getRouteApi in Lazy Components
```tsx
// routes/posts/$postId.lazy.tsx
import { createLazyFileRoute, getRouteApi } from '@tanstack/react-router'
const route = getRouteApi('/posts/$postId')
export const Route = createLazyFileRoute('/posts/$postId')({
component: PostPage,
})
function PostPage() {
// Type-safe access without importing main route file
const { postId } = route.useParams()
const data = route.useLoaderData()
return <article>{/* ... */}</article>
}
```
## Automatic Code Splitting
```tsx
// vite.config.ts - Enable automatic splitting
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
TanStackRouterVite({
autoCodeSplitting: true, // Automatically splits all route components
}),
react(),
],
})
// With autoCodeSplitting, you don't need .lazy.tsx files
// The plugin handles the splitting automatically
```
## Context
- Lazy loading reduces initial bundle size significantly
- Loaders are NOT lazy - they need to run before rendering
- `createLazyFileRoute` only accepts component-related options
- Use `getRouteApi()` for type-safe hook access in lazy files
- Consider `autoCodeSplitting: true` for simpler setup
- Virtual routes auto-generate when only .lazy.tsx exists

View File

@@ -0,0 +1,113 @@
# ts-register-router: Register Router Type for Global Inference
## Priority: CRITICAL
## Explanation
Register your router instance with TypeScript's module declaration to enable type inference across your entire application. Without registration, hooks like `useNavigate`, `useParams`, and `useSearch` won't know your route structure.
## Bad Example
```tsx
// router.tsx - Missing type registration
import { createRouter, createRootRoute } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export const router = createRouter({ routeTree })
// components/Navigation.tsx
import { useNavigate } from '@tanstack/react-router'
function Navigation() {
const navigate = useNavigate()
// TypeScript doesn't know valid routes - no autocomplete or type checking
navigate({ to: '/posts/$postId' }) // No error even if route doesn't exist
}
```
## Good Example
```tsx
// router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export const router = createRouter({ routeTree })
// Register the router instance for type inference
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
// components/Navigation.tsx
import { useNavigate } from '@tanstack/react-router'
function Navigation() {
const navigate = useNavigate()
// Full type safety - TypeScript knows all valid routes
navigate({ to: '/posts/$postId', params: { postId: '123' } })
// Type error if route doesn't exist
navigate({ to: '/invalid-route' }) // Error: Type '"/invalid-route"' is not assignable...
// Autocomplete for params
navigate({
to: '/users/$userId/posts/$postId',
params: { userId: '1', postId: '2' }, // Both required
})
}
```
## Benefits of Registration
```tsx
// After registration, all these get full type inference:
// 1. Navigation
const navigate = useNavigate()
navigate({ to: '/posts/$postId', params: { postId: '123' } })
// 2. Link component
<Link to="/posts/$postId" params={{ postId: '123' }}>View Post</Link>
// 3. useParams hook
const { postId } = useParams({ from: '/posts/$postId' }) // postId: string
// 4. useSearch hook
const search = useSearch({ from: '/posts' }) // Knows search param types
// 5. useLoaderData hook
const data = useLoaderData({ from: '/posts/$postId' }) // Knows loader return type
```
## File-Based Routing Setup
```tsx
// With file-based routing, routeTree is auto-generated
// router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen' // Generated file
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
```
## Context
- Must be done once, typically in your router configuration file
- Enables IDE autocomplete for routes, params, and search params
- Catches invalid routes at compile time
- Works with both file-based and code-based routing
- Required for full TypeScript benefits of TanStack Router

View File

@@ -0,0 +1,130 @@
# ts-use-from-param: Use `from` Parameter for Type Narrowing
## Priority: CRITICAL
## Explanation
When using hooks like `useParams`, `useSearch`, or `useLoaderData`, provide the `from` parameter to get exact types for that route. Without it, TypeScript returns a union of all possible types across all routes.
## Bad Example
```tsx
// Without 'from' - TypeScript doesn't know which route's types to use
function PostDetail() {
// params could be from ANY route - types are unioned
const params = useParams()
// params: { postId?: string; userId?: string; categoryId?: string; ... }
// TypeScript can't guarantee postId exists
console.log(params.postId) // postId: string | undefined
}
// Similarly for search params
function SearchResults() {
const search = useSearch()
// search: union of ALL routes' search params
}
```
## Good Example
```tsx
// With 'from' - exact types for this specific route
function PostDetail() {
const params = useParams({ from: '/posts/$postId' })
// params: { postId: string } - exactly what this route provides
console.log(params.postId) // postId: string (guaranteed)
}
// Full path matching
function UserPost() {
const params = useParams({ from: '/users/$userId/posts/$postId' })
// params: { userId: string; postId: string }
}
// Search params with type narrowing
function SearchResults() {
const search = useSearch({ from: '/search' })
// search: exactly the validated search params for /search route
}
// Loader data with type inference
function PostPage() {
const { post, comments } = useLoaderData({ from: '/posts/$postId' })
// Exact types from your loader function
}
```
## Using Route.fullPath for Type Safety
```tsx
// routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
return { post }
},
component: PostComponent,
})
function PostComponent() {
// Use Route.fullPath for guaranteed type matching
const params = useParams({ from: Route.fullPath })
const { post } = useLoaderData({ from: Route.fullPath })
// Or use route-specific helper (preferred in same file)
const { postId } = Route.useParams()
const data = Route.useLoaderData()
}
```
## Using getRouteApi for Code-Split Components
```tsx
// components/PostDetail.tsx (separate file from route)
import { getRouteApi } from '@tanstack/react-router'
// Get type-safe access without importing the route
const postRoute = getRouteApi('/posts/$postId')
export function PostDetail() {
const params = postRoute.useParams()
// params: { postId: string }
const data = postRoute.useLoaderData()
// data: exact loader return type
const search = postRoute.useSearch()
// search: exact search param types
}
```
## When to Use strict: false
```tsx
// In shared components that work across multiple routes
function Breadcrumbs() {
// strict: false returns union types but allows component reuse
const params = useParams({ strict: false })
const location = useLocation()
// params may or may not have certain values
return (
<nav>
{params.userId && <span>User: {params.userId}</span>}
{params.postId && <span>Post: {params.postId}</span>}
</nav>
)
}
```
## Context
- Always use `from` in route-specific components for exact types
- Use `Route.useParams()` / `Route.useLoaderData()` within route files
- Use `getRouteApi()` in components split from route files
- Use `strict: false` only in truly generic, cross-route components
- The `from` path must match exactly (including params like `$postId`)