The project's README from github.com/anonrig/router. As one markdown file: README.md.
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.
createRouter, Link, Outlet, loaders, search params, nested routes. Public names match @tanstack/react-router so existing apps and TanStack's own tests can run against it.pnpm bench:compare checks loader-call counts before timing. Headline rows use TanStack's default staleTime: 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.onShellReady and flushes incrementally. No isbot, no User-Agent parse, no waiting for a complete document because a crawler might be watching.react and react-dom ~19.2.0. No compatibility tax for React 18.engines.node is >=24. No compatibility tax for Node 22.pnpm bench:compare.speedy-router-plugin emits TanStack's routeTree.gen.ts shape: eager Route imports, .update(), and declare module '@tanstack/react-router'. Alias @tanstack/router-plugin at the package manager.Node 24+, React 19.2, and React DOM 19.2 are required.
pnpm add speedy-router
The 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/[email protected]@tanstack/[email protected]@tanstack/[email protected]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× |
to/params interpolation| 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:[email protected]
'@tanstack/router-plugin': npm:[email protected]
Apps 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 exports
The 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.
NPM_TOKEN repository secret on anonrig/router, then run Release with bump none (publishes 0.1.0), orNPM_TOKEN=… pnpm release.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.
none publishes the version already in the package files.patch / minor / major / prerelease rewrites every package in lockstep, commits, and tags.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.