plugins/dev/skills/frontend/tanstack-router/SKILL.md
TanStack Router patterns for type-safe, file-based routing. Covers installation, route configuration, typed params/search, layouts, and navigation. Use when setting up routes, implementing navigation, or configuring route loaders.
npx skillsauth add madappgang/claude-code tanstack-routerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Type-safe, file-based routing for React applications with TanStack Router.
pnpm add @tanstack/react-router
pnpm add -D @tanstack/router-plugin
// vite.config.ts
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react(),
TanStackRouterVite(), // Generates route tree
],
})
TanStack Router v1.x+ (2025) introduces automatic code splitting that separates critical route configuration from non-critical components.
// vite.config.ts
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react(),
TanStackRouterVite({
autoCodeSplitting: true, // NEW: Enable automatic splitting
}),
],
})
What Gets Split:
| Critical (Always Loaded) | Non-Critical (Lazy Loaded) | |--------------------------|---------------------------| | Route configuration | Component | | Loaders | Error component | | Search params validation | Pending component | | beforeLoad | Not-found component |
Benefits:
.lazy.tsx files needed)When to Use:
// src/main.tsx
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({ routeTree })
// Register router for type safety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
)
src/routes/
├── __root.tsx # Root layout (Outlet, providers)
├── index.tsx # "/" route
├── about.tsx # "/about" route
├── users/
│ ├── index.tsx # "/users" route
│ └── $userId.tsx # "/users/:userId" route (dynamic)
└── posts/
├── $postId/
│ ├── index.tsx # "/posts/:postId" route
│ └── edit.tsx # "/posts/:postId/edit" route
└── index.tsx # "/posts" route
Naming Conventions:
__root.tsx - Root layout (contains <Outlet />)index.tsx - Index route for that path$param.tsx - Dynamic parameter (e.g., $userId → :userId)_layout.tsx - Layout route (no URL segment)route.lazy.tsx - Lazy-loaded routeVirtual file routes allow the router to auto-generate route anchors without physical files:
src/routes/
├── __root.tsx # Root layout
├── index.tsx # "/" (physical file)
├── about.lazy.tsx # "/about" (virtual route, lazy only)
└── users/
├── index.tsx # "/users" (physical)
└── $userId.lazy.tsx # "/users/:userId" (virtual, lazy only)
Key Insight: If you only need a component (no loader, no search validation), you can delete the base route file. The router auto-generates a virtual anchor for .lazy.tsx files.
Example - Minimal About Page:
// src/routes/about.lazy.tsx
// No about.tsx needed! Router creates virtual anchor
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/about')({
component: () => <div>About Us</div>,
})
When Virtual Routes Make Sense:
// src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
export const Route = createRootRoute({
component: () => (
<>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/users">Users</Link>
</nav>
<main>
<Outlet /> {/* Child routes render here */}
</main>
<TanStackRouterDevtools /> {/* Auto-hides in production */}
</>
),
})
// src/routes/about.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
component: AboutComponent,
})
function AboutComponent() {
return <div>About Page</div>
}
// src/routes/users/$userId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/users/$userId')({
component: UserComponent,
})
function UserComponent() {
const { userId } = Route.useParams() // Fully typed!
return <div>User ID: {userId}</div>
}
// src/routes/users/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
const userSearchSchema = z.object({
page: z.number().default(1),
filter: z.enum(['active', 'inactive', 'all']).default('all'),
search: z.string().optional(),
})
export const Route = createFileRoute('/users/')({
validateSearch: userSearchSchema,
component: UsersComponent,
})
function UsersComponent() {
const { page, filter, search } = Route.useSearch() // Fully typed!
return (
<div>
<p>Page: {page}</p>
<p>Filter: {filter}</p>
{search && <p>Search: {search}</p>}
</div>
)
}
import { Link } from '@tanstack/react-router'
// Basic navigation
<Link to="/about">About</Link>
// With params
<Link to="/users/$userId" params={{ userId: '123' }}>
View User
</Link>
// With search params
<Link
to="/users"
search={{ page: 2, filter: 'active' }}
>
Users Page 2
</Link>
// With state
<Link to="/details" state={{ from: 'home' }}>
Details
</Link>
// Active link styling
<Link
to="/about"
activeProps={{ className: 'text-blue-600 font-bold' }}
inactiveProps={{ className: 'text-gray-600' }}
>
About
</Link>
import { useNavigate } from '@tanstack/react-router'
function MyComponent() {
const navigate = useNavigate()
const handleClick = () => {
// Navigate to route
navigate({ to: '/users' })
// With params
navigate({ to: '/users/$userId', params: { userId: '123' } })
// With search
navigate({ to: '/users', search: { page: 2 } })
// Replace history
navigate({ to: '/login', replace: true })
// Go back
navigate({ to: '..' }) // Relative navigation
}
return <button onClick={handleClick}>Navigate</button>
}
Basic Loader:
// src/routes/users/$userId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/users/$userId')({
loader: async ({ params }) => {
const user = await fetchUser(params.userId)
return { user }
},
component: UserComponent,
})
function UserComponent() {
const { user } = Route.useLoaderData() // Fully typed!
return <div>{user.name}</div>
}
With TanStack Query Integration (see tanstack-query skill for details):
import { queryClient } from '@/app/queryClient'
import { userQueryOptions } from '@/features/users/queries'
export const Route = createFileRoute('/users/$userId')({
loader: ({ params }) =>
queryClient.ensureQueryData(userQueryOptions(params.userId)),
component: UserComponent,
})
.lazy.tsx Pattern)For fine-grained control over code splitting, split routes into critical and lazy files:
Critical Route File (posts.tsx):
// src/routes/posts.tsx - Loaded immediately
import { createFileRoute } from '@tanstack/react-router'
import { postsQueryOptions } from '@/features/posts/queries'
import { queryClient } from '@/app/queryClient'
export const Route = createFileRoute('/posts')({
// Critical: loaders, search validation, beforeLoad
loader: () => queryClient.ensureQueryData(postsQueryOptions()),
validateSearch: (search) => postsSearchSchema.parse(search),
})
Lazy Route File (posts.lazy.tsx):
// src/routes/posts.lazy.tsx - Code-split, loaded on navigation
import { createLazyFileRoute } from '@tanstack/react-router'
import { Posts } from '@/features/posts/components/Posts'
export const Route = createLazyFileRoute('/posts')({
// Non-critical: component, error/pending/notFound
component: Posts,
pendingComponent: () => <PostsSkeleton />,
errorComponent: ({ error }) => <PostsError error={error} />,
})
Lazy-Only Properties:
component - The main route componenterrorComponent - Error boundary UIpendingComponent - Loading/suspense UInotFoundComponent - 404 UI for this routeCritical-Only Properties (NOT in lazy):
loader / loaderDepsbeforeLoadvalidateSearchsearch (search middleware)contextBest Practice: Use automatic code splitting (autoCodeSplitting: true) unless you need specific control over what goes in each file.
getRouteApi)When building components outside the route file, use getRouteApi for type-safe access:
// src/features/posts/components/PostHeader.tsx
import { getRouteApi } from '@tanstack/react-router'
// Get typed route API without importing the route
const routeApi = getRouteApi('/posts/$postId')
export function PostHeader() {
// All these are fully typed!
const { postId } = routeApi.useParams()
const { post } = routeApi.useLoaderData()
const { view } = routeApi.useSearch()
const context = routeApi.useRouteContext()
return (
<header>
<h1>{post.title}</h1>
<span>Viewing: {view}</span>
</header>
)
}
Available Methods:
useParams() - Route parametersuseSearch() - Search/query paramsuseLoaderData() - Data from loaderuseRouteContext() - Route contextuseMatch() - Full route match objectWhen to Use:
Important: The route path string must exactly match the route definition.
Layout Route (_layout.tsx - no URL segment):
// src/routes/_layout.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_layout')({
component: LayoutComponent,
})
function LayoutComponent() {
return (
<div className="dashboard-layout">
<Sidebar />
<div className="content">
<Outlet /> {/* Child routes */}
</div>
</div>
)
}
// Child routes
// src/routes/_layout/dashboard.tsx → "/dashboard"
// src/routes/_layout/settings.tsx → "/settings"
Route groups organize files without affecting URLs using parentheses:
src/routes/
├── (auth)/ # Group (not in URL)
│ ├── login.tsx # "/login"
│ ├── register.tsx # "/register"
│ └── forgot-password.tsx # "/forgot-password"
├── (dashboard)/ # Group (not in URL)
│ ├── _layout.tsx # Shared dashboard layout
│ ├── index.tsx # "/" (or "/dashboard")
│ ├── analytics.tsx # "/analytics"
│ └── settings.tsx # "/settings"
└── __root.tsx
Benefits:
Example - Auth Group Layout:
// src/routes/(auth)/_layout.tsx
export const Route = createFileRoute('/(auth)/_layout')({
component: () => (
<div className="auth-layout">
<Logo />
<Outlet />
</div>
),
})
export const Route = createFileRoute('/users')({
loader: async () => {
const users = await fetchUsers()
return { users }
},
pendingComponent: () => <Spinner />,
errorComponent: ({ error }) => <ErrorMessage>{error.message}</ErrorMessage>,
component: UsersComponent,
})
import { ErrorComponent } from '@tanstack/react-router'
export const Route = createFileRoute('/users')({
loader: async () => {
const users = await fetchUsers()
if (!users) throw new Error('Failed to load users')
return { users }
},
errorComponent: ({ error, reset }) => (
<div>
<h1>Error loading users</h1>
<p>{error.message}</p>
<button onClick={reset}>Try Again</button>
</div>
),
component: UsersComponent,
})
Providing Context:
// src/routes/__root.tsx
export const Route = createRootRoute({
beforeLoad: () => ({
user: getCurrentUser(),
}),
component: RootComponent,
})
// Access in child routes
export const Route = createFileRoute('/dashboard')({
component: function Dashboard() {
const { user } = Route.useRouteContext()
return <div>Welcome, {user.name}</div>
},
})
// src/routes/_authenticated.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context }) => {
if (!context.user) {
throw redirect({ to: '/login' })
}
},
component: Outlet,
})
// Protected routes
// src/routes/_authenticated/dashboard.tsx
// src/routes/_authenticated/profile.tsx
Hover Preload:
<Link
to="/users/$userId"
params={{ userId: '123' }}
preload="intent" // Preload on hover
>
View User
</Link>
Options:
preload="intent" - Preload on hover/focuspreload="render" - Preload when link renderspreload={false} - No preload (default)import { TanStackRouterDevtools } from '@tanstack/router-devtools'
// Add to root layout
<TanStackRouterDevtools position="bottom-right" />
Auto-hides in production builds.
route.lazy.tsx for code splittingCatch-All (Splat) Routes:
v1.x Syntax:
// src/routes/files/$.tsx - Catches all paths under /files/
export const Route = createFileRoute('/files/$')({
component: FileViewer,
})
function FileViewer() {
// Access splat via '_splat' key (v1.x+)
const { _splat } = Route.useParams()
// '/files/docs/readme.md' → _splat = 'docs/readme.md'
return <div>File: {_splat}</div>
}
v2 Migration Note:
In TanStack Router v2 (upcoming), splat routes use _splat key consistently:
params['*'] or params._splat (both work)params._splat (star deprecated)Prepare for v2:
// ✅ Future-proof
const { _splat } = Route.useParams()
// ⚠️ Works in v1, deprecated in v2
const splat = Route.useParams()['*']
404 Not Found Route:
// src/routes/$.tsx
export const Route = createFileRoute('/$')({
component: () => <div>404 Not Found</div>,
})
Optional Params:
// Use search params for optional data
const searchSchema = z.object({
optional: z.string().optional(),
})
Multi-Level Dynamic Routes:
/posts/$postId/comments/$commentId
Insights from large-scale TanStack Router deployments:
Colocate everything a page needs within its route folder:
src/routes/users/
├── $userId/
│ ├── index.tsx # Route definition
│ ├── index.lazy.tsx # Lazy component
│ ├── UserProfile.tsx # Page-specific component
│ └── useUserActions.ts # Page-specific hooks
└── index.tsx
Components/functions belong at the nearest shared ancestor in the hierarchy.
// ✅ Recommended - Router handles loading/error
export const Route = createFileRoute('/users/$userId')({
loader: fetchUser,
pendingComponent: UserSkeleton,
errorComponent: UserError,
component: UserProfile, // Only handles happy path!
})
// ❌ Avoid - Manual loading in component
function UserProfile() {
const { data, isLoading, error } = useUser()
if (isLoading) return <Spinner /> // Router should handle this
if (error) return <Error /> // Router should handle this
return <div>{data.name}</div>
}
// List views → preload detail on hover
<Link
to="/users/$userId"
params={{ userId }}
preload="intent" // Preload on hover
>
{user.name}
</Link>
// Critical navigation → preload on render
<Link to="/dashboard" preload="render">
Dashboard
</Link>
If users should be able to share or bookmark a specific view, use search params:
const searchSchema = z.object({
tab: z.enum(['overview', 'activity', 'settings']).default('overview'),
page: z.number().default(1),
sort: z.enum(['name', 'date', 'score']).optional(),
})
TanStack Start is the full-stack meta-framework built on TanStack Router:
Stack:
When to Consider Start:
When to Stick with Router + Vite:
Resources:
Note: Start is still maturing. For production SPAs in 2026, TanStack Router + Query + Vite remains the recommended stack.
testing
A test skill for validation testing. Use when testing skill parsing and validation logic.
tools
--- name: bad-skill description: This skill has invalid YAML in frontmatter allowed-tools: [invalid, array, syntax prerequisites: not-an-array --- # Bad Skill This skill has malformed frontmatter that should fail parsing. The YAML has: - Unclosed array bracket - Wrong type for prerequisites (should be array, not string)
tools
Plugin release process for MAG Claude Plugins marketplace. Covers version bumping, marketplace.json updates, git tagging, and common mistakes. Use when releasing new plugin versions or troubleshooting update issues.
testing
Fetch trending programming models from OpenRouter rankings. Use when selecting models for multi-model review, updating model recommendations, or researching current AI coding trends. Provides model IDs, context windows, pricing, and usage statistics from the most recent week.