packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md
Internal documentation for the TanStack Router code-splitting system. This covers the Babel-based transform pipeline that splits route files into lazily-loaded modules (typically separate chunks) at build time.
TanStack Router's automatic code-splitting transforms a single route file into multiple modules at build time so that heavy route properties (components, loaders) are loaded on demand rather than eagerly.
Given a route file like:
// src/routes/about.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
loader: () => fetchAboutData(),
component: () => <div>About page</div>,
})
The plugin produces up to three modules from the same physical file:
| Module | What it contains |
|---|---|
about.tsx (reference) | Route config with component replaced by lazyRouteComponent($$splitComponentImporter, 'component') and an injected const $$splitComponentImporter = () => import('about.tsx?tsr-split=component') |
about.tsx?tsr-split=component (virtual) | Only the component function, exported as component |
about.tsx?tsr-shared=1 (shared) | Optional; created only when bindings are shared between split and non-split properties |
The bundler (Vite, Webpack, Rspack) treats query-parameterized imports as
distinct modules, so about.tsx and about.tsx?tsr-split=component are
separate entries in the module graph — each transformed differently by the
corresponding plugin.
Without code-splitting, every route's component tree ships in the initial bundle. For large applications with dozens of routes, this means megabytes of JavaScript the user downloads but never executes until they navigate to that specific route. Automatic code-splitting solves this by:
import())flowchart TB
subgraph "Physical File System"
RF["src/routes/about.tsx
(original source)"]
end
subgraph "Plugin 1: compile-reference-file"
P1["Transform about.tsx
• computeSharedBindings()
• compileCodeSplitReferenceRoute()"]
end
subgraph "Plugin 2: compile-virtual-file"
P2["Transform about.tsx?tsr-split=component
• compileCodeSplitVirtualRoute()"]
end
subgraph "Plugin 3: compile-shared-file"
P3["Transform about.tsx?tsr-shared=1
• compileCodeSplitSharedRoute()"]
end
SBM[("sharedBindingsMap
(closure-shared Map)")]
RF --> P1
P1 -->|"writes shared bindings"| SBM
P1 -->|"emits import('...?tsr-split=...')"| P2
P1 -->|"emits import('...?tsr-shared=1')\n(only when needed)"| P3
SBM -->|"reads shared bindings"| P2
SBM -->|"reads shared bindings"| P3
subgraph "Bundler Output"
REF["Reference module
(Route config + lazy wrappers)"]
SPLIT["Split module(s)
(component, errorComponent, etc.)"]
SHARED["Shared module
(shared bindings, if any)"]
end
P1 --> REF
P2 --> SPLIT
P3 --> SHARED
unpluginRouterCodeSplitterFactory (in router-code-splitter-plugin.ts)
returns an array of 3 plugins that share a closure containing:
sharedBindingsMap: Map<string, Set<string>> — maps normalized file paths
to their computed shared binding namesuserConfig — resolved plugin configurationtanstack-router:code-splitter:compile-reference-fileFilter: File IDs matching /\.(m|c)?(j|t)sx?$/ whose code contains one of
createFileRoute(, createRootRoute(, or createRootRouteWithContext(.
Excludes file IDs that include tsr-split or tsr-shared.
What it does:
codeSplitGroupings if specified inlinecomputeSharedBindings() and caches the result in sharedBindingsMapcompileCodeSplitReferenceRoute() which:
lazyRouteComponent() / lazyFn() wrappersimport() expressions pointing to ?tsr-split=<encoded> URLsimport { ... } from '...?tsr-shared=1'export { ... } from '...?tsr-shared=1'Vite hooks:
configResolved: Validates plugin ordering (router must come before JSX
transformation plugins like @vitejs/plugin-react)applyToEnvironment: Scopes the plugin to a specific Vite environment when
plugin.vite.environmentName is configured (needed for TanStack Start which
creates separate client/SSR plugin instances)tanstack-router:code-splitter:compile-virtual-fileFilter: File IDs containing tsr-split.
What it does:
?tsr-split=<encoded> query parameterdecodeIdentifier() to get the list of properties this
virtual file should export (e.g., ['component'])sharedBindingsMap for the base filecompileCodeSplitVirtualRoute() which:
import { ... } from '...?tsr-shared=1' if shared bindings existVite hooks:
applyToEnvironment: Same environment scoping behavior as the reference
plugin.tanstack-router:code-splitter:compile-shared-fileFilter: File IDs containing tsr-shared.
What it does:
sharedBindingsMapcompileCodeSplitSharedRoute() which:
export wrappers from kept declarationsexport { ... } statement for all shared bindingsVite hooks:
applyToEnvironment: Same environment scoping behavior as the reference
plugin.The system does not create actual virtual files. Instead, it leverages the fact that bundlers (Vite, Webpack, Rspack) treat the same physical file with different query parameters as distinct module IDs:
src/routes/about.tsx → Module A (reference)
src/routes/about.tsx?tsr-split=component → Module B (virtual/split)
src/routes/about.tsx?tsr-shared=1 → Module C (shared)
All three modules start from the same source code — the bundler reads the
physical file each time. Each plugin's transform filter matches on different
query parameters, so each module gets a different transformation applied.
The ?tsr-split query parameter value is an encoded representation of the
split grouping. The encoding (in path-ids.ts) works as follows:
--- delimiter/ → --slash--? → --question--# → --hash--For example, a grouping of ['component'] becomes ?tsr-split=component.
A combined grouping of ['component', 'errorComponent'] becomes
?tsr-split=component---errorComponent.
The decoding is the inverse: decodeIdentifier() reverses the token
replacements and splits on ---.
Note: this encoding is not a general-purpose reversible scheme (it maps spaces
to _ and decodes _ back to spaces). It's safe here because split grouping
strings are known route option keys like component and loader.
When the reference compiler emits:
const $$splitComponentImporter = () => import('about.tsx?tsr-split=component')
The bundler:
about.tsx?tsr-split=component to the physical file about.tsx
with the query parameter preservedabout.tsxtsr-split in ID) and
transforms it into a module that exports only componentConsider a route file with a module-level variable used by both the loader
(non-split by default) and the component (split by default):
const cache = new Map()
function getCached(key: string) {
return cache.get(key)
}
function setCached(key: string, val: unknown) {
cache.set(key, val)
}
export const Route = createFileRoute('/cached')({
loader: async () => {
setCached('data', await fetch('/api').then((r) => r.json()))
return getCached('data')
},
component: () => <div>{JSON.stringify(getCached('data'))}</div>,
})
Without the shared bindings system, cache and getCached would be
duplicated — one copy in the reference module (used by loader) and another
in the split component module. Each would have its own Map instance, so
setCached in the loader would write to a different map than getCached in
the component reads from. This is a correctness bug, not just a bundle
size issue.
computeSharedBindings() identifies bindings that are referenced by properties
in 2+ distinct "groups" (where each split grouping index is one group, and all
non-split properties form group -1). These bindings are extracted into a
third virtual module (?tsr-shared=1) that both the reference module and the
split modules import from, ensuring a single shared instance.
computeSharedBindings() WorksLocation: src/core/code-splitter/compilers.ts (computeSharedBindings)
Algorithm:
Collect local bindings — Cheap loop over program.body to find all
module-level const/let/var/function/class declarations. Deletes
Route from the set (must never be extracted). If no local bindings
remain, returns empty set immediately (fast path).
Find route options — Uses babel.traverse to locate the
createFileRoute('/')({ ... }) call and extract the options object.
Fast path: group count check — Iterates over route option properties, tracking whether any non-split properties exist, and counting distinct split group indices.
Build dependency graph — buildDependencyGraph() creates a map from
each local binding to the set of other local bindings it references. Uses
collectIdentifiersFromNode() (a fast recursive walker, much cheaper than
babel.traverse) for each declaration.
Attribute bindings to groups — For each route option property:
Identify shared bindings — Any binding appearing in 2+ distinct groups is shared.
Handle destructured declarators — If bindings from the same
const { a, b } = fn() appear in different groups, the entire declarator
must be shared (can't split the initialization).
Expand destructured declarations — If any binding in a destructured pattern is shared, all bindings in that pattern must be shared.
Remove Route-dependent bindings — removeBindingsDependingOnRoute()
builds a reverse dependency graph, walks backwards from Route via BFS,
and removes any shared binding that transitively depends on Route. This
prevents Route duplication in the shared module.
Input (shared-function.tsx):
import { createFileRoute } from '@tanstack/react-router'
const cache = new Map()
function getCached(key: string) {
return cache.get(key)
}
function setCached(key: string, val: unknown) {
cache.set(key, val)
}
export const Route = createFileRoute('/cached')({
loader: async () => {
setCached('data', await fetch('/api').then((r) => r.json()))
return getCached('data')
},
component: () => <div>{JSON.stringify(getCached('data'))}</div>,
})
Analysis:
loader is non-split (group -1), references: setCached, getCachedcomponent is split (group 0), references: getCachedgetCached appears in groups -1 and 0 → sharedgetCached depends on cache → cache is transitively sharedsetCached appears only in group -1 → not sharedOutput — Reference file (shared-function.tsx):
import { getCached, cache } from 'shared-function.tsx?tsr-shared=1'
const $$splitComponentImporter = () =>
import('shared-function.tsx?tsr-split=component')
import { lazyRouteComponent } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
function setCached(key: string, val: unknown) {
cache.set(key, val)
}
export const Route = createFileRoute('/cached')({
loader: async () => {
setCached('data', await fetch('/api').then((r) => r.json()))
return getCached('data')
},
component: lazyRouteComponent($$splitComponentImporter, 'component'),
})
Output — Split module (shared-function.tsx?tsr-split=component):
import { getCached } from 'shared-function.tsx?tsr-shared=1'
const SplitComponent = () => <div>{JSON.stringify(getCached('data'))}</div>
export { SplitComponent as component }
Output — Shared module (shared-function.tsx?tsr-shared=1):
const cache = new Map()
function getCached(key: string) {
return cache.get(key)
}
export { cache, getCached }
Note how setCached is not in the shared module — it's only used by
loader (non-split), so it stays in the reference file. The reference file
imports cache (needed by setCached) from the shared module.
The three plugins share a closure containing sharedBindingsMap:
Plugin 1 (reference): computeSharedBindings() → sharedBindingsMap.set(id, bindings)
Plugin 2 (virtual): sharedBindingsMap.get(baseId) → pass to compileCodeSplitVirtualRoute()
Plugin 3 (shared): sharedBindingsMap.get(baseId) → pass to compileCodeSplitSharedRoute()
This works because all three plugins run in the same JavaScript process and the map is captured by closure. In typical builds, the reference module is transformed first and then the bundler resolves the emitted dynamic imports, which triggers transformations for the virtual/shared module IDs.
Defined in constants.ts:
export const defaultCodeSplitGroupings: CodeSplitGroupings = [
['component'], // group 0 → separate module ID (often a separate chunk)
['errorComponent'], // group 1 → separate module ID (often a separate chunk)
['notFoundComponent'], // group 2 → separate module ID (often a separate chunk)
]
Important: loader is not split by default. It stays in the reference
file alongside non-split properties like beforeLoad, validateSearch,
context, etc.
Only these 5 properties can be split (defined in splitRouteIdentNodes):
loadercomponentpendingComponenterrorComponentnotFoundComponentAny property not in splitRouteIdentNodes is never split, regardless of
configuration.
Each array within the groupings array represents one split module ID. Properties in the same inner array end up in the same split module:
// Default: 3 separate split module IDs (often 3 separate chunks)
;[['component'], ['errorComponent'], ['notFoundComponent']][
// Combined: component + loader in one module, error in another
(['component', 'loader'], ['errorComponent'])
][
// All in one module
['component', 'loader', 'errorComponent', 'notFoundComponent']
]
There are three levels of configuration, applied with this priority:
Per-route inline (highest priority):
export const Route = createFileRoute('/about')({
codeSplitGroupings: [['component', 'loader']],
component: ...,
loader: ...,
})
Per-route via plugin (splitBehavior callback):
tanstackRouter({
codeSplittingOptions: {
splitBehavior: ({ routeId }) => {
if (routeId === '/dashboard') return [['component', 'loader']]
return undefined // use default
},
},
})
Global default (defaultBehavior):
tanstackRouter({
codeSplittingOptions: {
defaultBehavior: [['component', 'loader'], ['errorComponent']],
},
})
findIndexForSplitNode()This function determines which group a property belongs to:
function findIndexForSplitNode(str: string) {
return opts.codeSplitGroupings.findIndex((group) =>
group.includes(str as any),
)
}
Returns the group index (0, 1, 2, ...) for split properties, or -1
for properties not in any group (non-split properties like beforeLoad,
validateSearch, context, etc.).
The group index -1 is treated as its own group in the shared bindings
computation — it represents "stays in the reference file."
All compiler functions live in compilers.ts.
compileCodeSplitReferenceRoute()Purpose: Transform the original route file into the "reference" module.
Input: Original source code, split groupings, framework target, options.
Output: Modified source where split properties are replaced with lazy wrappers, shared bindings are replaced with imports, and dead code is eliminated.
Key steps:
findReferencedIdentifiers() for DCEaddSplitSearchParamToFilename()const $$splitComponentImporter = () => import('...')lazyRouteComponent(importer, 'component')
or lazyFn(importer, 'loader')import { ... } from '...?tsr-shared=1'deadCodeElimination()compileCodeSplitVirtualRoute()Purpose: Transform a copy of the route file into a split module that exports only specific properties.
Input: Original source code, split targets (e.g., ['component']),
shared bindings set.
Output: A module that exports only the intended properties.
Key steps:
babel.traverse) — critical
to avoid checkBlockScopedCollisions crashesexport { SplitComponent as component })deadCodeElimination()compileCodeSplitSharedRoute()Purpose: Produce the shared virtual module containing only the shared binding declarations.
Input: Original source code, set of shared binding names.
Output: A module that declares and exports only shared bindings and their transitive dependencies.
Key steps:
program.body to keep only:
export wrappers from kept declarationsexport { ... } for all shared bindingsdeadCodeElimination()computeSharedBindings()Purpose: Analyze a route file to determine which module-level bindings are referenced by multiple split groups.
See Shared Bindings System for the full algorithm.
detectCodeSplitGroupingsFromRoute()Purpose: Parse inline codeSplitGroupings from route options.
Traverses the AST looking for a codeSplitGroupings property on the route
options object (supports both createFileRoute and createRoute call sites).
If found, extracts the array-of-arrays value and returns it.
| Function | Purpose |
|---|---|
collectIdentifiersFromNode() | Fast recursive walker to collect referenced identifier names from any AST node. Cheaper than babel.traverse. |
buildDeclarationMap() | Maps binding name → AST declaration node for all module-level declarations. |
buildDependencyGraph() | Maps binding name → set of other local bindings it references. |
expandTransitively() | BFS expansion of a set through a dependency graph. |
removeBindingsDependingOnRoute() | Reverse-graph BFS from Route to find and remove dependents. |
expandDestructuredDeclarations() | If any binding in a destructured pattern is shared, mark all as shared. |
expandSharedDestructuredDeclarators() | If bindings from same declarator appear in different groups, mark declarator as shared. |
collectLocalBindingsFromStatement() | Collects locally-declared names from a statement (cheap, no traversal). |
collectModuleLevelRefsFromNode() | Intersects collectIdentifiersFromNode() with local module-level bindings. |
removeSharedDeclarations() | Filters shared binding declarations out of program.body. |
findExportedSharedBindings() | Finds which shared bindings have export in the original source. |
After each compiler transforms the AST, it runs deadCodeElimination() from
@tanstack/router-utils. This is critical because:
findReferencedIdentifiers(ast) — Collects all Identifier NodePaths
in the AST that are in a "referenced" position (not binding sites). Returns
a Set<NodePath>.
deadCodeElimination(ast, refIdents) — Iterates over all bindings in
the program scope. For each binding, checks whether any of its references
are in refIdents. If not, the binding is unreferenced and its declaration
is removed. Repeats until no more bindings can be removed (handles chains
where removing one binding makes another unreferenced).
When the compilers add new imports (for shared bindings, or convert exports
to imports in virtual files), they must register the import specifier locals
in refIdents so DCE can later decide whether to keep or remove them:
sharedImportPath.traverse({
Identifier(identPath) {
if (identPath.parentPath.isImportSpecifier() && identPath.key === 'local') {
refIdents.add(identPath)
}
},
})
Without this, DCE would have no path references for the new imports and would always remove them.
DCE only removes unused declarations. Bare side-effect statements like
console.log(...) survive even when no locally-bound names reference them.
The virtual compiler has an additional cleanup pass:
ast.program.body = ast.program.body.filter((stmt) => {
if (!t.isExpressionStatement(stmt)) return true
const refs = collectIdentifiersFromNode(stmt)
return [...refs].some((name) => locallyBound.has(name))
})
This strips expression statements that don't reference any locally-bound name.
If the file body is empty after DCE (virtual file where no properties matched),
directive prologues ('use client', 'use strict') are also stripped:
if (ast.program.body.length === 0) {
ast.program.directives = []
}
The code-splitting system is framework-agnostic at the Babel transform level.
Framework differences are confined to framework-options.ts:
| Framework | Package | createFileRoute | lazyFn | lazyRouteComponent |
|---|---|---|---|---|
| React | @tanstack/react-router | createFileRoute | lazyFn | lazyRouteComponent |
| Solid | @tanstack/solid-router | createFileRoute | lazyFn | lazyRouteComponent |
| Vue | @tanstack/vue-router | createFileRoute | lazyFn | lazyRouteComponent |
Currently all frameworks use the same identifier names. The framework option
is used to determine which package to import lazyRouteComponent and lazyFn
from.
The reference plugin's configResolved hook checks that the TanStack Router
plugin appears before JSX transformation plugins in the Vite config. This
is required because the code-splitter must transform the source before JSX
is compiled away. Validated plugins:
@vitejs/plugin-react (Babel), @vitejs/plugin-react-swc (SWC),
@vitejs/plugin-react-oxc (OXC)vite-plugin-solidIf the order is wrong, the plugin throws an error with a suggested fix.
When userConfig.plugin?.vite?.environmentName is set, all three plugins use
applyToEnvironment to restrict themselves to that specific Vite environment.
This is necessary for TanStack Start, which creates separate plugin instances
for client and SSR environments.
If a value for a splittable route option is exported from the route file
(e.g. export const MyComp = ... used as component: MyComp), the plugin
skips splitting it. The value stays in the reference file and a warning is
emitted:
[tanstack-router] These exports from "about.tsx" will not be code-split and will increase your bundle size:
- MyComp
For the best optimization, these items should either have their export statements removed, or be imported from another location that is not a route file.
This is because other modules may import MyComp directly, and splitting it
would break those imports. (In dev, the warning is also injected into the
compiled output as a console.warn to make it obvious during local testing.)
createRootRoute() and createRootRouteWithContext() are in
unsplittableCreateRouteFns. The reference compiler adds HMR handling but
exits before attempting any code-splitting transforms.
undefined, null, and boolean Values Are Kept In-PlaceProperties with these literal values are not split:
createFileRoute('/about')({
component: undefined, // kept in reference
errorComponent: false, // kept in reference
notFoundComponent: null, // kept in reference
})
These represent "fall back to parent route" semantics and must not be moved.
const { a, b } = createHelpers()
If a is used by component (split) and b is used by loader (non-split),
the entire const { a, b } = createHelpers() declaration must go to the
shared module. You can't split the initialization — calling createHelpers()
twice would be incorrect.
const routePath = Route.fullPath
function useRouteStuff() {
return routePath
}
Even if useRouteStuff is referenced by multiple groups, it cannot be
extracted to the shared module because it depends on Route. Extracting it
would require Route in the shared module, duplicating the route singleton.
removeBindingsDependingOnRoute() handles this via reverse-graph BFS.
removeSharedDeclarations Must Run Before babel.traverse in Virtual FilesIn compileCodeSplitVirtualRoute(), shared declarations are removed
before babel.traverse runs:
if (opts.sharedBindings && opts.sharedBindings.size > 0) {
removeSharedDeclarations(ast, opts.sharedBindings)
}
babel.traverse(ast, { ... })
If done after, Babel's scope analysis would see both the shared declaration
and the new shared import, causing a checkBlockScopedCollisions crash
(duplicate const binding in the same scope).
Babel stores directives (like 'use client') in program.directives, not
in program.body. They are preserved through all transforms automatically.
The only exception is when the body is completely empty after DCE — then
directives are stripped too (a file with just 'use client' and no code is
useless).
The 3 plugins must be returned in order: reference, virtual, shared. The
reference plugin populates sharedBindingsMap before the other two consume
it. Bundlers process transforms in plugin array order for a given module ID.
computeSharedBindings() only considers locally-declared module-level
bindings. Import statements are handled by the bundler's module system — if
both the reference file and a virtual file import the same external module,
the bundler deduplicates that import automatically. No shared module needed.
SPLIT_NODES_CONFIG — Split Strategy per PropertyEach splittable property has a configured split strategy:
| Property | Strategy | Wrapper |
|---|---|---|
component | lazyRouteComponent | Wraps with Suspense-compatible lazy loading |
pendingComponent | lazyRouteComponent | Same |
errorComponent | lazyRouteComponent | Same |
notFoundComponent | lazyRouteComponent | Same |
loader | lazyFn | Wraps as a lazy function call |
Components use lazyRouteComponent (framework-provided). In React this
integrates with Suspense-compatible lazy loading. Loaders use lazyFn which
wraps the async function for deferred loading.
All three compilers pass sourceMaps: true and sourceFileName to
generateFromAst(), ensuring the browser's devtools can map back to the
original source file.
graph LR
subgraph "Reference Module"
REF["about.tsx"]
end
subgraph "Split Module"
COMP["about.tsx?tsr-split=component"]
end
REF -->|"() => import()"| COMP
When the route has no module-level bindings shared between split and non-split properties, there is no shared module. The reference module has a dynamic import to the split module.
graph LR
subgraph "Reference Module"
REF["cached.tsx"]
end
subgraph "Split Module"
COMP["cached.tsx?tsr-split=component"]
end
subgraph "Shared Module"
SHARED["cached.tsx?tsr-shared=1"]
end
REF -->|"() => import()"| COMP
REF -->|"import { getCached, cache }"| SHARED
COMP -->|"import { getCached }"| SHARED
The shared module is imported synchronously by both the reference module and the split module. Bundlers will usually dedupe this to a single module instance; whether it becomes a separate chunk or gets inlined into another chunk depends on the bundler's chunking strategy.
graph LR
subgraph "Reference Module"
REF["dashboard.tsx"]
end
subgraph "Split Modules"
COMP["dashboard.tsx?tsr-split=component"]
ERR["dashboard.tsx?tsr-split=errorComponent"]
NF["dashboard.tsx?tsr-split=notFoundComponent"]
end
subgraph "Shared Module"
SHARED["?tsr-shared=1"]
end
REF -->|"() => import()"| COMP
REF -->|"() => import()"| ERR
REF -->|"() => import()"| NF
REF --> SHARED
COMP --> SHARED
ERR --> SHARED
NF --> SHARED
Each split group gets its own split module. If a binding is shared across any two of these (or between a split module and the reference module), it lives in the shared module.
graph LR
subgraph "Reference Module"
REF["page.tsx"]
end
subgraph "Split Module"
COMBINED["page.tsx?tsr-split=component---loader"]
end
REF -->|"() => import()"| COMBINED
When component and loader are in the same grouping
([['component', 'loader']]), they end up in a single split module. This
reduces the number of split modules (and likely requests) at the cost of
loading the loader even when only the component is needed (and vice versa).
| File | Description |
|---|---|
src/core/router-code-splitter-plugin.ts | Plugin factory — creates 3 plugins, manages sharedBindingsMap |
src/core/code-splitter/compilers.ts | All compiler functions (~1,970 lines) |
src/core/constants.ts | tsrSplit, tsrShared, splitRouteIdentNodes, defaultCodeSplitGroupings |
src/core/code-splitter/framework-options.ts | Per-framework package/ident configuration |
src/core/code-splitter/path-ids.ts | URL-safe encoding/decoding for split identifiers |
src/core/config.ts | Plugin configuration types and validation |
src/core/route-hmr-statement.ts | HMR handling code template |
tests/code-splitter/ | Unit tests and snapshot fixtures |