Real-Router
July 9, 2026 · View on GitHub
Data-first router for JavaScript — the most declarative router for client applications
Wiki · Recipes · Roadmap · Changelog · Contributing · Issues
Every router you know maps URLs to components. Real-Router maps URLs to data.
React Router: URL → <Component /> (router decides what to render)
Vue Router: URL → component: View (router decides what to render)
Real-Router: URL → { name, params } (you decide what to do)
The router tells you where. You decide what.
This is not a minor API difference — it's a fundamentally different architecture.
The router is a lifecycle manager, not a data layer.
It tells you when transitions happen; what to do with that — render a page, load data, set a title, track analytics, or ignore it entirely — is your decision.
Built from scratch with TypeScript-first design. Independent project inspired by router5's declarative philosophy, not a fork.
Pre-1.0: Core API and plugin interfaces are stable. Minor versions preserve backward compatibility. The high release count reflects monorepo-wide coordinated publishing — one change in core triggers version bumps across all ~30 dependent packages. See Roadmap for the path to 1.0 and Quality & Testing for reliability guarantees.
Why Real-Router?
Your routing logic lives in one place — route config plus a plugin or two. Adding a page means adding a config entry; changing auth means touching one module.
One-Way Data Flow in UI
Other routers push data fetching into components — useParams() + useEffect() + fetch() is imperative boilerplate that every page repeats.
Real-Router inverts this: routing state arrives as external data, plugins handle data loading, titles, analytics outside the component tree. Components just render what they receive.
Declarative Route Config
One config object declares everything — routing, access control, data loading, and any custom concern. No logic scattered across component files.
Compare where routing logic lives across routers:
React Router v7: guards in loaders, meta in page files — one route module per file
Vue Router v4: guards in beforeEach(), data in components, titles wired manually
TanStack Router: beforeLoad + loader + head per route file, scattered across file tree
Real-Router: guards + data + titles + any router related logic in one config object → generic plugins
With @real-router/lifecycle-plugin, data loading, titles, and cleanup live next to the route they belong to — no wrapper components, no HOCs, no scattered useEffect in pages:
import { lifecyclePluginFactory } from "@real-router/lifecycle-plugin";
router.usePlugin(lifecyclePluginFactory());
const routes = [
{
name: "users",
path: "/users",
canActivate: authGuard,
onNavigate: (state) => {
store.users.load(state.params);
},
children: [
{
name: "profile",
path: "/:id",
onNavigate: (state) => {
document.title = `User ${state.params.id}`;
store.users.loadOne(state.params.id);
},
},
],
},
];
One config, one place. Adding a new page = one config entry. See Recipes for full examples.
Writing your own plugin when lifecycle-plugin isn't enough
For custom transition phases, cross-route coordination, or domain-specific concerns, you can write a generic plugin that reads your own fields from route config:
const routes = [
{
name: "users",
path: "/users",
loadData: (p, api) => api.getUsers(p), // custom field → your plugin
},
];
const dataPlugin: PluginFactory = (router, getDep) => {
const { getRouteConfig } = getPluginApi(router);
return {
onTransitionSuccess: (toState) => {
const route = getRouteConfig(toState.name);
route?.loadData?.(toState.params, getDep("api"));
},
};
};
The plugin reads custom fields from any route — no hardcoded route names, works across the entire config.
Runtime Route Management
Swap entire route trees in one call — auth flows, feature flags, A/B experiments.
Full CRUD for routes at runtime — add, remove, update, replace, clear.
No other router offers update() for modifying guards, redirects, or defaults of individual routes without remove+add.
replace() is atomic with state revalidation — designed for HMR and dynamic feature flags.
The most direct demonstration is an auth-driven tree swap:
import { getRoutesApi } from "@real-router/core/api";
async function login(credentials) {
await api.login(credentials);
const routes = getRoutesApi(router);
routes.clear();
routes.add(privateRoutes);
router.navigate("dashboard");
}
async function logout() {
await api.logout();
const routes = getRoutesApi(router);
routes.clear();
routes.add(publicRoutes);
router.navigate("auth");
}
In URL→Component routers, private routes exist in the tree regardless of auth state — something must match them and decide to redirect. Real-Router takes a different path: when the user isn't authenticated, private routes aren't in the tree at all. Nothing to match, nothing to mount. An entire class of auth-related edge cases disappears by construction.
This is a client-side state consistency pattern, not a security boundary. Data authorization still belongs on the server (auth middleware, RLS, scoped tokens). The router keeps the client tree consistent with auth state; it doesn't protect data.
Capability-Based API
Different consumers see different surfaces:
- Application code uses the router directly —
navigate,subscribe,usePlugin, etc. - UI components get
Navigator— a frozen object with only the methods components actually need (navigate,getState,isActiveRoute,canNavigateTo,subscribe,subscribeLeave,isLeaveApproved). No way to accidentally calldispose()or mutate routes. - Plugin authors explicitly import
getPluginApi(router)to access interceptors, state context claims, and internals. These are gated behind a separate import — invisible to everyone else.
Most applications never encounter plugin APIs. Most components never see dangerous methods. The right surface at the right layer.
One practical payoff — RBAC-aware menus without a second permission table:
// canNavigateTo runs the same guards navigate would —
// so your menu shows only what the current user is actually allowed to reach.
const visibleItems = menuItems.filter((item) =>
navigator.canNavigateTo(item.route),
);
No separate permission table to keep in sync with route guards. One source of truth, used both to decide what to show and what to let through.
Minimal Runtime Footprint
The core is platform-agnostic — no DOM, no History API, no framework dependencies in the routing layer. It works in any JavaScript runtime, not just browsers: SSR, SSG, Service Workers, edge functions, React Native — all without special modes or framework-specific adapters. Swap the platform plugin, reuse everything else.
Runs in browser, terminal (Ink), and desktop (Electron, Tauri) — same router, different plugins. See Desktop Integration Guide.
First-Class SSR · Streaming · SSG
The only standalone router that ships the same SSR contract across React 19, Preact 10, Vue 3, Solid, Svelte 5, and Angular 22+ — without locking you into Next.js, Nuxt, SolidStart, or SvelteKit. ~300+ e2e scenarios covering classical SSR, streaming, SSG, and RSC pipelines.
| Adapter | SSR | Streaming SSR | SSG | e2e scenarios |
|---|---|---|---|---|
| React 19 | ✓ | OOO <Suspense> + use() | ✓ | 36+ |
| Preact 10 | ✓ | OOO <preact-island> + <Await> | ✓ | 4 pipelines |
| Vue 3 | ✓ | chunked + blocking Suspense | ✓ | 55 |
| Solid | ✓ | OOO + selective hydration | ✓ | 59 |
| Svelte 5 | ✓ | deferred-data via {#await} | ✓ | 52 |
| Angular | ✓ | TransferState bridge | ✓ | 4 pipelines |
Unique primitives at the routing layer:
- Per-route SSR mode —
full/data-only/client-only+ function form(state) => SsrMode(data-driven, not path-based) - Cross-adapter SSR components —
<ClientOnly>,<ServerOnly>,<Await>,<Streamed>,<HttpStatusCode>shipped symmetric across 5 adapters via/ssrsubpath - Typed loader errors → HTTP —
LoaderRedirect/LoaderNotFound/LoaderTimeoutmapped to 301/302/404/504 in both SSR and RSC pipelines createRequestScope(req, baseRouter, deps)— correct-by-construction request DI: clone + AbortController +req.on("close")+ dispose in one call- Network-level cancellation —
withTimeout()composes deadline + client-disconnect into oneAbortSignal; in-flightfetchaborts at TCP level when deadline fires - Post-hydration loader skip — zero fetch on first paint after hydration, automatic in all 6 adapters
SSR · Streaming SSR · SSG · RSC · Wiki: SSR · Streaming SSR · SSR Hydration
Performance
Navigation stays fast as your route tree grows — from 10 routes to 1000, the cost per navigation barely moves. The Segment Trie matcher traverses in O(segments), not O(routes).
2.3–14× faster and 2–9× fewer allocations than TanStack Router in full client-side navigation benchmarks (identical 10-step loop, JSDOM, Vite production build):
Speed (relative to TanStack Router, higher is better):
| Framework | vs TanStack Router |
|---|---|
| React | ~14× faster |
| Solid | ~11.5× faster |
| Vue | ~2.3× faster |
Allocations per navigation (relative to TanStack Router, lower is better):
| Framework | vs TanStack Router |
|---|---|
| React | ~9× fewer allocations |
| Solid | ~7× fewer allocations |
| Vue | ~2× fewer allocations |
Key Features
- Framework-agnostic — React, Preact, Solid, Vue, Svelte, Angular, or vanilla JS
- First-class SSR / Streaming / SSG / RSC — same primitives across React 19, Preact 10, Vue 3, Solid, Svelte 5, Angular 22+ — no meta-framework lock-in. See above
- Named nested routes — dot-notation hierarchy (
users.profile) - Lifecycle guards —
canActivate/canDeactivateper route or globally - AbortController — cancel navigations via standard
AbortSignal - Dynamic route management — add, remove, update, replace routes at runtime
- Dependency injection — type-safe DI container for guards and plugins
- Plugin architecture — intercept and extend router behavior
- Observable state — RxJS and TC39 Observable compatible
- Immutable state — deeply frozen, predictable state management
- Scroll restoration — opt-in via
RouterProvider.scrollRestoration(docs); restores on back/forward, scrolls to top /#hashon push;restore/top/manualmodes; custom scroll containers - Scroll spy — opt-in via
RouterProvider.scrollSpy(docs); router-coordinatedIntersectionObserversyncs the URL hash to the topmost visible anchor as you scroll
Quick Start
npm install @real-router/core
import { createRouter } from "@real-router/core";
import { browserPluginFactory } from "@real-router/browser-plugin";
const routes = [
{ name: "home", path: "/" },
{
name: "users",
path: "/users",
children: [
{
name: "profile",
path: "/:id",
},
],
},
];
const router = createRouter(routes);
router.usePlugin(browserPluginFactory(), __DEV__ && validationPlugin());
await router.start();
await router.navigate("users.profile", { id: "123" });
Recommended for development: add
@real-router/validation-pluginfor descriptive runtime errors on every API call. Falsy values inusePlugin()are silently skipped, so inline conditionals work naturally:import { validationPlugin } from "@real-router/validation-plugin"; router.usePlugin(browserPluginFactory(), __DEV__ && validationPlugin());
Route-level lifecycle hooks: add
@real-router/lifecycle-pluginto attachonNavigate,onEnter,onStay,onLeavecallbacks directly to route definitions — nosubscribe()boilerplate:import { lifecyclePluginFactory } from "@real-router/lifecycle-plugin"; const routes = [ { name: "home", path: "/", onLeave: () => cleanup(), }, { name: "users.view", path: "/users/:id", onNavigate: (s) => track(s.params.id), }, ]; router.usePlugin(lifecyclePluginFactory());
With React
import { RouterProvider, RouteView, Link } from "@real-router/react";
function App() {
return (
<RouterProvider router={router}>
<nav>
<Link routeName="home">Home</Link>
<Link routeName="users">Users</Link>
</nav>
<RouteView nodeName="">
<RouteView.Match routeName="home">
<HomePage />
</RouteView.Match>
<RouteView.Match routeName="users">
<UsersPage />
</RouteView.Match>
<RouteView.NotFound>
<NotFoundPage />
</RouteView.NotFound>
</RouteView>
</RouterProvider>
);
}
RouteView with keepAlive requires React 19.2+ (React Activity API). For React 18+, use @real-router/react/legacy — all hooks and Link, no RouteView.
Packages
Core
| Package | Version | Description |
|---|---|---|
@real-router/core | Router implementation | |
@real-router/core/api | Tree-shakeable modular API: getRoutesApi, getDependenciesApi, getLifecycleApi, getPluginApi, cloneRouter | |
@real-router/core/utils | Utility functions: serializeState (XSS-safe JSON for SSR), getStaticPaths (static path generation for SSG) | |
@real-router/types | Shared TypeScript type definitions |
Framework Integration
| Package | Version | Description |
|---|---|---|
@real-router/react | React 19.2+ (hooks, RouteView, Link). React 18+ via ./legacy | |
@real-router/preact | Preact (hooks, RouteView, Link, Suspense) | |
@real-router/solid | Solid.js (signals, RouteView, Link, store-based state) | |
@real-router/vue | Vue 3 (composables, RouteView, Link, KeepAlive, v-link) | |
@real-router/svelte | Svelte 5 (runes, RouteView with snippets, Lazy, use:link) | |
@real-router/angular | Angular 22+ (signals, inject* functions, <route-view>, realLink directive, zoneless) |
Plugins
| Package | Version | Description |
|---|---|---|
@real-router/browser-plugin | Browser History API and URL synchronization | |
@real-router/navigation-plugin | Navigation API integration + route-level history | |
@real-router/memory-plugin | In-memory history: back/forward/go (no DOM) | |
@real-router/hash-plugin | Hash-based routing (#/path) | |
@real-router/logger-plugin | Development logging with transition tracking | |
@real-router/persistent-params-plugin | Parameter persistence across navigations | |
@real-router/ssr-data-plugin | SSR per-route data loading via interceptor | |
@real-router/rsc-server-plugin | RSC per-route ReactNode loading (bundler-agnostic) | |
@real-router/lifecycle-plugin | Route-level hooks: onNavigate, onEnter, onStay, onLeave | |
@real-router/preload-plugin | Preload on navigation intent (hover, touch) | |
@real-router/validation-plugin | Runtime argument validation for development | |
@real-router/search-schema-plugin | Search param validation via Standard Schema |
Utilities
| Package | Version | Description |
|---|---|---|
@real-router/sources | Reactive subscription sources for UI bindings — per-router cached getTransitionSource / createDismissableError / createActiveNameSelector + canonical params cache | |
@real-router/rx | Observable API: state$, events$, operators, TC39 Observable | |
@real-router/route-utils | Route tree queries: getRouteUtils, segment testers, areRoutesRelated | |
@real-router/logger | Structured logging utility |
Documentation
Full documentation is available in the Wiki.
Getting Started
- Core Concepts — overview and mental model
- Defining Routes — route configuration, nesting, path syntax
- Navigation Lifecycle — transitions, guards, hooks
API Reference
- createRouter · navigate · start · stop · buildPath · isActiveRoute
- Guards · State · NavigationOptions · RouterOptions · RouterError
- Plugin Architecture · getRoutesApi · getDependenciesApi · getLifecycleApi · cloneRouter
React
- RouterProvider · RouteView · Link · useRouter · useRoute · useRouteNode · useNavigator
Preact / Solid / Vue / Svelte / Angular
Plugins
- browser-plugin · navigation-plugin · hash-plugin · logger-plugin · persistent-params-plugin · search-schema-plugin · ssr-data-plugin · rsc-server-plugin · lifecycle-plugin · preload-plugin · memory-plugin · validation-plugin · rx · sources · route-utils
Server-Side Rendering & RSC
- Server-Side Rendering —
cloneRouterper request,start(url)resolution,dispose()cleanup - SSR Hydration —
serializeRouterState+hydrateRouterround-trip,excludeContextfor non-serializable namespaces - Data Loading —
ssr-data-pluginfor plain JSON, fetcher patterns - Streaming SSR — React 19
renderToReadableStream+<Suspense>+use(promise)for deferred sections. Zero router-specific API — pure delegation to React 19 native primitives. Reference:examples/web/react/ssr-examples/ssr-streaming/ - RSC Integration — React Server Components end-to-end:
@vitejs/plugin-rscsetup, two-endpoint architecture (HTML +/__rsc), Flight injection, client mount. Reference implementation: `examples/web/react/ssr-examples/ssr-rsc/$
\text{Examples}
\text{Many} \text{runnable} \text{examples} \text{across} \text{the} \text{most} \text{popular} \text{frameworks} — \text{each} \text{is} \text{a} \text{standalone} \text{Vite} \text{app}:
<\text{details}> <\text{summary}><\text{b}>\text{Feature} \text{matrix} — 6 \text{adapters} \times 12 \text{features} (+ \text{framework}-\text{specific})</\text{b}></\text{summary}>
</\text{details}>
\text{Server} \text{rendering} — \text{cross}-\text{framework} \text{symmetry}
\text{Every} \text{pipeline} \text{below} \text{ships} \text{as} \text{a} \text{standalone} \text{Vite} \text{app} \text{per} \text{adapter} — $pnpm devfrom any folder. All 6 web adapters cover the same 4 SSR pipelines through onessr-data-plugincontract; React additionally has RSC + Flight via@real-router/rsc-server-plugin`.
SSR pipeline matrix — 6 adapters × 4 pipelines (+ RSC for React)
| Pipeline | React | Preact | Vue | Solid | Svelte | Angular |
|---|---|---|---|---|---|---|
| Classical SSR | ssr | ssr | ssr | ssr | ssr | ssr |
| Streaming SSR | ssr-streaming | ssr-streaming | ssr-streaming | ssr-streaming | ssr-streaming | ssr-streaming |
| Mixed SSR modes | ssr-mixed | ssr-mixed | ssr-mixed | ssr-mixed | ssr-mixed | ssr-mixed |
| SSG | ssg | ssg | ssg | ssg | ssg | ssg |
| RSC + Flight | ssr-rsc | — | — | — | — | — |
Animations — cross-framework symmetry
Each adapter ships four standalone animation pipelines under animation-examples/ (24 apps in total). See Routing Animations and View Transitions.
Animation pipeline matrix — 6 adapters × 4 pipelines
| Terminal UI (Ink) | ink-demo — CLI app via @real-router/react/ink + memory-plugin |
| Desktop (Electron, Tauri) | electron/react (browser-plugin + app://), electron/react-hash (hash-plugin + file://), electron/react-navigation (navigation-plugin + HistoryPanel), tauri/react (Tauri v2 + browser-plugin), tauri/react-navigation (Tauri v2 + navigation-plugin). See Desktop Integration Guide |
Run any example: cd examples/web/react/basic && pnpm dev (the Ink demo is tsx-based — cd examples/console/react-ink && pnpm dev; Electron — cd examples/desktop/electron/react && pnpm dev; Tauri — cd examples/desktop/tauri/react && pnpm tauri dev, requires Rust toolchain).
Relationship to Router5
Real-Router is an independent project — not a fork. Built from scratch with different algorithms (Segment Trie vs linear scan), modern TypeScript API, and independent roadmap. Inspired by router5's declarative routing philosophy (named routes, hierarchical routing, lifecycle guards).
Quality & Testing
Real-Router treats testing as a first-class engineering concern, not an afterthought.
- 100% code coverage — enforced in CI across all packages, no exceptions
- Static analysis — SonarCloud quality gate on every PR: zero bugs, zero vulnerabilities, zero code smells
- Property-based testing — 1000+ property tests via fast-check across 31 packages, each running hundreds of generated inputs to verify invariants that hand-written tests miss (URL encoding, parameter serialization, route tree operations, reactive subscription ordering)
- Stress testing — 500+ dedicated stress tests across core, plugins, and all 6 framework adapters: thousands of concurrent navigations, guard removal mid-execution, route CRUD under load, heap snapshots confirming zero memory leaks, mount/unmount lifecycle validation, subscription fanout granularity, and full SPA simulations
- Playwright e2e testing — 1000+ end-to-end test cases across 100+ Playwright suites covering all 6 framework adapters (React, Preact, Solid, Vue, Svelte, Angular). Tests verify real browser behavior: navigation, guards, data loading, error handling, hash routing, nested routes, dynamic routes, and async guards
- Mutation testing — Stryker mutates source code and verifies that tests catch every mutation, ensuring test suite quality beyond line coverage
- Continuous performance benchmarking — core hot-path navigation gated by CodSpeed on every PR: deterministic CPU-instruction-count measurement (not wall-clock) catches regressions before merge
Development
This is a pnpm monorepo with Turborepo for task orchestration.
pnpm install # Install all dependencies
pnpm build # Build all packages (errors-only output)
pnpm build:verbose # Build with full output (debugging)
pnpm test -- --run # Run tests once (errors-only output)
pnpm test:verbose # Tests with full output (debugging)
pnpm type-check # TypeScript type checking
pnpm lint # ESLint
pnpm lint:e2e # Verify e2e directories have spec files
pnpm lint:unused # Check for unused code (knip)
Windows: enable symlinks
The repo uses git-tracked symlinks to share source files across framework adapters (packages/*/src/dom-utils → shared/dom-utils/ — see IMPLEMENTATION_NOTES.md for the rationale). Unix/macOS/Linux contributors need no extra setup. Windows contributors need a one-time configuration:
# 1. Enable symlink support in git (one-time, global)
git config --global core.symlinks true
Additionally, enable Developer Mode in Windows Settings, or run git from an elevated shell. After enabling both, re-clone the repo (or run git checkout on an existing clone) to materialize the symlinks. Without this, pnpm install, pnpm build, and pnpm test fail with "file not found" errors for paths under src/dom-utils/.
See CONTRIBUTING.md for full development setup, coding standards, and PR guidelines.
Contributing
Contributions are welcome! Please read the contributing guidelines before submitting a pull request.
- Good first issues — great starting points for new contributors
- Help wanted — issues where community input is needed
Changelog
The changelog is regularly updated to reflect what's changed in each new release.
Security
For details on supported versions and reporting security vulnerabilities, please refer to the security policy.