TanStack Router is a fully type-safe, client-side router with first-class support for SSR, streaming, file-based routing, search param validation, and data loading. The codebase is a monorepo of ~38 packages. Core routing logic lives in router-core (framework-agnostic), with framework adapters (react-router, solid-router, vue-router) and a full-stack layer (start-*).
Key packages:
router-core— RouterCore class, route definition, matching, state, history, search params, SSRreact-router— React bindings (hooks, Link, error boundaries, useBlocker)router-generator— file-based route tree codegenrouter-plugin— Vite/Webpack plugin for code splittingvirtual-file-routes— programmatic route definition APIhistory— browser/hash/memory history abstractionstart-client-core/start-server-core— full-stack middleware + server functions- Validator adapters:
zod-adapter,valibot-adapter,arktype-adapter
class RouterCore<
in out TRouteTree extends AnyRoute,
in out TTrailingSlashOption extends TrailingSlashOption,
in out TDefaultStructuralSharingOption extends boolean,
in out TRouterHistory extends RouterHistory = RouterHistory,
in out TDehydrated extends Record<string, any> = Record<string, any>,
>Created via createRouter(options) (constructor is deprecated). Uses in out variance annotations on all type params for strict invariance.
{
routeTree?: TRouteTree
history?: TRouterHistory // default: createBrowserHistory()
basepath?: string // default: '/'
context?: InferRouterContext<TRouteTree>
caseSensitive?: boolean // default: false
trailingSlash?: 'always' | 'never' | 'preserve' // default: 'never'
notFoundMode?: 'root' | 'fuzzy' // default: 'fuzzy'
defaultPreload?: false | 'intent' | 'viewport' | 'render'
defaultPreloadDelay?: number // default: 50ms
defaultPendingMs?: number // default: 1000ms
defaultPendingMinMs?: number // default: 500ms
defaultStaleTime?: number // default: 0
defaultPreloadStaleTime?: number // default: 30_000ms
defaultGcTime?: number // default: 1_800_000ms (30min)
defaultPreloadGcTime?: number // default: 1_800_000ms
stringifySearch?: SearchSerializer
parseSearch?: SearchParser
search?: { strict?: boolean }
defaultStructuralSharing?: boolean
defaultViewTransition?: boolean | ViewTransitionOptions
scrollRestoration?: boolean | ((opts: { location }) => boolean)
dehydrate?: () => TDehydrated
hydrate?: (dehydrated: TDehydrated) => Awaitable<void>
routeMasks?: Array<RouteMask<TRouteTree>>
pathParamsAllowedCharacters?: Array<';' | ':' | '@' | '&' | '=' | '+' | '$' | ','>
rewrite?: LocationRewrite // { input?, output? } for basepath/subdomain rewrites
}Uses @tanstack/store Store<RouterState> on client, synchronous createServerStore on server. State accessed via router.state getter.
interface RouterState<TRouteTree extends AnyRoute = AnyRoute> {
status: "pending" | "idle";
loadedAt: number;
isLoading: boolean;
isTransitioning: boolean;
matches: Array<RouteMatch>; // currently active committed matches
pendingMatches?: Array<RouteMatch>; // matches being loaded for next location
cachedMatches: Array<RouteMatch>; // recently exited matches kept for gcTime
location: ParsedLocation<FullSearchSchema<TRouteTree>>;
resolvedLocation?: ParsedLocation; // last successfully loaded location
statusCode: number;
redirect?: AnyRedirect;
}interface RouteMatch {
id: string; // routeId + interpolatedPath + loaderDepsHash
routeId: string;
fullPath: string;
index: number;
pathname: string; // interpolated (params substituted)
params: Record<string, string>;
status: "pending" | "success" | "error" | "redirected" | "notFound";
isFetching: false | "beforeLoad" | "loader";
error: unknown;
loaderData?: unknown;
context: Record<string, unknown>; // merged routerContext + routeContext + beforeLoadContext
search: Record<string, unknown>;
loaderDeps: Record<string, unknown>;
cause: "preload" | "enter" | "stay";
preload: boolean;
invalid: boolean;
fetchCount: number;
abortController: AbortController;
meta?: Array<RouterManagedTag>; // from head()
links?: Array<RouterManagedTag>;
headScripts?: Array<RouterManagedTag>;
staticData: StaticDataRouteOption;
}interface RouterEvents {
onBeforeNavigate: NavigationEventInfo;
onBeforeLoad: NavigationEventInfo;
onLoad: NavigationEventInfo;
onResolved: NavigationEventInfo;
onBeforeRouteMount: NavigationEventInfo;
onRendered: NavigationEventInfo;
}
// NavigationEventInfo = { fromLocation?, toLocation, pathChanged, hrefChanged, hashChanged }Subscribe: router.subscribe('onResolved', fn) → returns unsubscribe.
commitLocation()→ pushes/replaces history entryload()→ callsbeforeLoad()which runsmatchRoutes(), storespendingMatches- Emits
onBeforeNavigate,onBeforeLoad loadMatches({ matches: pendingMatches })runs beforeLoad → loader lifecycleonReadycallback: commitspendingMatches → matches, moves exiting matches tocachedMatches, firesonLeave/onEnter/onStayhooks- Redirect handling:
navigate({ replace: true, ignoreBlocker: true, ...redirect.options })
getMatchedRoutes(pathname)— trie-based route matching- For each matched route serially (child depends on parent's search):
- Runs
validateSearch(route.options.validateSearch, parentSearch)→preMatchSearch - Runs
loaderDeps({ search })→ computesloaderDepsHash - Builds
matchId = route.id + interpolatedPath + loaderDepsHash - Reuses
existingMatchfrom state if available, else creates new match - Calls synchronous
route.options.context(ctx)→__routeContext
- Runs
- Returns array of matches
The central mechanism for global type inference:
// Declared in router-core:
export interface Register {
// router: typeof myRouter
}
// User augments in their app:
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}RegisteredRouter resolves to the user's concrete router type, enabling all hooks (useParams, useSearch, useLoaderData, etc.) to infer route-specific types without explicit type parameters.
Recursively flattens the route tree into a union of all route types:
type ParseRoute<TRouteTree, TAcc = TRouteTree> = TRouteTree extends {
types: { children: infer TChildren };
}
? unknown extends TChildren
? TAcc
: TChildren extends ReadonlyArray<any>
? ParseRoute<TChildren[number], TAcc | TChildren[number]>
: ParseRoute<
TChildren[keyof TChildren],
TAcc | TChildren[keyof TChildren]
>
: TAcc;type CodeRoutesById<TRouteTree> = {
[K in ParseRoute<TRouteTree> as K["id"]]: K;
};
type RoutesByPath<TRouteTree> = {
[K in ParseRoute<TRouteTree> as K["fullPath"]]: K;
};For file-based routes, these resolve from InferFileRouteTypes<TRouteTree> → fileRoutesById / fileRoutesByFullPath written by codegen.
// '/blog/$postId/comments/$commentId' → { required: 'postId' | 'commentId', optional: never }
// '/files/$...path' (splat) → { required: never, optional: 'path' }
type ParsePathParams<TPath extends string> = ...Syntax: $paramName (required), $paramName? (optional), $...name (splat/catch-all).
Each route carries a types phantom property encoding the full type tree:
interface RouteTypes<...> {
parentRoute: TParentRoute
path: TPath
to: TrimPathRight<TFullPath>
fullPath: TFullPath
id: TId
searchSchema: ResolveValidatorOutput<TSearchValidator>
fullSearchSchema: ResolveFullSearchSchema<TParentRoute, TSearchValidator>
params: TParams
allParams: ResolveAllParamsFromParent<TParentRoute, TParams>
routeContext: ResolveRouteContext<TRouteContextFn, TBeforeLoadFn>
allContext: ResolveAllContext<TParentRoute, TRouterContext, TRouteContextFn, TBeforeLoadFn>
loaderData: ResolveLoaderData<TLoaderFn>
loaderDeps: TLoaderDeps
children: TChildren
fileRouteTypes: TFileRouteTypes
}Written by the generator onto the root route's types.fileRouteTypes:
interface FileRouteTypes {
fileRoutesByFullPath: Record<string, Route>;
fullPaths: string; // union
to: string; // navigable "to" paths
fileRoutesByTo: Record<string, Route>;
id: string; // union of all IDs
fileRoutesById: Record<string, Route>;
}All RouterCore type params use in out for strict invariance — prevents widening/narrowing that would break type safety at router boundaries.
class BaseRoute<TRegister, TParentRoute, TPath, TFullPath, TCustomId, TId,
TSearchValidator, TParams, TRouterContext, TRouteContextFn, TBeforeLoadFn,
TLoaderDeps, TLoaderFn, TChildren, TFileRouteTypes, TSSR, TServerMiddlewares, THandlers>Created via createRoute(options) or createRootRoute(options).
Composed of BaseRouteOptions & UpdatableRouteOptions:
Core lifecycle options:
{
validateSearch?: SearchValidator // search param validation
context?: (ctx) => any // synchronous context derivation
beforeLoad?: (ctx) => any // async, serial — auth guards, context extension
loader?: (ctx) => any // async — data fetching
loaderDeps?: (opts: { search }) => TLoaderDeps // cache key from search
shouldReload?: boolean | ((match) => any)
ssr?: SSROption | ((ctx) => Awaitable<SSROption>)
}Component + behavior options:
{
component?: unknown
errorComponent?: unknown
pendingComponent?: unknown
notFoundComponent?: unknown
pendingMs?: number
pendingMinMs?: number
staleTime?: number
gcTime?: number
preloadStaleTime?: number
preloadGcTime?: number
search?: { middlewares?: Array<SearchMiddleware> }
onEnter?: (match) => void
onStay?: (match) => void
onLeave?: (match) => void
onCatch?: (error: Error) => void
head?: (ctx) => Awaitable<{ links?, scripts?, meta?, styles? }>
headers?: (ctx) => Awaitable<Record<string, string>>
params?: { parse?: ParseParamsFn, stringify?: StringifyParamsFn }
caseSensitive?: boolean
codeSplitGroupings?: Array<Array<'loader' | 'component' | 'pendingComponent' | ...>>
}During init():
const id = joinPaths([
parentRoute.id === rootRouteId ? "" : parentRoute.id,
customId,
]);
const fullPath =
id === rootRouteId ? "/" : joinPaths([parentRoute.fullPath, path]);
this._to = trimPathRight(fullPath); // removes trailing slashCode-based:
const rootRoute = createRootRoute({ component: RootComponent })
const blogRoute = createRoute({ getParentRoute: () => rootRoute, path: 'blog' })
const postRoute = createRoute({ getParentRoute: () => blogRoute, path: '$postId', loader: ... })
const routeTree = rootRoute.addChildren([blogRoute.addChildren([postRoute])])File-based: Routes defined as files in src/routes/, generator writes routeTree.gen.ts with createFileRoute(path)(options) calls wired together.
type SearchSchemaInput = { __TSearchSchemaInput__: "TSearchSchemaInput" };When validateSearch accepts a param typed with SearchSchemaInput, the input type becomes the "input schema" (for <Link search={...}> type-checking), separate from the output/validated type.
type SearchValidator<TInput, TOutput> =
| ValidatorFn<TInput, TOutput> // (input) => output
| ValidatorObj<TInput, TOutput> // { parse: (input) => output }
| ValidatorAdapter<TInput, TOutput> // { types: { input, output }, parse: (input) => output }
| StandardSchemaValidator<TInput, TOutput>; // { '~standard': { validate, types? } }Shape 1 — Plain function:
validateSearch: (search) => ({ page: Number(search.page) || 1 });Shape 2 — Object with parse (Zod v3, etc.):
validateSearch: z.object({ page: z.number() }); // has .parse()Shape 3 — ValidatorAdapter (library adapters):
validateSearch: { types: { input: ..., output: ... }, parse: (input) => output }Shape 4 — Standard Schema (Valibot, ArkType, Zod v4 via ~standard):
validateSearch: v.object({ page: v.number() });Runtime dispatch checks in order: '~standard' in v → 'parse' in v → typeof v === 'function'.
During matchRoutesInternal, each route's validated search is merged on top of parent's:
const strictSearch = validateSearch(route.options.validateSearch, {
...parentSearch,
});
preMatchSearch = { ...parentSearch, ...strictSearch };Type: fullSearchSchema = IntersectAssign<parentFullSearchSchema, thisRouteSchema>.
type SearchMiddlewareContext<TSearchSchema> = {
search: TSearchSchema;
next: (newSearch: TSearchSchema) => TSearchSchema;
};
type SearchMiddleware<TSearchSchema> = (
ctx: SearchMiddlewareContext<TSearchSchema>
) => TSearchSchema;Declared: search: { middlewares: [...] } on route options.
Chain built during buildLocation / applySearchMiddleware:
- All middlewares from root to leaf concatenated
- Final "terminal" middleware applies the navigation's
dest.searchtransform - Standard middleware pipeline:
middleware({ search, next })
Built-in helpers (searchMiddleware.ts):
retainSearchParams(keys | true); // carry specified keys forward across navigations
stripSearchParams(defaults | keys | true); // remove optional/default-valued keysconst defaultParseSearch = parseSearchWith(JSON.parse);
const defaultStringifySearch = stringifySearchWith(JSON.stringify, JSON.parse);Uses qss.decode() for query string parsing, then attempts JSON.parse on each string value. Customizable via stringifySearch / parseSearch router options.
router.options.context (global root context)
↓ merged
route.options.context() → match.__routeContext (sync, during matchRoutes)
↓ merged
route.options.beforeLoad() → match.__beforeLoadContext (async, serial per route)
↓ all merged into
match.context = { ...parentContext, ...__routeContext, ...__beforeLoadContext }
↓ available in
route.options.loader(ctx) ctx.context = full merged context
Each level receives parent's merged context. Types tracked via:
type ResolveAllContext<
TParentRoute,
TRouterContext,
TRouteContextFn,
TBeforeLoadFn,
> = Assign<
BeforeLoadContextParameter<TParentRoute, TRouterContext, TRouteContextFn>,
ContextAsyncReturnType<TBeforeLoadFn>
>;interface BeforeLoadContextOptions {
context: Expand<BeforeLoadContextParameter<...>> // parent + routeContext merged
params: ...
search: ...
// Can throw redirect() or notFound()
// Return object to extend context for downstream
}Runs serially — each route's beforeLoad awaits parent's. Used for auth guards, context extension.
interface LoaderFnContext {
abortController: AbortController
preload: boolean
params: Expand<ResolveAllParamsFromParent<...>>
deps: TLoaderDeps
context: Expand<ResolveAllContext<...>> // router + context() + beforeLoad() merged
location: ParsedLocation // intentionally no typed search (use loaderDeps)
parentMatchPromise: Promise<MakeRouteMatchFromRoute<TParentRoute>>
cause: 'preload' | 'enter' | 'stay'
route: AnyRoute
}Return value becomes match.loaderData.
loaderDeps?: (opts: { search: FullSearchSchema }) => TLoaderDepsReturn value is JSON.stringify()-ed into loaderDepsHash → part of matchId. This is the only sanctioned way to make the loader re-run on search param changes. Loader intentionally does NOT see typed search to discourage bypassing deps.
matchId = route.id + interpolatedPath + loaderDepsHash- Existing match with same
matchIdis reused unlessinvalid: true staleTime(default 0): data considered stale after this many ms → re-fetches on navigationgcTime(default 30min): exiting matches stay incachedMatchesbefore GCpreloadStaleTime(default 30s) /preloadGcTime(default 30min): separate for preloads- Uses
replaceEqualDeepstructural sharing for reference identity stability
clearExpiredCache() runs after each load():
const gcTime = match.preload
? (route.options.preloadGcTime ?? router.options.defaultPreloadGcTime)
: (route.options.gcTime ?? router.options.defaultGcTime ?? 5 * 60 * 1000);
const gcEligible = Date.now() - match.updatedAt >= gcTime;const TSR_DEFERRED_PROMISE = Symbol.for("TSR_DEFERRED_PROMISE");
type DeferredPromise<T> = Promise<T> & {
[TSR_DEFERRED_PROMISE]: {
status: "pending" | "resolved" | "rejected";
data?;
error?;
};
};
function defer<T>(
promise: Promise<T>,
options?: { serializeError? }
): DeferredPromise<T>;Usage:
loader: async () => {
const critical = await fetchCritical();
const streamed = defer(fetchSlow()); // NOT awaited
return { critical, streamed };
};Client uses <Await> component (calls useAwaited) to suspend on the deferred promise.
- For each match in order:
handleBeforeLoad(index)— serial, awaited per match - After all beforeLoads:
runLoader(matchId, index)— can run in parallel executeHead(matchId)runshead()/scripts()/headers()after loaderonReady()fires afterpendingMstimeout OR when first loader resolves → pending component renders
The core location-building utility (used by navigate, Link, preloading):
- Resolves current location from
pendingBuiltLocation || latestLocation matchRoutesLightweightto get currentfullPath,search,params- Resolves
frompath →nextToviaresolvePathWithBase - Merges/updates
nextParams getMatchedRoutes(nextTo)to resolve destination routes- Runs
params.stringifyon each route applySearchMiddleware({ search, dest, destRoutes })— runs middleware chain- Stringifies search, hash, state
- Returns
ParsedLocation(with optionalmaskedLocationif route masks apply)
If reloadDocument or absolute href → window.location.href. Otherwise calls buildAndCommitLocation(opts).
- If URL + state identical → calls
load()directly (no history push) - Handles
maskedLocation: stores real location instate.__tempLocation - Calls
history.push/replace(publicHref, state) - If no history subscribers → calls
load()directly
buildLocation(opts)→ locationmatchRoutes(next, { preload: true })- Adds unmatched to
cachedMatches loadMatches({ preload: true })without triggering navigation- Follows redirects recursively
Marks matches as { invalid: true } (with optional filter). If forcePending or error/notFound, resets to { status: 'pending' }. Then calls load().
type LinkProps = ActiveLinkOptions & LinkPropsChildren;
// children: ReactNode | ((state: { isActive, isTransitioning }) => ReactNode)Key props:
to— destination route path (type-safe)from— base for relative navigationparams,search,hash,state— all type-safe per routereplace— use history.replaceresetScroll— reset scroll on navigationviewTransition— enable view transitionsstartTransition— wrap in ReactstartTransitionpreload—false | 'intent' | 'render' | 'viewport'preloadDelay— ms before preloading on hover/focus (default from router)activeOptions—{ exact?, includeSearch?, includeHash?, explicitUndefined? }activeProps/inactiveProps— applied based on active statemask— location masking config
Preloading strategies:
'intent'— preloads onmouseEnter/focus; usespreloadDelaytimeout, cancels on leave'render'— preloads immediately viauseEffecton mount (once viahasRenderFetchedref)'viewport'— usesIntersectionObserver(100px rootMargin) to preload when visible
Active state detection:
- Exact:
exactPathTest(current, next, basepath) - Fuzzy:
current.startsWith(next) && ('/' follows OR same length) - Search comparison:
deepEqual(current.search, next.search, { partial: !exact }) - Hash comparison: optional,
current.hash === next.hash - Active adds
data-status="active"+aria-current="page" - Transitioning adds
data-transitioning="transitioning"
createLink(Comp) — creates a router-aware wrapper for any host component.
{
target: 'react' | 'solid' | 'vue' // default: 'react'
routesDirectory: string // default: './src/routes'
generatedRouteTree: string // default: './src/routeTree.gen.ts'
routeFilePrefix?: string // only include files with this prefix
routeFileIgnorePrefix: string // default: '-'
routeFileIgnorePattern?: string // regex
indexToken: TokenMatcher // default: 'index'
routeToken: TokenMatcher // default: 'route'
autoCodeSplitting?: boolean
verboseFileRoutes?: boolean
enableRouteTreeFormatting: boolean // default: true
disableTypes: boolean // default: false
quoteStyle: 'single' | 'double' // default: 'single'
semicolons: boolean // default: false
plugins?: Array<GeneratorPlugin>
tmpDir: string // default: '.tanstack/tmp'
customScaffolding?: {
routeTemplate?: string
lazyRouteTemplate?: string
}
}| File/Segment | Type | Effect |
|---|---|---|
__root.tsx |
Root route | Root of all routes |
index.tsx |
Index | Renders at exact parent path |
route.tsx |
Layout | Layout for parent path segment |
$param |
Dynamic | URL parameter segment |
$...name |
Splat | Catch-all parameter |
_prefix |
Pathless layout | No URL contribution, wraps children |
(group) |
Route group | Organization only, no URL contribution |
.lazy.tsx |
Lazy chunk | Split into separate bundle |
[bracket] |
Escape | Literal segment (escapes special meaning) |
-prefix |
Ignored | Excluded from route tree |
FsRouteType enum: '__root' | 'static' | 'layout' | 'pathless_layout' | 'lazy'
- Imports of each route file
.update({ id, path, getParentRoute })calls wiring each route into tree.lazy(() => import('./foo.lazy').then(d => d.Route))for lazy routes.update({ component: lazyRouteComponent(...) })for split components- Interface declarations:
FileRoutesByFullPath,FileRoutesByTo,FileRoutesById,FileRouteTypes routeTree = rootRoute._addFileChildren(children)._addFileTypes<FileRouteTypes>()- Module augmentation for
verboseFileRoutes: false
Generator maintains routeNodeCache (mtime-based) and only rewrites when changed.
Uses Babel AST transforms to produce three virtual module types per route file:
compileCodeSplitReferenceRoute — for each splittable key:
- Adds
const $$splitComponentImporter = () => import('./route?tsr-split=component') - Replaces prop value with
lazyRouteComponent($$splitComponentImporter, 'component') - For
loader: useslazyFn($$splitLoaderImporter, 'loader') - Runs dead-code elimination to remove unused imports
compileCodeSplitVirtualRoute — strips everything except targeted prop(s), exports as { SplitComponent as component }.
Created when a binding is referenced by both split and non-split props. Contains only shared bindings. The Route singleton is explicitly excluded to prevent duplication.
'loader' → lazyFn(...)
'component' → lazyRouteComponent(...)
'pendingComponent' → lazyRouteComponent(...)
'errorComponent' → lazyRouteComponent(...)
'notFoundComponent' → lazyRouteComponent(...)Per-route override: codeSplitGroupings: [['component', 'pendingComponent'], ['loader']].
interface DehydratedMatch {
i: string; // match.id
b?: any; // __beforeLoadContext
l?: any; // loaderData
e?: any; // error
u: number; // updatedAt
s: string; // status
ssr?: any;
}
interface DehydratedRouter {
manifest?: Manifest;
dehydratedData?: any;
lastMatchId?: string;
matches: Array<DehydratedMatch>;
}attachRouterServerSsrUtils sets up router.serverSsr:
dehydrate()— callscrossSerializeStream(seroval) onDehydratedRouter; each chunk enqueued toScriptBufferinjectHtml(html)— buffers HTML, emitsonInjectedHtmlinjectScript(script)— wraps in<script>, callsinjectHtmlsetRenderFinished()— calls listeners, lifts script barriertakeBufferedHtml()— drains injection buffer
ScriptBuffer: Queue of serialized script chunks with a "barrier" — scripts buffered until liftBarrier() called after HTML stream sends TSR_SCRIPT_BARRIER_ID marker. Uses queueMicrotask to batch. Scripts joined with ; and appended with ;document.currentScript.remove().
Wraps the app ReadableStream:
- Subscribes to
router.onInjectedHtml— buffers router HTML - Scans each chunk for
TSR_SCRIPT_BARRIER_ID→ callsliftScriptBarrier() - Scans for
</body>→ captures closing tags, flushes router HTML before them - After render: waits for serialization, flushes remaining bytes, closes stream
- 60-second timeouts for both serialization and stream lifetime
window.$_TSR holds TsrSsrGlobal:
interface TsrSsrGlobal {
router?: DehydratedRouter;
h: () => void; // signal hydration complete
e: () => void; // signal stream ended
c: () => void; // cleanup
p: (script: () => void) => void; // push to buffer or execute
buffer: Array<() => void>;
t?: Map<string, (value: any) => any>; // custom transformers
initialized?: boolean;
hydrated?: boolean;
streamEnded?: boolean;
}hydrate(router):
- Reads
window.$_TSR.router - Calls
router.matchRoutes(), loads route chunks in parallel hydrateMatch()populatesloaderData,__beforeLoadContext,error- Awaits hydration complete promise (resolves when
window.$_TSR.e()called)
'request'(default) — runs per HTTP request; access toRequest,pathname,context'function'— runs per server function call; access todata,context,method,signal
const authMiddleware = createMiddleware({ type: "request" })
.middleware([otherMiddleware])
.server(async ({ context, next, request, pathname }) => {
// server-side phase
const result = await next({ context: { ...context, user } });
return result;
})
.client(async ({ context, next, data }) => {
// client-side phase (function middleware only)
const result = await next({ context: { ...context } });
return result;
});Request middleware can return a Response directly to short-circuit.
Function middleware additionally has .inputValidator(schema) for input validation.
Each next({ context: additionalCtx }) merges into accumulated context via IntersectAssign. Types track this via:
AssignAllServerFnContext<TRegister, TMiddlewares, TSendContext, TServerContext>AssignAllServerRequestContext<TRegister, TMiddlewares, ...>
Standard onion model:
function executeMiddleware(middlewares, ctx) {
async function dispatch(i, ctx) {
const middleware = middlewares[i];
if (!middleware) return ctx;
return await middleware({ ...ctx, next: (ctx) => dispatch(i + 1, ctx) });
}
return dispatch(0, ctx);
}- Global:
createStart({ requestMiddleware: [...] })— runs for every request - Route-level:
server: { middleware: [...] }in route options - Tracked in
Set<AnyRequestMiddleware>(executedRequestMiddlewares) to prevent double-execution
interface RouterHistory {
location: HistoryLocation;
length: number;
subscribers: Set<(opts: { location; action }) => void>;
subscribe: (cb) => () => void;
push: (path, state?, opts?) => void;
replace: (path, state?, opts?) => void;
go: (index, opts?) => void;
back: (opts?) => void;
forward: (opts?) => void;
canGoBack: () => boolean;
createHref: (href) => string;
block: (blocker: NavigationBlocker) => () => void;
flush: () => void; // immediately commit pending URL change
destroy: () => void;
notify: (action) => void;
}
interface ParsedHistoryState {
__TSR_key?: string;
__TSR_index: number; // monotonically increasing, for back/forward detection
}The key optimization — batches rapid navigations:
let next: { href; state; isPush } | undefined;
let scheduled: Promise<void> | undefined;
const queueHistoryAction = (type, destHref, state) => {
currentLocation = parseHref(destHref, state); // update in-memory immediately
next = { href, state, isPush: next?.isPush || type === "push" };
if (!scheduled) {
scheduled = Promise.resolve().then(() => flush());
}
};
const flush = () => {
(next.isPush ? history.pushState : history.replaceState)(
next.state,
"",
next.href
);
next = undefined;
scheduled = undefined;
};Multiple navigate() calls in the same sync tick → one browser history entry. isPush uses || to prefer push if any call was push.
Wraps createBrowserHistory with custom parseLocation (reads from window.location.hash) and createHref (prepends #). Same microtask batching.
Pure in-memory for SSR/testing. Array-based push/replace/go. No microtask batching.
router-core augments @tanstack/history:
declare module "@tanstack/history" {
interface HistoryState {
__tempLocation?: HistoryLocation; // for route masking
__tempKey?: string; // masked location lifetime
__hashScrollIntoViewOptions?: boolean | ScrollIntoViewOptions;
}
}sessionStorage under key tsr-scroll-restoration-v1_3 (versioned).
type ScrollRestorationByKey = Record<
string,
Record<string, { scrollX: number; scrollY: number }>
>;
// outer key: route location key (TSR_key or href)
// inner key: CSS selector or 'window'window.history.scrollRestoration = 'manual'— disables browser native restoration- Document-level
scrolllistener (throttled 100ms) captures:windowfor document scroll[data-scroll-restoration-id="xxx"]elements by attribute- Fallback:
getCssSelector(element)(nth-child CSS path)
- Subscribes to router
onRenderedevent →restoreScroll()
- Cached entry exists AND
shouldScrollRestorationenabled → restore all registered elements - URL has hash →
element.scrollIntoView()using__hashScrollIntoViewOptions - Default →
window.scrollTo({ top: 0, left: 0 })+ scrollscrollToTopSelectorsto top
scrollRestoration?: boolean | ((opts: { location }) => boolean)
scrollRestorationBehavior?: ScrollToOptions['behavior'] // 'smooth' | 'instant' | 'auto'
getScrollRestorationKey?: (location: ParsedLocation) => string
scrollToTopSelectors?: Array<string | (() => Element | null | undefined)>type UseBlockerOpts<TRouter, TWithResolver extends boolean> = {
shouldBlockFn: (args: {
current: ShouldBlockFnLocation; // { routeId, fullPath, pathname, params, search }
next: ShouldBlockFnLocation;
action: HistoryAction;
}) => boolean | Promise<boolean>;
enableBeforeUnload?: boolean | (() => boolean);
disabled?: boolean;
withResolver?: TWithResolver;
};
function useBlocker<TWithResolver = false>(
opts: UseBlockerOpts<TRouter, TWithResolver>
): TWithResolver extends true ? BlockerResolver : void;When withResolver: true, returns a BlockerResolver:
type BlockerResolver =
| { status: 'blocked'; current; next; action; proceed: () => void; reset: () => void }
| { status: 'idle'; current: undefined; next: undefined; ... }When blocked, a Promise<boolean> is created. proceed() calls resolve(false) (allow navigation), reset() calls resolve(true) (cancel). UI shows blocked state until user decides.
function Block<TWithResolver extends boolean>(
opts: UseBlockerOpts & { children?: ReactNode | ((resolver) => ReactNode) }
): React.ReactNode;Blocker registered via history.block({ blockerFn, enableBeforeUnload }). beforeunload handler checks blockers for tab close. Back/forward detection uses __TSR_index comparison.
head?: (ctx: AssetFnContextOptions) => Awaitable<{
links?: Array<RouterManagedTag> // <link>
scripts?: Array<RouterManagedTag> // <script> in head
meta?: Array<RouterManagedTag> // <meta> + <title>
styles?: Array<RouterManagedTag> // <style>
}>type RouterManagedTag =
| { tag: "title"; attrs?: Record<string, any>; children: string }
| { tag: "meta" | "link"; attrs?: Record<string, any>; children?: never }
| { tag: "script"; attrs?: Record<string, any>; children?: string }
| { tag: "style"; attrs?: Record<string, any>; children?: string };- Tags from all active route matches collected
- Deepest-wins for meta: iterates from deepest match backwards, deduplicates by
name/propertyattribute — first seen (deepest) wins - Title: only the deepest title used
- JSON-LD (
script:ld+json): always appended (no dedup) - Links, styles, scripts: flat concatenation across all matches
- Manifest preload links (
rel: modulepreload) added from ViteManifest per route - Final dedup by
JSON.stringify(tag)viauniqBy
// Place in document shell:
<head>
<HeadContent />
</head>rootRoute(file: string, children?: VirtualRouteNode[]): VirtualRootRoute
index(file: string): IndexRoute
layout(file: string, children: VirtualRouteNode[]): LayoutRoute
layout(id: string, file: string, children: VirtualRouteNode[]): LayoutRoute
route(path: string, children: VirtualRouteNode[]): Route // path-only
route(path: string, file: string): Route // leaf
route(path: string, file: string, children: VirtualRouteNode[]): Route
physical(directory: string): PhysicalSubtree
physical(pathPrefix: string, directory: string): PhysicalSubtreetype VirtualRouteNode = IndexRoute | LayoutRoute | Route | PhysicalSubtree;
type IndexRoute = { type: "index"; file: string };
type LayoutRoute = {
type: "layout";
id?: string;
file: string;
children?: VirtualRouteNode[];
};
type Route = {
type: "route";
file?: string;
path: string;
children?: VirtualRouteNode[];
};
type PhysicalSubtree = {
type: "physical";
directory: string;
pathPrefix: string;
};physical(pathPrefix, directory) mounts a filesystem directory at a path prefix. The generator crawls it using normal physical route scanning — allows mixing programmatic and filesystem routes.
- Set
virtualRouteConfigintsr.config.jsonto a file path, or - Place
__virtual.[mc]?[jt]sin any subdirectory - Export can be
VirtualRouteSubtreeConfigvalue or async function returning one
Per-match rendering order:
-
CatchBoundary— wraps each match's componentgetResetKeyreturnsfetchCount— resets boundary when route re-fetchesgetDerivedStateFromPropscompares keys → auto-resets on navigation- Shows
errorComponent(route-specific or default)
-
CatchNotFound— wraps inside CatchBoundary- Re-throws non-not-found errors (propagate to parent error boundary)
- Reset key:
not-found-${pathname}-${status} - Shows
notFoundComponent
-
MatchInner— the actual route component render
function notFound(options: NotFoundError = {}): NotFoundError;
// NotFoundError = {
// data?: any
// throw?: boolean // throws instead of returns
// routeId?: RouteIds<...> // target specific route's boundary
// headers?: HeadersInit
// }Sets isNotFound = true as sentinel. Check with isNotFound(obj).
For errors:
- Route's own
errorComponent→ parent's → root's →DefaultErrorComponent
For 404s:
- Route's
notFoundComponent→ parent's → root's →DefaultGlobalNotFound(<p>Not Found</p>)
notFoundMode: 'fuzzy' (default) allows any route to handle 404s. 'root' forces all 404s to the root route.
Match ID uniqueness: matchId = route.id + interpolatedPath + JSON.stringify(loaderDeps) — same route with different params or deps gets different cache entries.
Structural sharing: replaceEqualDeep on search, params, state, loaderDeps during match creation preserves reference identity when values unchanged — prevents re-renders.
SearchSchemaInput trick: Separate input/output types — function parameter type (extending SearchSchemaInput) becomes the "input type" for search props, return type is the "output/validated type" in loaders and components.
Register augmentation: Central mechanism connecting route tree to all hooks globally — resolve RegisteredRouter['routeTree'] without explicit type parameters.
Microtask batching: Multiple navigate() calls in same tick produce one browser history entry. In-memory location updates immediately, browser URL updates on next microtask.
| Topic | Package | Path |
|---|---|---|
| Router class + state | router-core | src/router.ts |
| Route definition | router-core | src/route.ts |
| Route type utilities | router-core | src/routeInfo.ts |
| ParsePathParams + Link types | router-core | src/link.ts |
| Search params parsing | router-core | src/searchParams.ts |
| Search middleware | router-core | src/searchMiddleware.ts |
| Match loading | router-core | src/load-matches.ts |
| History abstraction | history | src/index.ts |
| Scroll restoration | router-core | src/scroll-restoration.ts |
| Not-found utilities | router-core | src/not-found.ts |
| SSR server dehydration | router-core | src/ssr/ssr-server.ts |
| SSR stream transform | router-core | src/ssr/ssr-client.ts |
| SSR types | router-core | src/ssr/types.ts |
| File-based route config | router-generator | src/config.ts |
| FS route scanner | router-generator | src/filesystem/physical/getRouteNodes.ts |
| Route tree generator | router-generator | src/generator.ts |
| Code splitting transforms | router-plugin | src/core/code-splitter/compilers.ts |
| Virtual routes API | virtual-file-routes | src/api.ts |
| Link component | react-router | src/link.tsx |
| useBlocker | react-router | src/useBlocker.tsx |
| CatchBoundary | react-router | src/CatchBoundary.tsx |
| CatchNotFound | react-router | src/not-found.tsx |
| HeadContent + useTags | react-router | src/headContentUtils.tsx |
| Start middleware | start-client-core | src/createMiddleware.ts |
| Middleware execution | start-server-core | src/createStartHandler.ts |