TanStack Router conventions for the Vite + React SPA in `packages/client`. File-based routing, typed params, loaders integrated with TanStack Query. Load when adding routes or wiring up the router.
This project uses TanStack Router (not react-router). Routes are generated from files under src/routes/ via the Vite plugin @tanstack/router-plugin. routeTree.gen.ts is committed and regenerated on dev/build.
cd packages/client
bun add @tanstack/react-router
bun add -D @tanstack/router-plugin @tanstack/router-devtools
vite.config.ts:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
tanstackRouter({ target: 'react', autoCodeSplitting: true }),
react(),
tailwindcss(),
],
server: {
port: 5173,
proxy: { '/api': 'http://localhost:11575' },
},
})
src/main.tsx:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { ThemeProvider } from 'next-themes'
import './main.css'
const queryClient = new QueryClient()
const router = createRouter({ routeTree, context: { queryClient } })
declare module '@tanstack/react-router' {
interface Register { router: typeof router }
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute='class' defaultTheme='system'>
<RouterProvider router={router} />
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
)
| File | URL |
|---|---|
src/routes/__root.tsx | shell / layout root (Outlet + providers' children) |
src/routes/index.tsx | / |
src/routes/epg.tsx | /epg |
src/routes/live/$channelId.tsx | /live/:channelId |
src/routes/recordings/index.tsx | /recordings |
src/routes/recordings/$id.tsx | /recordings/:id |
src/routes/_authed/admin.tsx | /admin inside a pathless _authed layout |
// src/routes/live/$channelId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useLiveStream } from '@/hooks/useLiveStream'
import { HlsPlayer } from '@/components/player/HlsPlayer'
export const Route = createFileRoute('/live/$channelId')({
// optional prefetch — pair with TanStack Query:
loader: async ({ context: { queryClient }, params: { channelId } }) => {
await queryClient.ensureQueryData({
queryKey: ['channel', channelId],
queryFn: () => fetchChannel(channelId),
})
},
component: LivePage,
})
function LivePage() {
const { channelId } = Route.useParams() // typed
const { playlistUrl } = useLiveStream(channelId)
return playlistUrl ? <HlsPlayer src={playlistUrl} /> : null
}
__root.tsx conventions// src/routes/__root.tsx
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
import { AppShell } from '@/components/layout/AppShell'
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: () => (
<AppShell>
<Outlet />
</AppShell>
),
})
Dev tools are opt-in:
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
...
{import.meta.env.DEV && <TanStackRouterDevtools />}
Validate with Zod — the router uses the schema for type inference and coercion.
export const Route = createFileRoute('/epg')({
validateSearch: z.object({
at: z.string().datetime().optional(),
channel: z.string().optional(),
}),
component: EPGPage,
})
function EPGPage() {
const { at, channel } = Route.useSearch()
...
}
<Link to="/live/$channelId" params={{ channelId }} /> — typed, required params for dynamic segments.const navigate = useNavigate(); navigate({ to: '/epg', search: { at } }).pendingComponent, errorComponent, notFoundComponent.createRouter({ defaultPendingComponent, defaultErrorComponent, defaultNotFoundComponent }).Two patterns, pick based on whether the data blocks first paint:
ensureQueryData: prefetch in the loader so the component renders with data. Use for the page's primary data.useQuery in component: simpler; the component renders with a pending state. Use for secondary data.export const Route = createFileRoute('/epg')({
loader: ({ context }) => context.queryClient.ensureQueryData(programsQuery()),
component: () => {
const programs = useSuspenseQuery(programsQuery()).data
...
},
})
/admin/* via Cloudflare Access)The _authed pathless layout is currently a no-op — Cloudflare Access gates /admin/* at the infrastructure layer (per project memory: admin route auth is handled by Cloudflare Access, not the app). Don't add a React-side auth check for /admin/*. Other protected areas can use beforeLoad + redirect().
routeTree.gen.ts — the Vite plugin watches, but CI/build needs the plugin in the pipeline.$param (dollar sign), not [param] or :param.routeTree.gen.ts. Ever.37:["$","$L3f",null,{"content":"$40","frontMatter":{"name":"tanstack-router","description":"TanStack Router conventions for the Vite + React SPA in packages/client. File-based routing, typed params, loaders integrated with TanStack Query. Load when adding routes or wiring up the router."}}]