agentleFS
PL-Coding-GmbHPL-Coding-GmbH/Skills/skills/react-routing/SKILL.md

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

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
  1. Very specific:

    Search params are the web's equivalent of SavedStateHandle: whatever must survive a reload goes there (see react-screen-architecture).

    line 146
  2. Very specific:

    Don't return data from the loader for the component to read with Route.useLoaderData().

    line 162
  3. Very specific:

    Use <Link> from @tanstack/react-router for every user-initiated navigation, also in feature code and also to another feature's pages.

    line 177
  4. Very specific:

    Use search on <Link> to link to filtered views: <Link to="/notes" search={{ filter: 'archived' }}>.

    line 179
  5. Very specific:

    After login, navigate to the redirect search param only after checking it is a path inside the app (see react-security).

    line 235
  6. Very specific:

    Use findBy* queries, because route loading is async.

    line 253
  7. Very specific:

    Every path that isn't /api/ must serve index.html, or reloading a deep link returns a 404 from the server.

    line 279
  8. Very specific:

    No route in the app may start with /api.

    line 280

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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 Register augmentation makes every <Link>, useNavigate and redirect in the app type-checked against the route tree, including in feature code that never imports src/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.
  • loaderDeps lists 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/app may 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 DataError from the API) renders the route's errorComponent. A missing resource should throw notFound() (see react-error-handling).

Navigation

Links

<Link to="/notes/$noteId" params={{ noteId: note.id }} className="...">
  {note.title}
</Link>
  • Use <Link> from @tanstack/react-router for 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 a div or button with onClick.
  • Use search on <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

SituationHandled by
Loader still running (after the pending delay)route pendingComponent, or the router's defaultPendingComponent
Loader or component throwsroute errorComponent, or defaultErrorComponent
URL matches no routenotFoundComponent on the root route
Route matches but the resource doesn't existthrow notFound() in the loader, rendered by notFoundComponent
  • The default components live in src/app and use translated text.
  • An errorComponent gets reset. Offer a retry that calls router.invalidate() (see react-error-handling).
  • Not-found keeps the URL the user entered. Don't redirect to a /404 route.

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 from core/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 redirect search 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.pathname and router.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
Productionthe host proxies to the Spring backendindex.html (SPA fallback), static assets
npm run dev / vite previewVite'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 serve index.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

ThingConventionExample
Route filepath segments as files and folders, $param for paramsroutes/notes/$noteId.tsx
Pathless layout_name.tsx + _name/ folder_authenticated.tsx
Local route component<Page>RouteNoteDetailRoute
Search schema<page>SearchSchema in the route filenoteListSearchSchema
Navigation callback propon<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 ensureQueryData and the feature's query options, with loaderDeps for search-dependent queries
  • Route component reads params and search and renders the feature Root with plain props
  • Links to the page use <Link> with typed to and params
  • Navigation after async results goes through callbacks from the route file
  • A route test with renderAppAt covers the page loading at its URL

Download now (ZIP)

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.