react-routing
Skill18 starsChanged 4 months agoMITView on GitHub
Type-safe routing for React SPAs with TanStack Router (file-based routes, Vite plugin, automatic code splitting) - thin route files in src/app/routes, path params, Zod-validated search params, loaders that prefetch TanStack Query data, typed <Link> navigation across features, navigation after async results through callbacks, pending/error/not-found components, the router context, auth guards with beforeLoad, and route tests. Use this skill whenever adding a page or route, linking between pages, reading path or search params, prefetching data for a page, protecting routes, handling not-found or route errors, or configuring the router. Trigger on phrases like "proxy", "hosting", "SPA fallback", "add a route", "add a page", "new screen", "link to", "navigate", "useNavigate", "Link", "path param", "search param", "query string", "validateSearch", "loader", "beforeLoad", "redirect", "protected route", "auth guard", "not found", "404 page", "errorComponent", "pendingComponent", "router context", "createFileRoute", or "route tree".
2 other projects use a version of this.
Adopted by
Most recently taken up by
- jaballer/react-claude-skillstheir own version, first seen Jun 23, 2026
- Nupke/react-claude-skillstheir own version, first seen May 27, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- Nupke/react-claude-skills★ 1same name
- jaballer/react-claude-skills★ 0same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository67 other files it gives its agents.
- agent-onboardingSkill
- android-backgroundSkill
- android-cliSkill
- android-compose-architectureSkill
- android-compose-componentsSkill
- android-data-layerSkill
- android-di-koinSkill
- android-error-handlingSkill
- android-integration-testingSkill
- android-jacoco-setupSkill
- android-jvm-test-coverageSkill
- android-module-structureSkill
- android-navigationSkill
- android-presentation-mviSkill
- android-preview-figma-verifySkill
- android-ui-screenshot-verifySkill
- android-ui-testingSkill
- android-ui-verifySkill
- android-unit-testingSkill
- android-version-catalogSkill
- architecture-decision-recordSkill
- careful-planningSkill
- compose-adaptive-layoutsSkill
- figma-to-android-vectorSkill
- finalizeSkill
- git-commitSkill
- handoffSkill
- kmp-compose-architectureSkill
- kmp-compose-componentsSkill
- kmp-data-layerSkill
- kmp-di-koinSkill
- kmp-error-handlingSkill
- kmp-integration-testingSkill
- kmp-migration-analyzerSkill
- kmp-module-structureSkill
- kmp-navigationSkill
- kmp-presentation-mviSkill
- kmp-ui-testingSkill
- kmp-unit-testingSkill
- kotlin-coroutinesSkill
- kotlin-flowsSkill
- kotlin-mutation-testingSkill
- openspec-artifact-review-lightSkill
- openspec-artifact-reviewSkill
- project-breakdownSkill
- react-data-layerSkill
- react-error-handlingSkill
- react-integration-testingSkill
- react-presentation-mviSkill
- react-project-structureSkill
- react-screen-architectureSkill
- react-securitySkill
- react-server-stateSkill
- react-ui-componentsSkill
- react-unit-testingSkill
- speckit-parallelize-agent-teamsSkill
- speckit-parallelize-subagentsSkill
- spring-api-layerSkill
- spring-di-configurationSkill
- spring-integration-testingSkill
- The first 60 shown.
The file
We split this skill into the rules it gives an agent
8 rules. About 0 are common, 0 are distinctive, and 8 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
8
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 146
Search params are the web's equivalent of SavedStateHandle: whatever must survive a reload goes there (see react-screen-architecture).
- Very specific: line 162
Don't return data from the loader for the component to read with Route.useLoaderData().
- Very specific: line 177
Use <Link> from @tanstack/react-router for every user-initiated navigation, also in feature code and also to another feature's pages.
- Very specific: line 179
Use search on <Link> to link to filtered views: <Link to="/notes" search={{ filter: 'archived' }}>.
- Very specific: line 235
After login, navigate to the redirect search param only after checking it is a path inside the app (see react-security).
- Very specific: line 253
Use findBy* queries, because route loading is async.
- Very specific: line 279
Every path that isn't /api/ must serve index.html, or reloading a deep link returns a 404 from the server.
- Very specific: line 280
No route in the app may start with /api.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
React Routing (TanStack Router)
Principles
- The URL is the source of truth for "where am I". Everything a user should be able to bookmark, share or reload (the page, the selected item, filters, the active tab) is in the path or the search params.
- Route files are thin. They declare params, search validation, the loader and the guard, then render one feature Root component. No UI and no logic beyond that.
- Links are real links. Anything the user clicks to go somewhere is a
<Link>, so it renders an<a href>that supports middle-click, "open in new tab" and "copy link".navigate()is for navigation that follows an async result. - Routes are typed. A link to a path that doesn't exist, or with missing params, fails
npm run typecheck.
Setup
The Vite plugin generates the route tree from src/app/routes/:
// vite.config.ts
plugins: [
// Must run before the React plugin so it can split route files first.
tanstackRouter({
target: 'react',
routesDirectory: './src/app/routes',
generatedRouteTree: './src/app/routeTree.gen.ts',
autoCodeSplitting: true,
}),
react(),
]routeTree.gen.ts is committed and excluded from oxlint and Prettier. It is regenerated by the dev server and by vite build.
// src/app/router.ts
export type RouterContext = {
queryClient: QueryClient
}
/** Creates the app router. Tests pass a memory history and a fresh QueryClient. */
export function createAppRouter(queryClient: QueryClient, history?: RouterHistory) {
return createRouter({
routeTree,
history,
context: { queryClient },
defaultPreload: 'intent',
// TanStack Query decides freshness, so the router must not cache loader results itself.
defaultPreloadStaleTime: 0,
defaultPendingComponent: PagePending,
defaultErrorComponent: PageError,
})
}
export const router = createAppRouter(queryClient)
declare module '@tanstack/react-router' {
// Module augmentation only works with an interface.
// oxlint-disable-next-line typescript/consistent-type-definitions
interface Register {
router: typeof router
}
}// src/app/routes/__root.tsx
export const Route = createRootRouteWithContext<RouterContext>()({
component: Outlet,
notFoundComponent: NotFoundPage,
})defaultPreload: 'intent'starts the loader when the user hovers or focuses a link.- The
Registeraugmentation makes every<Link>,useNavigateandredirectin the app type-checked against the route tree, including in feature code that never importssrc/app.
Route Files
// src/app/routes/notes/$noteId.tsx
export const Route = createFileRoute('/notes/$noteId')({
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(noteQueryOptions(params.noteId)),
component: NoteDetailRoute,
})
function NoteDetailRoute() {
const { noteId } = Route.useParams()
const navigate = useNavigate()
return (
<NoteDetailRoot
noteId={noteId}
onNoteDeleted={() => navigate({ to: '/notes' })}
/>
)
}- The route file is the only file that reads params and search. The feature's Root receives them as plain props, so the Root can be tested without a router.
- File routes need
export const Route. The component is a local, non-exported function in the same file. Automatic code splitting moves it into its own chunk. - Pages that belong to no feature (not-found, a placeholder start page) live in
src/app.
Path params
Path params are strings. Pass IDs through as they are. If a param has a format (a number, a slug), parse it in the route with params: { parse, stringify } and throw notFound() for invalid values, so the feature never sees a malformed ID.
Search params
Validate search params with a Zod schema. TanStack Router accepts Zod schemas directly:
// src/app/routes/notes/index.tsx
const noteListSearchSchema = z.object({
filter: z.enum(['all', 'archived']).catch('all'),
query: z.string().catch(''),
})
export const Route = createFileRoute('/notes/')({
validateSearch: noteListSearchSchema,
loaderDeps: ({ search }) => ({ filter: search.filter }),
loader: ({ context, deps }) =>
context.queryClient.ensureQueryData(noteListQueryOptions(deps.filter)),
component: NoteListRoute,
})
function NoteListRoute() {
const search = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
return (
<NoteListRoot
filter={search.filter}
query={search.query}
onSearchChange={(next) => navigate({ search: (prev) => ({ ...prev, ...next }) })}
/>
)
}.catch(default)on every field, so a hand-edited or outdated URL falls back to defaults instead of showing an error page.loaderDepslists exactly the search fields the loader uses. Without it, the loader doesn't re-run when they change.- Search params are the web's equivalent of
SavedStateHandle: whatever must survive a reload goes there (see react-screen-architecture).
Loaders
Loaders only prefetch into the TanStack Query cache. The component reads the same data with useSuspenseQuery and the same query options (see react-server-state).
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(noteQueryOptions(params.noteId)),- The request starts as soon as navigation starts, in parallel with loading the route's code chunk, instead of after the component renders.
- Query options come from the feature's
api/<feature>Queries.ts.src/appmay import features, so this keeps the route file thin. - Prefetch only what the page needs before it can show anything useful. Secondary data (a sidebar count, a "related" list) is fetched by its component.
- Don't return data from the loader for the component to read with
Route.useLoaderData(). The Query cache is the single source of server data. - A loader that throws (a
DataErrorfrom the API) renders the route'serrorComponent. A missing resource shouldthrow notFound()(see react-error-handling).
Navigation
Links
<Link to="/notes/$noteId" params={{ noteId: note.id }} className="...">
{note.title}
</Link>- Use
<Link>from@tanstack/react-routerfor every user-initiated navigation, also in feature code and also to another feature's pages. The path is checked against the route tree, and no feature code is imported. - A clickable card or list row that navigates is a
<Link>around the content, not adivorbuttonwithonClick. - Use
searchon<Link>to link to filtered views:<Link to="/notes" search={{ filter: 'archived' }}>.
Navigation after an async result
When navigation depends on the result of an action (after a note is created, after logging in), the feature doesn't decide where to go. The route file passes a callback into the Root, the Root passes it into the view-model hook, and the hook calls it on success:
// route file
<NoteEditorRoot onSaved={(noteId) => navigate({ to: '/notes/$noteId', params: { noteId } })} />This keeps the feature reusable in another flow, and keeps the view-model testable without a router. It is the same idea as cross-feature callbacks in Compose Navigation.
Pending, Error and Not-Found States
| Situation | Handled by |
| Loader still running (after the pending delay) | route pendingComponent, or the router's defaultPendingComponent |
| Loader or component throws | route errorComponent, or defaultErrorComponent |
| URL matches no route | notFoundComponent on the root route |
| Route matches but the resource doesn't exist | throw notFound() in the loader, rendered by notFoundComponent |
- The default components live in
src/appand use translated text. - An
errorComponentgetsreset. Offer a retry that callsrouter.invalidate()(see react-error-handling). - Not-found keeps the URL the user entered. Don't redirect to a
/404route.
Protected Routes
Group authenticated pages under a pathless layout route:
src/app/routes/
login.tsx
_authenticated.tsx # guard
_authenticated/
notes/index.tsx # -> /notes
notes/$noteId.tsx # -> /notes/$noteId// src/app/routes/_authenticated.tsx
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ location }) => {
if (!(await ensureSession())) {
throw redirect({ to: '/login', search: { redirect: location.href } })
}
},
})ensureSession()comes fromcore/auth. It restores the session through the refresh endpoint and the httpOnly refresh cookie (see react-security).- The guard is a UX feature, not security. The backend must reject every unauthorized request on its own.
- After login, navigate to the
redirectsearch param only after checking it is a path inside the app (see react-security).
Testing
Route tests render the whole router at a URL with a memory history and a fresh QueryClient, and MSW for HTTP:
// src/app/renderAppAt.ts
/** Renders the whole app router starting at [path]. */
export function renderAppAt(path: string) {
const queryClient = createTestQueryClient()
const router = createAppRouter(queryClient, createMemoryHistory({ initialEntries: [path] }))
return { router, queryClient, ...renderWithProviders(<RouterProvider router={router} />, { queryClient }) }
}- Use
findBy*queries, because route loading is async. - Assert the URL with
router.state.location.pathnameandrouter.state.location.search. - See react-integration-testing for MSW and query conventions.
Hosting
The SPA and the API share one origin. There is no CORS, and the refresh cookie works with SameSite=Strict (see react-security).
| Environment | /api/** | Every other path |
| Production | the host proxies to the Spring backend | index.html (SPA fallback), static assets |
npm run dev / vite preview | Vite's server.proxy / preview.proxy forwards to the local backend (http://localhost:8080) | Vite serves the app with SPA fallback |
// vite.config.ts
const apiProxy = { '/api': { target: 'http://localhost:8080', changeOrigin: false } }
export default defineConfig({
server: { proxy: apiProxy },
preview: { proxy: apiProxy },
// ...
})- Every path that isn't
/api/**must serveindex.html, or reloading a deep link returns a 404 from the server. - No route in the app may start with
/api. - The production host also sets the security headers (see react-security).
Naming Conventions
| Thing | Convention | Example |
| Route file | path segments as files and folders, $param for params | routes/notes/$noteId.tsx |
| Pathless layout | _name.tsx + _name/ folder | _authenticated.tsx |
| Local route component | <Page>Route | NoteDetailRoute |
| Search schema | <page>SearchSchema in the route file | noteListSearchSchema |
| Navigation callback prop | on<Result> | onSaved, onNoteDeleted |
Checklist: Adding a Page
- Route file under
src/app/routes/, inside_authenticated/if the page needs a session - Search params validated with a Zod schema with
.catch()defaults - Loader prefetches the page's main query with
ensureQueryDataand the feature's query options, withloaderDepsfor search-dependent queries - Route component reads params and search and renders the feature Root with plain props
- Links to the page use
<Link>with typedtoandparams - Navigation after async results goes through callbacks from the route file
- A route test with
renderAppAtcovers the page loading at its URL
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

