Skip to content

Latest commit

 

History

368 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

speedy-router

speedy-router

The TanStack Router API. Rebuilt for the hot path.

A from-scratch React 19.2 router. Same public names. Faster navigations. Faster SSR.

CI npm License: MIT React Node TypeScript


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.

Why this exists

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.

Features

  • Same API. 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.
  • Faster on the same work. 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.
  • Streaming SSR. Every stream starts on onShellReady and flushes incrementally. No isbot, no User-Agent parse, no waiting for a complete document because a crawler might be watching.
  • React 19.2 only. Peers are pinned to react and react-dom ~19.2.0. No compatibility tax for React 18.
  • Node 24 only. engines.node is >=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-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.

Quick start

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

Performance

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

Equal-work headlines

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×

Same staleTime, no to/params interpolation

Operation speedy-router TanStack
Warm navigate({ href }) 1,274,553 52,253 24.39×

Utilities

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.

Bundle size

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.

Large route trees

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.4

Apps keep createFileRoute('/posts/$id') in each route file. Nothing new to call at runtime.

Streaming, on purpose

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.

Compatibility

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

Publishing

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.

1. First publish (token)

  1. Sign in at npmjs.com as the owner who will hold the four package names.
  2. Create a granular access token with Read and write permission for new packages (or an Automation classic token).
  3. Either:
    • add it as the NPM_TOKEN repository secret on anonrig/router, then run Release with bump none (publishes 0.1.0), or
    • from a clean checkout of the release commit: NPM_TOKEN=… pnpm release.
  4. Confirm all four names exist: speedy-router, speedy-router-core, speedy-router-history, speedy-router-plugin.

2. Trusted publishing (every later 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.

3. Cut a release

Preferred: ReleaseRun workflow.

  • bump none publishes the version already in the package files.
  • patch / minor / major / prerelease rewrites 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.00.1.0). A mismatched tag fails before publish.

License

MIT. TanStack Router is also MIT. Its tests and benches are vendored for compatibility; copyright remains with Tanner Linsley.

About

The TanStack Router API. Rebuilt for the hot path.

Topics

Resources

Stars

156 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages