tanstack
Scannednpx machina-cli add skill tenequm/claude-plugins/tanstack --openclawTanStack (Query + Router + Start)
Type-safe libraries for React applications. Query manages server state (fetching, caching, mutations). Router provides file-based routing with validated search params and data loaders. Start extends Router with SSR, server functions, and middleware for full-stack apps.
When to Use
Query - data fetching, caching, mutations, optimistic updates, infinite scroll, streaming AI/SSE responses, tRPC v11 integration Router - file-based routing, type-safe navigation, validated search params, route loaders, code splitting, preloading Start - SSR/SSG, server functions (type-safe RPCs), middleware, API routes, deployment to Cloudflare/Vercel/Node
Decision tree:
- Client-only SPA with API calls -> Router + Query
- Full-stack with SSR/server functions -> Start + Query (Start includes Router)
TanStack Query v5
Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
)
}
Queries
import { useQuery, queryOptions } from '@tanstack/react-query'
// Reusable query definition (recommended pattern)
const todosQueryOptions = queryOptions({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error('Failed to fetch')
return res.json() as Promise<Todo[]>
},
})
// In component - full type inference from queryOptions
function TodoList() {
const { data, isLoading, error } = useQuery(todosQueryOptions)
if (isLoading) return <Spinner />
if (error) return <div>Error: {error.message}</div>
return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>
}
Mutations
import { useMutation, useQueryClient } from '@tanstack/react-query'
function CreateTodo() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (newTodo: { title: string }) =>
fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo) }).then(r => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
return (
<button onClick={() => mutation.mutate({ title: 'New' })}>
{mutation.isPending ? 'Creating...' : 'Create'}
</button>
)
}
Key Patterns
Query keys - hierarchical arrays for cache management:
['todos'] // all todos
['todos', 'list', { page, sort }] // filtered list
['todo', todoId] // single item
Dependent queries - chain with enabled:
const { data: user } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) })
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => fetchProjects(user!.id),
enabled: !!user?.id,
})
Important defaults: staleTime: 0, gcTime: 5min, retry: 3, refetchOnWindowFocus: true
Suspense - use useSuspenseQuery with <Suspense> boundaries
Streamed queries (experimental) - for AI chat/SSE:
import { experimental_streamedQuery as streamedQuery } from '@tanstack/react-query'
const { data: chunks } = useQuery(queryOptions({
queryKey: ['chat', sessionId],
queryFn: streamedQuery({ streamFn: () => fetchChatStream(sessionId), refetchMode: 'reset' }),
}))
DevTools
pnpm add @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
// Add inside QueryClientProvider
<ReactQueryDevtools initialIsOpen={false} />
Query Deep Dives
query-guide.md- Complete Query reference with all patternsinfinite-queries.md- useInfiniteQuery, pagination, virtual scrolloptimistic-updates.md- Optimistic UI, rollback, undoquery-performance.md- staleTime tuning, deduplication, prefetchingquery-invalidation.md- Cache invalidation strategies, filters, predicatesquery-typescript.md- Type inference, generics, custom hooks
TanStack Router v1
Setup (Vite)
pnpm add @tanstack/react-router @tanstack/router-plugin
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
tanstackRouter({ autoCodeSplitting: true }),
react(),
],
})
// src/router.ts
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export const router = createRouter({ routeTree, defaultPreload: 'intent' })
declare module '@tanstack/react-router' {
interface Register { router: typeof router }
}
File-Based Routing
Files in src/routes/ auto-generate route config:
| Convention | Purpose | Example |
|---|---|---|
__root.tsx | Root route (always rendered) | src/routes/__root.tsx |
index.tsx | Index route | src/routes/index.tsx -> / |
$param | Dynamic segment | posts.$postId.tsx -> /posts/:id |
_prefix | Pathless layout | _layout.tsx wraps children |
(folder) | Route group (no URL) | (auth)/login.tsx -> /login |
Type-Safe Navigation
<Link to="/posts/$postId" params={{ postId: '123' }}>View Post</Link>
// Active styling
<Link to="/posts" activeProps={{ className: 'font-bold' }}>Posts</Link>
// Imperative
const navigate = useNavigate({ from: '/posts' })
navigate({ to: '/posts/$postId', params: { postId: post.id } })
Always provide from on Link and hooks - narrows types and improves TS performance.
Search Params
import { zodValidator, fallback } from '@tanstack/zod-adapter'
import { z } from 'zod'
const searchSchema = z.object({
page: fallback(z.number(), 1).default(1),
sort: fallback(z.enum(['newest', 'oldest']), 'newest').default('newest'),
})
export const Route = createFileRoute('/products')({
validateSearch: zodValidator(searchSchema),
component: () => {
const { page, sort } = Route.useSearch()
// Writing
return <Link from={Route.fullPath} search={prev => ({ ...prev, page: prev.page + 1 })}>Next</Link>
},
})
Use fallback(...).default(...) from the Zod adapter. Plain .catch() causes type loss.
Data Loading
export const Route = createFileRoute('/posts')({
// loaderDeps: only extract what loader needs (not full search)
loaderDeps: ({ search: { page } }) => ({ page }),
loader: ({ deps: { page } }) => fetchPosts({ page }),
pendingComponent: () => <Spinner />,
component: () => {
const posts = Route.useLoaderData()
return <PostList posts={posts} />
},
})
Route Context (Dependency Injection)
// __root.tsx
interface RouterContext { queryClient: QueryClient }
export const Route = createRootRouteWithContext<RouterContext>()({ component: Root })
// router.ts
const router = createRouter({ routeTree, context: { queryClient } })
// Child route - queryClient available in loader
export const Route = createFileRoute('/posts')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(postsQueryOptions()),
})
Router Deep Dives
router-guide.md- Complete Router reference with all patternssearch-params.md- Custom serialization, Standard Schema, sharing paramsdata-loading.md- Deferred loading, streaming SSR, shouldReloadrouting-patterns.md- Virtual routes, route masking, navigation blockingcode-splitting.md- Automatic/manual splitting strategiesrouter-ssr.md- SSR setup, streaming, hydration
TanStack Start (RC)
Full-stack framework extending Router with SSR, server functions, middleware. API stable, feature-complete. No RSC yet.
Setup
pnpm create @tanstack/start@latest
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
tanstackStart(),
viteReact(), // MUST come after tanstackStart()
],
})
Server Functions
Type-safe RPCs. Server code extracted from client bundles at build time.
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
// GET - no input
export const getUsers = createServerFn({ method: 'GET' })
.handler(async () => db.users.findMany())
// POST - validated input
export const createUser = createServerFn({ method: 'POST' })
.inputValidator(z.object({ name: z.string(), email: z.string().email() }))
.handler(async ({ data }) => db.users.create(data))
// Call from loader
export const Route = createFileRoute('/users')({
loader: () => getUsers(),
component: () => {
const users = Route.useLoaderData()
return <UserList users={users} />
},
})
Critical: Loaders are isomorphic (run on server AND client). Never put secrets in loaders - use createServerFn() instead.
Middleware
import { createMiddleware } from '@tanstack/react-start'
const authMiddleware = createMiddleware({ type: 'function' })
.server(async ({ next }) => {
const user = await getCurrentUser()
if (!user) throw redirect({ to: '/login' })
return next({ context: { user } })
})
const getProfile = createServerFn()
.middleware([authMiddleware])
.handler(async ({ context }) => context.user) // typed
Global middleware via src/start.ts:
export const startInstance = createStart(() => ({
requestMiddleware: [logger], // all requests
functionMiddleware: [auth], // all server functions
}))
SSR Modes
| Mode | Use Case |
|---|---|
true (default) | SEO, performance |
false | Browser-only features |
'data-only' | Dashboards (data on server, render on client) |
SPA mode: tanstackStart({ spa: { enabled: true } }) in vite.config.ts
Deployment
- Cloudflare Workers:
@cloudflare/vite-plugin(official partner) - Netlify:
@netlify/vite-plugin-tanstack-start - Node/Vercel/Bun/Docker: via Nitro
- Static:
tanstackStart({ prerender: { enabled: true, crawlLinks: true } })
Start Deep Dives
start-guide.md- Complete Start reference with all patternsserver-functions.md- Streaming, FormData, progressive enhancementmiddleware.md- sendContext, custom fetch, global configssr-modes.md- Selective SSR, shellComponent, fallback renderingserver-routes.md- Dynamic params, wildcards, pathless layouts
Best Practices
- Use
queryOptions()factory for reusable, type-safe query definitions - Structure query keys hierarchically -
['entity', 'action', { filters }] - Set staleTime per data type - static:
Infinity, dynamic:0, moderate:5min - Always validate search params with Zod via
zodValidator+fallback().default() - Provide
fromon navigation - narrows types, catches route mismatches - Use route context for DI - pass QueryClient, auth via
createRootRouteWithContext - Set
defaultPreload: 'intent'globally for perceived performance - Never put secrets in loaders - use
createServerFn()for server-only code - Compose middleware hierarchically - global -> route -> function
- Use
head()on every content route for SEO (title, description, OG tags)
Resources
- Query Docs: https://tanstack.com/query/latest/docs/framework/react/overview
- Router Docs: https://tanstack.com/router/latest/docs/framework/react/overview
- Start Docs: https://tanstack.com/start/latest/docs/framework/react/overview
- GitHub: https://github.com/TanStack/query | https://github.com/TanStack/router
- Discord: https://discord.gg/tanstack
Source
git clone https://github.com/tenequm/claude-plugins/blob/main/frontend-dev/skills/tanstack/SKILL.mdView on GitHub Overview
TanStack provides Query, Router, and Start to build type-safe React apps. It handles server-state data fetching, caching, and mutations (Query), file-based routing with validated search params and loaders (Router), and SSR plus server functions (Start) for full-stack React.
How This Skill Works
TanStack uses a central QueryClient to manage server state and cache. You define reusable queryOptions with typed keys, then components call useQuery or useMutation, and you refresh data by calling invalidateQueries on mutation success.
When to Use It
- Client-only SPA needing reliable server-state caching with useQuery and useMutation
- App with file-based routing and validated search params via TanStack Router
- App requiring SSR or server functions and middleware with Start for full-stack React
- Need to invalidate or refetch queries after mutations or on route loaders
- Projects using typed query keys, dependent queries, or experimental streamed queries
Quick Start
- Step 1: Create a QueryClient and wrap your app with QueryClientProvider
- Step 2: Define a reusable queryOptions for a resource (e.g., todos) with a queryFn
- Step 3: Use useQuery or useMutation in a component and invalidate after mutation
Best Practices
- Create reusable queryOptions for common queries to leverage full type inference
- Use hierarchical query keys (e.g., ['todos'], ['todo', id]) for precise cache control
- Invalidate queries after mutations to keep UI in sync (e.g., invalidateQueries(['todos']))
- Tune defaults (staleTime, retry, refetchOnWindowFocus) to balance freshness and performance
- Leverage Suspense with useSuspenseQuery when using loading boundaries
Example Use Cases
- Todo app: fetch todos with useQuery and create todos with useMutation, then invalidate(['todos']) on success
- User dashboard with dependent queries that fetch projects after a user id is known
- Chat UI using experimental streamed queries for real-time AI/SSE responses
- Server-rendered page using Start for SSR and server functions
- Router-based routes with loaders that prefetch data for each route