The TanStack Router API. Rebuilt for the hot path.
A from-scratch React 19.2 router. Same public names. Faster navigations. Faster SSR.
| 14.02× | 12.54× | 579,665 |
faster typed to/params |
faster changing-params navigate | cold router.load / sec |
Median of three same-machine runs, same loops, published TanStack Router 1.170.30. Re-run with pnpm bench:compare.
import {
Link,
Outlet,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
} from 'speedy-router'
const rootRoute = createRootRoute({
component: () => (
<>
<Link to="/">Home</Link>
<Outlet />
</>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <h1>Home</h1>,
})
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute]),
})
export function App() {
return <RouterProvider router={router} />
}Keep your existing @tanstack/react-router imports. Point the alias at speedy-router.
TanStack Router is the right API: typed routes, loaders, search params, nested layouts. The internals were not written for the cost of every navigation and every SSR request.
This repo is not a fork. The compatibility surface is the TanStack Router API. The hot path is new: less store setup, less match-object construction, less work before the first byte.
If you already know TanStack Router, you already know this router.
- Same API.
createRouter,Link,Outlet, loaders, search params, nested routes. Public names match@tanstack/react-routerso existing apps and TanStack's own tests can run against it. - Faster on the same work.
pnpm bench:comparechecks loader-call counts before timing. Headline rows use TanStack's defaultstaleTime: 0, so loaders rerun on enter and param changes on both sides. Utility rows rotate unique inputs so last-value intern caches do not dominate. - Streaming SSR. Every stream starts on
onShellReadyand flushes incrementally. Noisbot, no User-Agent parse, no waiting for a complete document because a crawler might be watching. - React 19.2 only. Peers are pinned to
reactandreact-dom~19.2.0. No compatibility tax for React 18. - Node 24 only.
engines.nodeis>=24. No compatibility tax for Node 22. - Typed the same way. Vendored TanStack type tests pass. Route trees, params, and search stay on the TanStack type surface.
- Measured in the open. Head-to-head benches, heap per operation, and bundle sizes live in the repo. Re-run them. The tables are the same loop as
pnpm bench:compare. - Same generated route tree.
speedy-router-pluginemits TanStack'srouteTree.gen.tsshape: eagerRouteimports,.update(), anddeclare module '@tanstack/react-router'. Alias@tanstack/router-pluginat the package manager.
Node 24+, React 19.2, and React DOM 19.2 are required.
pnpm add speedy-routerThe other public packages are speedy-router-core, speedy-router-history, and speedy-router-plugin. Clone the workspace to develop or re-run the benches:
pnpm install
pnpm test
pnpm bench:compare
pnpm size| Package | What you import |
|---|---|
speedy-router |
RouterProvider, Link, hooks, SSR bindings |
speedy-router-core |
Matcher, navigation, loaders, search params |
speedy-router-history |
Browser, hash, and memory history |
speedy-router-plugin |
TanStack-compatible routeTree.gen.ts + Vite plugin |
Routing cost is not a microbenchmark. It is every click and every request.
On a 4-core Intel Xeon, Linux, Node 24.19.0, in memory, no HTTP server. Each figure is the median of three fresh process runs:
| speedy-router | TanStack | ||
|---|---|---|---|
Warm navigate({ to, params }) |
1,063,228 | 75,820 | 14.02× |
Warm navigate changing params |
753,297 | 60,074 | 12.54× |
SSR cold router.load |
579,665 | 81,029 | 7.15× |
These are in-memory Node loops: no browser, React render, HTTP, HTML, lazy components, or loader I/O. Cold router.load is match + loaders on a new router each time. Typed to/params navigation is what <Link> uses. Default staleTime is 0, the same as TanStack, so those rows rerun the post loader on enter and when params change. The compare harness refuses to print if loader-call counts diverge (pnpm audit:loaders is the same probe). navigate({ href }) uses the same staleTime policy and skips to/params interpolation, so it is listed separately. A settled router.load() no-op is not published: this implementation can skip that call. createRequestHandler (normalize, attach SSR utils, load, dehydrate) is in the full table.
TanStack side is the published packages, not this repo's test aliases:
@tanstack/react-router@1.170.30@tanstack/router-core@1.171.25@tanstack/history@1.162.1
pnpm bench:compare| Operation | speedy-router | TanStack | |
|---|---|---|---|
Warm navigate({ to, params }) |
1,063,228 | 75,820 | 14.02× |
Warm navigate changing params |
753,297 | 60,074 | 12.54× |
| Invalidate + reload | 784,437 | 106,033 | 7.40× |
SSR cold router.load req/s |
579,665 | 81,029 | 7.15× |
createRequestHandler req/s |
84,278 | 20,398 | 4.13× |
| Operation | speedy-router | TanStack | |
|---|---|---|---|
Warm navigate({ href }) |
1,274,553 | 52,253 | 24.39× |
Rotating unique inputs, so a last-value intern cache does not decide the row.
| Operation | speedy-router | TanStack | vs TanStack |
|---|---|---|---|
| Query-string encode | 4,285,035 | 2,545,249 | 1.68× |
| Query-string decode | 1,635,010 | 1,350,159 | 1.21× |
defaultStringifySearch |
4,435,301 | 2,595,604 | 1.71× |
parseHref |
5,622,778 | 3,565,276 | 1.58× |
cleanPath |
19,660,854 | 9,226,014 | 2.13× |
resolvePath |
10,200,326 | 4,400,655 | 2.32× |
interpolatePath |
6,206,952 | 2,329,602 | 2.66× |
| Route match (large tree) | 14,376,087 | 5,588,120 | 2.57× |
| Encode 100 typical SSR match IDs | 1,869,041 | 29,452 | 63.46× |
History push |
3,707,376 | 1,503,108 | 2.47× |
Typed navigate({ to, params }) is the Link-shaped path: an absolute to with fully specified simple params interpolates and uses the same warm load lane as href. Search middlewares, blockers, preloads, masks, and route lifecycle hooks still take the full load coordinator. Default staleTime is 0, so changing params and re-entering a loaded route rerun the post loader on both sides. Set staleTime: Infinity (or defaultStaleTime) to keep successful data. Invalidate + reload marks matches invalid and reruns loaders on both sides. navigate({ href }) is a resolved-href fast path with the same staleTime policy. A settled router.load() on an already-valid router is not published: this implementation can skip that call. Unique query-string encode and decode both beat published TanStack on this machine; decode no longer clones every miss. cleanPath, resolvePath, and simple $param compile keep a last-value memo; they do not keep a default-graph LRU. resolvePath still accepts an optional caller cache. Large-tree match walks many static leaves through staticExact instead of one repeated LRU key. SSR match IDs replace slashes in one pass and intern the result. Memory history keeps the full stack by default, the same as TanStack. Pass createMemoryHistory({ compact: true }) to drop the oldest half at 2048 entries. Cold createRouter().load() reuses processed trees, empty-search match templates, a prototype createMemoryHistory, and a synchronous fast SSR lane when loaders are sync. createRequestHandler stays on that sync lane when load, dehydrate, and the render callback are sync, then dehydrates without an extra await.
jsdom URLSearchParams numbers from pnpm bench are a different environment. Do not compare them to the Node table above.
Initial client graph for the public constructors. Vite 8 / Rolldown minify, gzip -9, react / react-dom external. The client load coordinator and SSR load chunk are dynamic imports and are not counted.
| Package | speedy-router | gzip | TanStack | gzip |
|---|---|---|---|---|
speedy-router |
117.7 kB | 33.5 kB | 105.2 kB | 29.5 kB |
speedy-router-core |
95.5 kB | 26.7 kB | 75.4 kB | 21.7 kB |
RouterClient hydrate |
125.8 kB | 36.3 kB | 110.7 kB | 31.2 kB |
TanStack is still smaller on both client graphs (1.14× gzip for speedy-router). Parallel route slots are tree-shaken out of this graph unless createSlotRoute is imported. The sync warm loader is opt-in via speedy-router-core/warm (or speedy-router/warm) and is not in this graph. The client load coordinator and SSR load chunk are dynamic imports and are not counted. hydrate / RouterClient import match helpers instead of the coordinator, so first paint does not statically bind load-client. Default window scroll lives in the constructor instead of a separate scroll-default module. useMatchRoute / MatchRoute ship in their own module, so Matches does not pull them. The default graph no longer keeps a path LRU or StringMap flight registry. cleanPath / resolvePath / simple $param compile keep a last-value memo only. The remaining extra is matcher interners and the href fast path. The initial graph no longer includes TanStack's segment-tree matcher, HMR refresh, or hash/memory history. Re-run with pnpm size.
Copied TanStack unit benches (search params, SSR match IDs, Link, closing-tag detection) live in benches/tanstack/. TanStack's Nx Start app benches are not copied; they need @tanstack/react-start and a built server.
The generator is a drop-in for @tanstack/router-plugin. It writes one routeTree.gen.ts with the same eager Route imports, .update({ id, path, getParentRoute }), _addFileChildren / _addFileTypes, and FileRoutesByPath module augmentation. Existing tools that read fullPath out of that file keep working.
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
tanstackRouter({
target: 'react',
autoCodeSplitting: true,
routeFileIgnorePattern: '\\.test\\.|\\.e2e\\.|__generated__',
}),
],
})Point the TanStack package names at the speedy packages:
# pnpm-workspace.yaml catalog
'@tanstack/react-router': npm:speedy-router@0.1.4
'@tanstack/router-plugin': npm:speedy-router-plugin@0.1.4Apps keep createFileRoute('/posts/$id') in each route file. Nothing new to call at runtime.
TanStack's renderRouterToStream inspects User-Agent with isbot and, for crawlers, waits for React's allReady / onAllReady so the first byte is a complete document.
This router never inspects User-Agent.
Every SSR stream starts on onShellReady by default and flushes incrementally. That keeps a dependency out of the hot path and avoids a User-Agent parse on every request.
If you need crawlers to receive fully buffered HTML, pass isBot: true (or a request predicate) to renderRouterToStream. That waits for React's allReady / onAllReady without adding isbot.
The public names match @tanstack/react-router. Default loader freshness does too: omitted staleTime is 0. This workspace runs a copied, modified subset of TanStack's history, router-core, and React tests — not TanStack's full monorepo, e2e, or TypeScript-version matrix. Alias the TanStack names to the local packages:
// vitest / vite
resolve: {
alias: {
'@tanstack/react-router': 'speedy-router',
'@tanstack/router-core': 'speedy-router-core',
'@tanstack/history': 'speedy-router-history',
'@tanstack/router-plugin': 'speedy-router-plugin',
},
}| Suite | Status |
|---|---|
| First-party unit tests | Passing |
| Vendored TanStack history tests | Passing |
| Vendored TanStack type tests | Passing |
| Vendored TanStack core path / qss / search / match | Passing |
| Vendored TanStack core load / preload / SSR lifecycle | Passing |
| Vendored TanStack React runtime | Passing |
pnpm test # first-party
pnpm test:tanstack # vendored TanStack runtime
pnpm test:types # vendored TanStack types
pnpm audit:loaders # loader-call counts vs published TanStack
pnpm bench # vitest benches
pnpm bench:compare # head-to-head ops/s + heap vs published TanStack
pnpm size # client min+gzip vs published TanStack
pnpm lint && pnpm fmt:check
pnpm knip # unused files, dependencies, and exportsThe public packages are speedy-router, speedy-router-core, speedy-router-history, and speedy-router-plugin (formerly speedy-router-generator). The repo root stays private. Versions stay in lockstep. Releases run from .github/workflows/release.yml.
Published tarballs contain compiled ESM JavaScript and .d.ts files in dist/. TypeScript source stays in the git repo. pnpm build emits dist before pnpm release.
Trusted publishing cannot create a package's first version. Bootstrap once with a token, then switch to OIDC.
- Sign in at npmjs.com as the owner who will hold the four package names.
- Create a granular access token with Read and write permission for new packages (or an Automation classic token).
- Either:
- add it as the
NPM_TOKENrepository secret onanonrig/router, then run Release with bumpnone(publishes0.1.0), or - from a clean checkout of the release commit:
NPM_TOKEN=… pnpm release.
- add it as the
- Confirm all four names exist: speedy-router, speedy-router-core, speedy-router-history, speedy-router-plugin.
On each of the four package pages: Settings → Trusted publisher → GitHub Actions.
| Field | Value |
|---|---|
| Organization or user | anonrig |
| Repository | router |
| Workflow filename | release.yml |
| Environment | leave blank (the workflow does not set environment) |
| Allowed actions | npm publish |
Then delete the NPM_TOKEN repository secret. The workflow only writes .npmrc when that secret is present; an empty token would skip the OIDC exchange.
Preferred: Release → Run workflow.
- bump
nonepublishes the version already in the package files. patch/minor/major/prereleaserewrites every package in lockstep, commits, and tags.- version overrides the bump with an exact semver.
- dry_run runs the same checks and prints the npm plan without publishing.
The job installs current npm (OIDC needs 11.5.1+), runs the same checks as CI, publishes with provenance, pushes a v* tag only if it is new or already points at HEAD, and opens a GitHub Release.
Pushing a v* tag yourself also starts the job. The tag must match the lockstep version in every package.json (v0.1.0 → 0.1.0). A mismatched tag fails before publish.
MIT. TanStack Router is also MIT. Its tests and benches are vendored for compatibility; copyright remains with Tanner Linsley.