When to use: File-based for nearly every project; code-based only when the filesystem genuinely doesn't fit (highly dynamic/programmatic route generation, no build step).
How: File-based routing lives under src/routes/, generated via the tanstackRouter bundler plugin (Vite/Rspack/Webpack/Esbuild) or the tsr CLI into routeTree.gen.ts. Code-based routing builds the same tree manually with createRoute({ getParentRoute, path }) + addChildren().
Trade-offs: File-based needs zero manual getParentRoute wiring, gets automatic code-splitting, and scales to 40-50+ routes without file bloat. Code-based gives full programmatic control but the docs explicitly discourage it for most apps due to the extra boilerplate for equivalent results.
When to use: Pick per-directory based on nesting depth and team preference; freely mix within one project.
How: Flat (dot-notation, e.g. posts.$postId.tsx) for shallow trees or when file count in one folder is manageable; Directory (posts/$postId.tsx) for deep or heavily-nested sections; Virtual File Routes (@tanstack/virtual-file-routes) when you need to remap URL structure independently of file locations, falling back to physical() for subtrees that don't need customization.
Trade-offs: Flat files are easy to grep but get long filenames; directories group related route files (route/lazy/loader) but add folder depth; virtual routes add an indirection layer but decouple URL design from file layout entirely.
When to use: Small-to-medium apps without a need for persistence, mutation APIs, or cross-route data sharing beyond the router's own cache.
How: Return data from a route's loader; read it via Route.useLoaderData(). Use loaderDeps to key the cache on specific search params, staleTime/gcTime to tune freshness and retention.
Trade-offs: Zero extra dependency, integrates automatically with preloading; but lacks mutation helpers, persistence, and cross-tab sync that a dedicated data library provides.
When to use: Apps that need mutation APIs, optimistic updates, persistence, or a cache shared across many non-route-triggered reads/writes.
How: In loader, call the library's prefetch/ensure API (e.g. queryClient.ensureQueryData(queryOptions)) without returning the data; in the component, read the same query via the library's own hook (e.g. useSuspenseQuery). Wire dehydrate/hydrate/Wrap on createRouter for SSR. For TanStack Query specifically, prefer @tanstack/react-router-ssr-query's setupRouterSsrQueryIntegration over doing this by hand.
Trade-offs: More setup and an extra dependency, but unlocks mutation state, retries, and caching features the router's built-in cache doesn't offer. Always create the external client per-request inside createRouter/getRouter(), never at module scope, to avoid cross-request state leakage on the server.
When to use: A route has both fast, critical data and slow, non-critical data, and you don't want the slow data to block the whole route's first render.
How: Return the slow data as an unawaited promise from loader, await only the fast data. Render the promise with <Await promise={...} fallback={...}> (or React 19's use()). Requires Streaming SSR setup for the promise to actually stream server-side.
Trade-offs: Better perceived performance than a single pendingComponent for the whole route, but adds complexity (suspense boundaries, streaming SSR wiring) and only pays off when there's a genuine fast/slow data split.
When to use: Any route that reads or writes URL search params, essentially all app state that should be shareable/bookmarkable.
How: Define validateSearch on the route, ideally via a schema library adapter (Zod, Valibot, ArkType, Effect/Schema). Prefer .catch()-style fallbacks over pure .default() so malformed params degrade gracefully instead of throwing.
Trade-offs: Schema validation adds a dependency and a small runtime cost per navigation, but is the only reliable way to keep user-editable URL state type-safe and resilient to manual URL tampering.
When to use: A search param should always be carried through every generated link (e.g. a rootValue), or should always be hidden from the URL when it equals its default (e.g. sort=newest).
How: Register retainSearchParams([keys]) and/or stripSearchParams(defaults) (or a custom middleware) via a route's search.middlewares, typically on the root route for app-wide effect.
Trade-offs: Centralizes behavior that would otherwise need to be repeated at every <Link>/navigate() call site; only affects link generation and post-validation navigation, not raw incoming URLs.
When to use: You want a route (e.g. a modal showing photo detail) to internally navigate to a real nested route while the browser address bar shows a simpler parent URL.
How: Pass mask to <Link>/navigate() for one-off cases, or configure routeMasks + createRouteMask() on the router for rules that should apply consistently.
Trade-offs: Purely a client-side presentation layer; a copied/shared URL always reveals the real underlying route, and masked state is not restored on reload unless unmaskOnReload: true is set. Don't rely on it for anything security-relevant.
beforeLoadWhen to use: Any route (or subtree) that requires an authenticated/authorized user.
How: Throw redirect({ to: '/login', search: { redirect: location.href } }) inside beforeLoad on the shared parent route; child routes' beforeLoad never runs if the parent's throws first. Use isRedirect() when wrapping the check in try/catch to avoid mishandling the intentional throw. Bridge React-hook-derived auth state into router context (via <RouterProvider context={...}>) since hooks can't run inside beforeLoad.
Trade-offs: Guarding the parent once protects the entire subtree with no per-leaf-route repetition; but auth state sourced from React context needs an explicit bridging step, it isn't automatically available inside beforeLoad.
When to use: Both unmatched pathnames (automatic) and missing resources (e.g. a deleted post) need a not-found UI.
How: Throw notFound() from a loader (preferred over throwing from a component, to preserve typed loader data and avoid flicker) and define notFoundComponent on the relevant route(s). Use notFoundMode: 'root' for one consistent not-found page app-wide, or the default 'fuzzy' to preserve parent layout context.
Trade-offs: The deprecated NotFoundRoute class requires an <Outlet> on the parent and doesn't support layouts, always migrate to notFoundComponent instead.
When to use: Any cross-cutting dependency (data client, auth state, feature flags, singleton service) that loaders/beforeLoad across many routes need access to.
How: Type it via createRootRouteWithContext<T>(), supply initial values in createRouter({ context }), extend it per-subtree by returning fields from a route's beforeLoad.
Trade-offs: Avoids direct imports scattered across loader files, but context sourced from React hooks needs to be threaded in through <RouterProvider context={...}>, and router.invalidate() must be called manually when externally-tracked context (e.g. an auth listener) changes.
When to use: Almost every production app benefits from some preload strategy; the choice depends on link density and UX goals.
How: defaultPreload: 'intent' (hover/touch) is the default high-value choice; 'viewport' for below-the-fold links using Intersection Observer; 'render' for routes that are almost always needed immediately.
Trade-offs: Preloading trades some extra network/compute for perceived speed; when combined with an external cache (TanStack Query), set defaultPreloadStaleTime: 0 so the external library's freshness logic, not the router's, governs reuse.
When to use: Any file-based routing project on a supported bundler; almost always the right default.
How: Enable autoCodeSplitting: true in the bundler plugin. Manually, use .lazy.tsx files (createLazyFileRoute) to isolate component/errorComponent/pendingComponent/notFoundComponent from critical config (loader, beforeLoad, validateSearch).
Trade-offs: Never split the loader itself unless there's a strong reason, it's already an async boundary and splitting it adds an extra network round-trip that delays data fetching, hurting preload-on-hover performance.
When to use: A component only needs part of router/search/loader state and re-renders too often.
How: Use the select option on hooks like useSearch, useRouterState to subscribe to a narrow slice. If select computes a new object/array each call, also enable structuralSharing: true (per-hook or defaultStructuralSharing globally) so deep-equal results don't still trigger re-renders.
Trade-offs: Structural sharing requires the select result to be JSON-compatible; non-serializable values (e.g. Date instances) fail type-checking when combined with structuralSharing: true.
When to use: Locale-prefixed routing (/en/about, /fr/about) without duplicating the route tree per locale.
How: Define routes with {-$locale} (e.g. /{-$locale}/about), validate the locale in beforeLoad with a type guard, and either manage translations manually or integrate a dedicated library (Paraglide) via the router's rewrite option for automatic locale detection/URL localization.
Trade-offs: The manual approach requires no extra dependency but scales poorly for large translation sets; a dedicated i18n library adds setup complexity but provides type-safe message catalogs and SSR-aware locale middleware.
When to use: Locale prefixes, subdomain-based multi-tenancy, or legacy URL migration, where you don't want the route tree itself to encode the concern.
How: Configure input/output rewrite functions on createRouter({ rewrite }); input transforms the browser URL into the router's internal URL before matching, output does the reverse for what's displayed. Use composeRewrites to combine multiple rewrites (output rewrites apply in reverse order of input).
Trade-offs: Keeps route definitions clean of orthogonal URL concerns, but adds an extra mental layer (location.href vs location.publicHref) that other code touching URLs must account for.