When to use: Any app of more than a handful of routes; the default recommendation for new projects.
How: Put route files under src/routes/, name a root layout __root.tsx, name children by URL segment or nested folders. The Vite/Rspack router plugin watches the folder and emits routeTree.gen.ts, imported into createRouter({ routeTree }).
Trade-offs: Requires the build plugin running; file naming conventions (., _, $, ()) take some learning but scale well.
See: Ch 1, 3, 5, 6, 7, 10
When to use: Very small apps, tests, or when a build plugin is undesirable or routes must be constructed dynamically.
How: Call createRootRoute(), then createRoute({ getParentRoute, path, component }) per route, and rootRoute.addChildren([...]) to assemble the tree passed to createRouter.
Trade-offs: No codegen step, but the tree is hand-maintained and doesn't scale past a small route count.
See: Ch 2, 4, 9, 11, 13, 14, 15, 18, 20
When to use: A group of routes needs a shared wrapper (nav, sidebar) or a shared precondition (auth check) without adding a URL segment.
How: Create a route file prefixed with _ (e.g. _auth.tsx) with an <Outlet /> in its component; child routes are named _auth.dashboard.tsx, etc. Put guard logic in the pathless route's beforeLoad.
Trade-offs: Keeps auth/layout logic in one place, but nesting depth in filenames can get long for deeply grouped routes.
See: Ch 17, 5, 6
When to use: Protecting a whole section of the app behind login.
How: In the pathless auth layout's beforeLoad({ context, location }), check an auth context/store; if not authenticated, throw redirect({ to: '/login', search: { redirect: location.href } }).
Trade-offs: Runs on every navigation into the guarded subtree, so it must stay cheap (read from context, not a network call) unless paired with a loader-level fetch.
See: Ch 17
defer() + <Await>When to use: A route has a fast "critical" data need (page shell) and a slow "non-critical" one (comments, related content) that shouldn't block first paint.
How: In loader, await the fast data and return the slow promise wrapped in defer(fetchSlowThing()) without awaiting it; render the fast data immediately and wrap the slow value in <Await promise={...}> with a fallback.
Trade-offs: Improves perceived performance but adds a loading-state branch to maintain in the component; combine with SSR streaming to also avoid the client waterfall.
See: Ch 9, 8
When to use: Server-rendered apps where some route data is slow and you don't want to block the entire HTML response on it.
How: A custom entry-server.tsx renders to a stream (not a string), and deferred loader promises are flushed to the client as they resolve, hydrating in place.
Trade-offs: Needs a streaming-capable server runtime (Node stream/Web stream) and a bit more server plumbing than a synchronous renderToString SSR setup.
See: Ch 8, 7
When to use: Route search params need runtime validation and static typing, and the team already uses Zod, Valibot, or ArkType elsewhere.
How: Pass the schema (or a router validator-adapter wrapper around it) to a route's validateSearch; the router calls it on every navigation and exposes the parsed, typed result via Route.useSearch().
Trade-offs: All three libraries produce the same developer experience through the router's adapter layer, so the choice is really about which schema library the rest of the codebase already standardizes on.
See: Ch 12
When to use: A route should behave sensibly even when the URL omits an optional search param, or a caller navigates with a partial search object.
How: In validateSearch, wrap each field in .catch(defaultValue) (e.g. z.number().catch(1)) instead of .optional(); the schema resolves to a concrete value whenever the incoming param is missing or fails validation, so Route.useSearch() never returns undefined for that key.
Trade-offs: Keeps components simple (no ?? 1 scattered around), but .catch() silently swallows malformed input instead of surfacing it, so it's not the right choice for a param whose invalidity should be visible to the user.
See: Ch 11, 12
When to use: Server data should be cached, deduped, and shared between a route's loader (for preloading) and its component (for reactive re-fetching).
How: Define a queryOptions(...) object once; call queryClient.ensureQueryData(options) in the route loader, and useSuspenseQuery(options) in the component so both read the same cache entry.
Trade-offs: Requires wiring a QueryClient into the router context, but avoids duplicating fetch logic between loader and render and gets Query's caching/retry/invalidation for free.
See: Ch 6, 18, 24, 19
When to use: A large app wants route ownership, UI, and data-fetching logic split into independently buildable/publishable packages inside a pnpm/turborepo workspace.
How: A router package owns createRouter/routeTree/route definitions and re-exports the router; an app package imports it and renders <RouterProvider>; optional post-feature/post-query packages hold UI components and query options respectively, consumed by router via workspace:*.
Trade-offs: Clear ownership boundaries and independent builds at the cost of more package.json/tsconfig/vite.config boilerplate per package; lazy-loading route components across the package boundary (Ch 23) further reduces the app bundle at the cost of an extra async import.
See: Ch 22, 23, 24
When to use: A route should visually "float" over another (e.g. a photo detail modal over a gallery) while the browser URL reflects only the route the user thinks they're on.
How: Register a global mask with createRouteMask({ routeTree, from: '/photos/$photoId/modal', to: '/photos/$photoId', params: true }) and pass it to createRouter({ routeMasks: [...] }); a per-Link mask prop also exists but the example itself notes the global router-level mask is generally the safer choice.
Trade-offs: Powerful for modal/gallery UX, but two URLs now describe one state, opening the masked URL directly (e.g. in a new tab) de-masks it and shows the real underlying route instead of the modal, so deep-linking behavior needs explicit test coverage.
See: Ch 13