tanstack-router

Type-safe routing for React and Solid applications with first-class search params, data loading, and seamless integration with the React ecosystem.

INSTALLATION
npx skills add https://github.com/tanstack-skills/tanstack-skills --skill tanstack-router
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

$27

Core Concepts

Route Trees

Routes are organized in a tree structure. The root route is the top-level layout, and child routes nest underneath.

import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router'

const rootRoute = createRootRoute({

  component: RootLayout,

})

const indexRoute = createRoute({

  getParentRoute: () => rootRoute,

  path: '/',

  component: HomePage,

})

const aboutRoute = createRoute({

  getParentRoute: () => rootRoute,

  path: '/about',

  component: AboutPage,

})

const routeTree = rootRoute.addChildren([indexRoute, aboutRoute])

const router = createRouter({ routeTree })

File-Based Routing

File-based routing automatically generates the route tree from your file structure. Configure with Vite plugin:

// vite.config.ts

import { defineConfig } from 'vite'

import { TanStackRouterVite } from '@tanstack/router-plugin/vite'

export default defineConfig({

  plugins: [

    TanStackRouterVite(),

    // ... other plugins

  ],

})

#### File Naming Conventions

File Pattern

Route Type

Example Path

__root.tsx

Root layout

N/A (wraps all)

index.tsx

Index route

/

about.tsx

Static route

/about

$postId.tsx

Dynamic param

/posts/$postId

posts.tsx

Layout route

/posts/* (layout)

posts/index.tsx

Nested index

/posts

posts/$postId.tsx

Nested dynamic

/posts/123

posts_.$postId.tsx

Pathless layout

/posts/123 (different layout)

_layout.tsx

Pathless layout

N/A (groups routes)

_layout/dashboard.tsx

Grouped route

/dashboard

$.tsx

Splat/catch-all

/*

posts.$postId.edit.tsx

Dot notation

/posts/123/edit

#### Special Prefixes

  • _ prefix: Pathless routes (layout groups without URL segment)
  • $ prefix: Dynamic path parameters
  • (folder) parentheses: Route groups (organizational, no URL impact)

Route Configuration

Each route can define:

// routes/posts.$postId.tsx

import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({

  // Validation for path params

  params: {

    parse: (params) => ({ postId: Number(params.postId) }),

    stringify: (params) => ({ postId: String(params.postId) }),

  },

  // Search params validation

  validateSearch: (search: Record<string, unknown>) => {

    return {

      page: Number(search.page ?? 1),

      filter: (search.filter as string) || '',

    }

  },

  // Data loading

  loader: async ({ params, context, abortController }) => {

    return fetchPost(params.postId)

  },

  // Loader dependencies (re-run loader when these change)

  loaderDeps: ({ search }) => ({ page: search.page }),

  // Stale time for cached loader data

  staleTime: 5_000,

  // Preloading

  preloadStaleTime: 30_000,

  // Error component

  errorComponent: PostErrorComponent,

  // Pending/loading component

  pendingComponent: PostLoadingComponent,

  // 404 component

  notFoundComponent: PostNotFoundComponent,

  // Before load hook (authentication, redirects)

  beforeLoad: async ({ context, location }) => {

    if (!context.auth.isAuthenticated) {

      throw redirect({

        to: '/login',

        search: { redirect: location.href },

      })

    }

  },

  // Head/meta management

  head: () => ({

    meta: [{ title: 'Post Details' }],

  }),

  // Component

  component: PostComponent,

})

function PostComponent() {

  const { postId } = Route.useParams()

  const post = Route.useLoaderData()

  const { page, filter } = Route.useSearch()

  return <div>{post.title}</div>

}

Data Loading

Route Loaders

export const Route = createFileRoute('/posts')({

  loader: async ({ context }) => {

    // Access router context (e.g., queryClient)

    const posts = await context.queryClient.ensureQueryData({

      queryKey: ['posts'],

      queryFn: fetchPosts,

    })

    return { posts }

  },

  component: PostsComponent,

})

function PostsComponent() {

  const { posts } = Route.useLoaderData()

  // ...

}

Loader Dependencies

Control when loaders re-execute:

export const Route = createFileRoute('/posts')({

  loaderDeps: ({ search: { page, filter } }) => ({ page, filter }),

  loader: async ({ deps: { page, filter } }) => {

    return fetchPosts({ page, filter })

  },

})

Deferred Data Loading

Stream non-critical data:

import { Await, defer } from '@tanstack/react-router'

export const Route = createFileRoute('/dashboard')({

  loader: async () => {

    const criticalData = await fetchCriticalData()

    const deferredData = defer(fetchSlowData())

    return { criticalData, deferredData }

  },

  component: DashboardComponent,

})

function DashboardComponent() {

  const { criticalData, deferredData } = Route.useLoaderData()

  return (

    <div>

      <CriticalSection data={criticalData} />

      <Suspense fallback={<Loading />}>

        <Await promise={deferredData}>

          {(data) => <SlowSection data={data} />}

        </Await>

      </Suspense>

    </div>

  )

}

Context-Based Data Loading

Provide shared dependencies via router context:

// Create router with context

const router = createRouter({

  routeTree,

  context: {

    queryClient,

    auth: undefined!, // Will be provided by RouterProvider

  },

})

// In root/app component

function App() {

  const auth = useAuth()

  return <RouterProvider router={router} context={{ auth }} />

}

// In routes

export const Route = createFileRoute('/protected')({

  beforeLoad: ({ context }) => {

    if (!context.auth.user) throw redirect({ to: '/login' })

  },

  loader: ({ context }) => {

    return context.queryClient.ensureQueryData(userQueryOptions())

  },

})

Search Parameters

Validation

import { z } from 'zod'

const postSearchSchema = z.object({

  page: z.number().default(1),

  filter: z.string().default(''),

  sort: z.enum(['date', 'title']).default('date'),

})

export const Route = createFileRoute('/posts')({

  validateSearch: postSearchSchema,

  // Or manual validation:

  // validateSearch: (search) => postSearchSchema.parse(search),

})

Reading Search Params

function PostsComponent() {

  // From route

  const { page, filter, sort } = Route.useSearch()

  // Or from any component with useSearch hook

  const search = useSearch({ from: '/posts' })

}

Updating Search Params

import { useNavigate } from '@tanstack/react-router'

function Pagination() {

  const navigate = useNavigate()

  const { page } = Route.useSearch()

  return (

    <button

      onClick={() =>

        navigate({

          search: (prev) => ({ ...prev, page: prev.page + 1 }),

        })

      }

    >

      Next Page

    </button>

  )

}

// Or via Link component

<Link

  to="/posts"

  search={(prev) => ({ ...prev, page: 2 })}

>

  Page 2

</Link>

Search Param Options

const router = createRouter({

  routeTree,

  // Custom serialization

  search: {

    strict: true, // Reject unknown params

  },

  // Default search param serializer

  stringifySearch: defaultStringifySearch,

  parseSearch: defaultParseSearch,

})

Navigation

Link Component

import { Link } from '@tanstack/react-router'

// Static route

<Link to="/about">About</Link>

// Dynamic route with params

<Link to="/posts/$postId" params={{ postId: '123' }}>

  Post 123

</Link>

// With search params

<Link to="/posts" search={{ page: 2, filter: 'react' }}>

  Page 2

</Link>

// Active link styling

<Link

  to="/posts"

  activeProps={{ className: 'active' }}

  inactiveProps={{ className: 'inactive' }}

  activeOptions={{ exact: true }}

>

  Posts

</Link>

// Preloading

<Link to="/posts" preload="intent">Posts</Link>

<Link to="/dashboard" preload="viewport">Dashboard</Link>

// Hash

<Link to="/docs" hash="api-reference">API Reference</Link>

Programmatic Navigation

import { useNavigate, useRouter } from '@tanstack/react-router'

function MyComponent() {

  const navigate = useNavigate()

  const router = useRouter()

  // Navigate to a route

  navigate({ to: '/posts', search: { page: 1 } })

  // Navigate with replace

  navigate({ to: '/posts', replace: true })

  // Relative navigation

  navigate({ to: '.', search: (prev) => ({ ...prev, page: 2 }) })

  // Go back/forward

  router.history.back()

  router.history.forward()

  // Invalidate and reload current route

  router.invalidate()

}

Redirects

import { redirect } from '@tanstack/react-router'

// In beforeLoad or loader

throw redirect({

  to: '/login',

  search: { redirect: location.href },

  // Optional status code

  statusCode: 301, // Permanent redirect (SSR)

})

Navigation Blocking

import { useBlocker } from '@tanstack/react-router'

function FormComponent() {

  const [isDirty, setIsDirty] = useState(false)

  useBlocker({

    shouldBlockFn: () => isDirty,

    withResolver: true, // Shows confirm dialog

  })

  // Or with custom UI

  const { proceed, reset, status } = useBlocker({

    shouldBlockFn: () => isDirty,

  })

  if (status === 'blocked') {

    return (

      <div>

        <p>Are you sure you want to leave?</p>

        <button onClick={proceed}>Leave</button>

        <button onClick={reset}>Stay</button>

      </div>

    )

  }

}

Code Splitting

Automatic (File-Based Routing)

With file-based routing, create a lazy file:

routes/

  posts.tsx          # Critical: loader, beforeLoad, meta

  posts.lazy.tsx     # Lazy: component, pendingComponent, errorComponent
// posts.tsx (loaded eagerly)

export const Route = createFileRoute('/posts')({

  loader: () => fetchPosts(),

})

// posts.lazy.tsx (loaded lazily)

import { createLazyFileRoute } from '@tanstack/react-router'

export const Route = createLazyFileRoute('/posts')({

  component: PostsComponent,

  pendingComponent: PostsLoading,

  errorComponent: PostsError,

})

Manual Code Splitting

const postsRoute = createRoute({

  getParentRoute: () => rootRoute,

  path: '/posts',

  loader: () => fetchPosts(),

}).lazy(() => import('./posts.lazy').then((d) => d.Route))

Preloading

// Router-level defaults

const router = createRouter({

  routeTree,

  defaultPreload: 'intent', // 'intent' | 'viewport' | 'render' | false

  defaultPreloadStaleTime: 30_000, // 30 seconds

})

// Route-level

export const Route = createFileRoute('/posts/$postId')({

  // Stale time for the loader data

  staleTime: 5_000,

  // How long preloaded data stays fresh

  preloadStaleTime: 30_000,

})

// Link-level

<Link to="/posts" preload="intent" preloadDelay={100}>

  Posts

</Link>

Type Safety

Register Router Type

// Declare module for type inference

declare module '@tanstack/react-router' {

  interface Register {

    router: typeof router

  }

}

Type-Safe Hooks

All hooks are fully typed based on the route tree:

// useParams - typed to route's params

const { postId } = useParams({ from: '/posts/$postId' })

// useSearch - typed to route's search schema

const { page } = useSearch({ from: '/posts' })

// useLoaderData - typed to loader return

const data = useLoaderData({ from: '/posts/$postId' })

// useRouteContext - typed to route context

const { auth } = useRouteContext({ from: '/protected' })

Route Generics

import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({

  // TypeScript infers:

  // params: { postId: string }

  // search: validated search schema type

  // loaderData: return type of loader

  // context: router context type

})

Authenticated Routes

// __root.tsx

export const Route = createRootRouteWithContext<{

  auth: AuthContext

}>()({

  component: RootComponent,

})

// _authenticated.tsx (pathless layout for auth)

export const Route = createFileRoute('/_authenticated')({

  beforeLoad: ({ context, location }) => {

    if (!context.auth.isAuthenticated) {

      throw redirect({

        to: '/login',

        search: { redirect: location.href },

      })

    }

  },

})

// _authenticated/dashboard.tsx

export const Route = createFileRoute('/_authenticated/dashboard')({

  component: Dashboard, // Only accessible when authenticated

})

Scroll Restoration

const router = createRouter({

  routeTree,

  // Enable scroll restoration

  defaultScrollRestoration: true,

})

// Or per-route

export const Route = createFileRoute('/posts')({

  // Scroll to top on navigation

  scrollRestoration: true,

})

// Custom scroll restoration key

<ScrollRestoration

  getKey={(location) => location.pathname}

/>

Route Masking

Display a different URL than the actual route:

<Link

  to="/photos/$photoId"

  params={{ photoId: photo.id }}

  mask={{ to: '/photos', search: { photoId: photo.id } }}

>

  View Photo

</Link>

// Or programmatically

navigate({

  to: '/photos/$photoId',

  params: { photoId: photo.id },

  mask: { to: '/photos', search: { photoId: photo.id } },

})

Not Found Handling

// Global 404

const router = createRouter({

  routeTree,

  defaultNotFoundComponent: () => <div>Page not found</div>,

})

// Route-level 404

export const Route = createFileRoute('/posts/$postId')({

  loader: async ({ params }) => {

    const post = await fetchPost(params.postId)

    if (!post) throw notFound()

    return post

  },

  notFoundComponent: () => <div>Post not found</div>,

})

Head Management

export const Route = createFileRoute('/posts/$postId')({

  head: ({ loaderData }) => ({

    meta: [

      { title: loaderData.title },

      { name: 'description', content: loaderData.excerpt },

      { property: 'og:title', content: loaderData.title },

    ],

    links: [

      { rel: 'canonical', href: `https://example.com/posts/${loaderData.id}` },

    ],

  }),

})

Integration with TanStack Query

import { queryOptions } from '@tanstack/react-query'

const postsQueryOptions = queryOptions({

  queryKey: ['posts'],

  queryFn: fetchPosts,

})

export const Route = createFileRoute('/posts')({

  loader: ({ context: { queryClient } }) => {

    // Ensure data is in cache, won't refetch if fresh

    return queryClient.ensureQueryData(postsQueryOptions)

  },

  component: PostsComponent,

})

function PostsComponent() {

  // Use the same query options for reactive updates

  const { data: posts } = useSuspenseQuery(postsQueryOptions)

  return <PostsList posts={posts} />

}

Router Hooks Reference

Hook

Purpose

useRouter()

Access router instance

useRouterState()

Subscribe to router state

useParams()

Get route path params

useSearch()

Get validated search params

useLoaderData()

Get route loader data

useRouteContext()

Get route context

useNavigate()

Get navigate function

useLocation()

Get current location

useMatches()

Get all matched routes

useMatch()

Get specific route match

useBlocker()

Block navigation

useLinkProps()

Get link props for custom components

useMatchRoute()

Check if a route matches

Best Practices

  • Use file-based routing for most applications - it's simpler and auto-generates the route tree
  • Validate search params with Zod or custom validators for type safety
  • **Use loaderDeps** to control when loaders re-execute based on search param changes
  • Leverage context for dependency injection (QueryClient, auth state)
  • **Use beforeLoad** for authentication guards, not in components
  • Separate critical vs lazy code - keep loaders in the main file, components in .lazy.tsx
  • **Use preload="intent"** on Links for perceived performance
  • **Use staleTime** to prevent unnecessary refetches during navigation
  • Register the router type for full TypeScript inference across the app
  • **Use notFound()** instead of conditional rendering for 404 states
  • Colocate search param logic with routes that own them
  • Use pathless layouts (_authenticated) for shared auth/layout logic without URL segments

Common Pitfalls

  • Forgetting to register the router type (declare module)
  • Not using loaderDeps when loader depends on search params (causes stale data)
  • Putting auth checks in components instead of beforeLoad (flash of protected content)
  • Not handling the loading state with pendingComponent
  • Using useEffect for data fetching instead of route loaders
  • Mutating search params directly instead of using navigate/Link
  • Not wrapping the app with RouterProvider
  • Forgetting getParentRoute in code-based route definitions
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card