npx skills add ...
npx skills add tanstack/router --skill data-loading
Route loader option, loaderDeps for cache keys, staleTime/gcTime/ defaultPreloadStaleTime SWR caching, pendingComponent/pendingMs/ pendingMinMs, errorComponent/onError/onCatch, beforeLoad, router context and createRootRouteWithContext DI pattern, router.invalidate, Await component, deferred data loading with unawaited promises.
npx skills add tanstack/router --skill data-loading
Basic loader returning data, consumed via useLoaderData:
In code-split components, use getRouteApi instead of importing Route:
The router executes this sequence on every URL/history update:
route.params.parseroute.validateSearchroute.beforeLoadroute.onError → route.errorComponentroute.component.preload?route.loader
route.pendingComponent (optional)route.componentroute.onError → route.errorComponentKey: beforeLoad runs before loader. beforeLoad for a parent runs before its children's beforeLoad. Throwing in beforeLoad prevents all children from loading.
Loaders don't receive search params directly. Use loaderDeps to declare which search params affect the cache key:
When deps change, the route reloads regardless of staleTime.
TanStack Router has built-in Stale-While-Revalidate caching keyed on the route's parsed pathname + loaderDeps.
Defaults:
staleTime: 0 — data is always considered stale, reloads in background on re-matchpreloadStaleTime: 30 seconds — preloaded data won't be refetched for 30sgcTime: 30 minutes — unused cache entries garbage collected after 30minDisable SWR caching entirely:
Globally:
By default, a pending component shows after 1 second (pendingMs: 1000) and stays for at least 500ms (pendingMinMs: 500) to avoid flash.
createRootRouteWithContext is a factory that returns a function. You must call it twice — the first call passes the generic type, the second passes route options:
Supply the context when creating the router:
Consume in loaders and beforeLoad:
To pass React hook values into the router context, call the hook above RouterProvider and inject via the context prop:
Route-level context via beforeLoad:
Keep the implementation SSR-safe when the router is used by TanStack Start. A relative fetch('/api/posts') works in a browser event handler, but Node and many server runtimes require an absolute URL during SSR. For app-internal data in Start, call a server function from the loader:
Use a server route plus an origin-derived absolute URL only when the HTTP boundary itself is required. Do not hard-code the production origin.
Return unawaited promises from the loader for non-critical data. Use the Await component to render them:
router.invalidate() forces all active route loaders to re-run and marks all cached data as stale:
Use await router.invalidate({ sync: true }) when the next step requires refreshed loader data.
Treat the mutation and invalidation as one workflow. The mutation must persist before invalidation starts, and the loader must read from the same authoritative store. Verify create, update, and delete through the rendered route, including a fresh reload; local component state can hide a stale loader or non-persistent write.
The loader function receives:
params — parsed path paramsdeps — object from loaderDepscontext — merged parent + beforeLoad contextabortController — cancelled when route unloads or becomes stalecause — 'enter', 'stay', or 'preload'preload — true during preloadinglocation — current location objectparentMatchPromise — promise of parent route matchroute — the route object itselfTanStack Router is client-first. Loaders run on the client by default. They also run on the server when using TanStack Start for SSR, but the default mental model is client-side execution.
Do NOT put database queries, filesystem access, or server-only code directly in loaders. In TanStack Start, put that work in a server function and call the function from the loader. Do not use a relative fetch('/api/...') in an SSR loader.
Default staleTime is 0. This means data reloads in the background on every route re-match. This is intentional — it ensures fresh data. But if your data is expensive or static, set staleTime:
reset() only resets the error boundary UI. It does NOT re-run the loader. For loader errors, use router.invalidate() which re-runs loaders and resets the boundary:
createRootRouteWithContext<Type>() is a factory — it returns a function. Must call twice:
beforeLoad and loader are NOT React components. You cannot call hooks inside them. Use router context to inject values from hooks:
Router infers types from earlier properties into later ones. Declaring beforeLoad after loader means context from beforeLoad is unknown in the loader:
Returning the whole search object means unrelated param changes (e.g., sortDirection, viewMode) trigger unnecessary reloads because deep equality fails on the entire object.
defaultPreloadStaleTime: 0 to avoid double-caching. See compositions/router-query/SKILL.md.loaderDeps consumes validated search params as cache keysimport { getRouteApi } from '@tanstack/react-router'
const routeApi = getRouteApi('/posts')
function PostsComponent() {
const posts = routeApi.useLoaderData()
return <ul>{/* ... */}</ul>
}// src/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts')({
validateSearch: (search) => ({
offset: Number(search.offset) || 0,
limit: Number(search.limit) || 10,
}),
loaderDeps: ({ search: { offset, limit } }) => ({ offset, limit }),
loader: ({ deps: { offset, limit } }) => fetchPosts({ offset, limit }),
})export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
staleTime: 10_000, // 10s: data considered fresh for 10 seconds
gcTime: 5 * 60 * 1000, // 5min: garbage collect after 5 minutes
})export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
staleTime: Infinity,
})const router = createRouter({
routeTree,
defaultStaleTime: Infinity,
})export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
pendingMs: 500,
pendingMinMs: 300,
pendingComponent: () => <div>Loading posts...</div>,
component: PostsComponent,
})// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
interface MyRouterContext {
auth: { userId: string }
fetchPosts: () => Promise<Post[]>
}
// NOTE: double call — createRootRouteWithContext<Type>()({...})
export const Route = createRootRouteWithContext<MyRouterContext>()({
component: () => <Outlet />,
})// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({
routeTree,
context: {
auth: { userId: '123' },
fetchPosts,
},
})// src/routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: ({ context: { fetchPosts } }) => fetchPosts(),
})import { RouterProvider } from '@tanstack/react-router'
function InnerApp() {
const auth = useAuth()
return <RouterProvider router={router} context={{ auth }} />
}
function App() {
return (
<AuthProvider>
<InnerApp />
</AuthProvider>
)
}export const Route = createFileRoute('/posts')({
beforeLoad: ({ context }) => ({
fetchPosts: context.fetchPosts,
}),
loader: ({ context: { fetchPosts } }) => fetchPosts(),
})import { createServerFn } from '@tanstack/react-start'
const getPosts = createServerFn({ method: 'GET' }).handler(() => {
return db.posts.findMany()
})
export const Route = createFileRoute('/posts')({
loader: () => getPosts(),
})import { createFileRoute, Await } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params: { postId } }) => {
// Slow data — do NOT await
const slowDataPromise = fetchComments(postId)
// Fast data — await
const post = await fetchPost(postId)
return { post, deferredComments: slowDataPromise }
},
component: PostComponent,
})
function PostComponent() {
const { post, deferredComments } = Route.useLoaderData()
return (
<div>
<h1>{post.title}</h1>
<Await
promise={deferredComments}
fallback={<div>Loading comments...</div>}
>
{(comments) => (
<ul>
{comments.map((c) => (
<li key={c.id}>{c.body}</li>
))}
</ul>
)}
</Await>
</div>
)
}import { useRouter } from '@tanstack/react-router'
function AddPostButton() {
const router = useRouter()
const handleAdd = async () => {
await createPost({ title: 'New post' })
await router.invalidate({ sync: true })
}
return <button onClick={handleAdd}>Add Post</button>
}import {
createFileRoute,
ErrorComponent,
useRouter,
} from '@tanstack/react-router'
export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
errorComponent: ({ error, reset }) => {
const router = useRouter()
if (error instanceof CustomError) {
return <div>{error.message}</div>
}
return (
<div>
<ErrorComponent error={error} />
<button
onClick={() => {
// For loader errors, invalidate to re-run loader + reset boundary
router.invalidate()
}}
>
Retry
</button>
</div>
)
},
})export const Route = createFileRoute('/posts/$postId')({
loader: ({ params: { postId }, abortController }) =>
fetchPost(postId, { signal: abortController.signal }),
})// WRONG — this will crash in the browser
export const Route = createFileRoute('/posts')({
loader: async () => {
const fs = await import('fs') // Node.js only!
return JSON.parse(fs.readFileSync('...')) // fails in browser
},
})
// CORRECT for an SPA — use a client-safe API helper
export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
})export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
staleTime: 60_000, // Consider fresh for 1 minute
})// WRONG — resets boundary but loader still has stale error
function PostErrorComponent({ error, reset }) {
return <button onClick={reset}>Retry</button>
}
// CORRECT — re-runs loader and resets the error boundary
function PostErrorComponent({ error }) {
const router = useRouter()
return <button onClick={() => router.invalidate()}>Retry</button>
}// WRONG — missing second call, passes options to the factory
const rootRoute = createRootRouteWithContext<{ auth: AuthState }>({
component: RootComponent,
})
// CORRECT — factory()({options})
const rootRoute = createRootRouteWithContext<{ auth: AuthState }>()({
component: RootComponent,
})// WRONG — hooks cannot be called outside React components
export const Route = createFileRoute('/posts')({
loader: () => {
const auth = useAuth() // This will crash!
return fetchPosts(auth.userId)
},
})
// CORRECT — inject hook values via router context
// In your App component:
function InnerApp() {
const auth = useAuth()
return <RouterProvider router={router} context={{ auth }} />
}
// In your route:
export const Route = createFileRoute('/posts')({
loader: ({ context: { auth } }) => fetchPosts(auth.userId),
})// WRONG — context.user is unknown because beforeLoad declared after loader
export const Route = createFileRoute('/admin')({
loader: ({ context }) => fetchData(context.user),
beforeLoad: () => ({ user: getUser() }),
})
// CORRECT — validateSearch → loaderDeps → beforeLoad → loader
export const Route = createFileRoute('/admin')({
beforeLoad: () => ({ user: getUser() }),
loader: ({ context }) => fetchData(context.user),
})// WRONG — loader re-runs on ANY search param change
loaderDeps: ({ search }) => search
// CORRECT — only re-run when page changes
loaderDeps: ({ search }) => ({ page: search.page })