Real-Router

July 9, 2026 · View on GitHub

License: MIT npm npm downloads TypeScript PRs Welcome Engineered with Claude Code

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 call dispose() 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.

AdapterSSRStreaming SSRSSGe2e scenarios
React 19OOO <Suspense> + use()36+
Preact 10OOO <preact-island> + <Await>4 pipelines
Vue 3chunked + blocking Suspense55
SolidOOO + selective hydration59
Svelte 5deferred-data via {#await}52
AngularTransferState bridge4 pipelines

Unique primitives at the routing layer:

  • Per-route SSR modefull / 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 /ssr subpath
  • Typed loader errors → HTTPLoaderRedirect / LoaderNotFound / LoaderTimeout mapped 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 cancellationwithTimeout() composes deadline + client-disconnect into one AbortSignal; in-flight fetch aborts 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):

Frameworkvs TanStack Router
React~14× faster
Solid~11.5× faster
Vue~2.3× faster

Allocations per navigation (relative to TanStack Router, lower is better):

Frameworkvs 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 guardscanActivate / canDeactivate per 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 / #hash on push; restore / top / manual modes; custom scroll containers
  • Scroll spy — opt-in via RouterProvider.scrollSpy (docs); router-coordinated IntersectionObserver syncs 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-plugin for descriptive runtime errors on every API call. Falsy values in usePlugin() 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-plugin to attach onNavigate, onEnter, onStay, onLeave callbacks directly to route definitions — no subscribe() 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

PackageVersionDescription
@real-router/corenpmRouter implementation
@real-router/core/apiTree-shakeable modular API: getRoutesApi, getDependenciesApi, getLifecycleApi, getPluginApi, cloneRouter
@real-router/core/utilsUtility functions: serializeState (XSS-safe JSON for SSR), getStaticPaths (static path generation for SSG)
@real-router/typesnpmShared TypeScript type definitions

Framework Integration

PackageVersionDescription
@real-router/reactnpmReact 19.2+ (hooks, RouteView, Link). React 18+ via ./legacy
@real-router/preactnpmPreact (hooks, RouteView, Link, Suspense)
@real-router/solidnpmSolid.js (signals, RouteView, Link, store-based state)
@real-router/vuenpmVue 3 (composables, RouteView, Link, KeepAlive, v-link)
@real-router/sveltenpmSvelte 5 (runes, RouteView with snippets, Lazy, use:link)
@real-router/angularnpmAngular 22+ (signals, inject* functions, <route-view>, realLink directive, zoneless)

Plugins

PackageVersionDescription
@real-router/browser-pluginnpmBrowser History API and URL synchronization
@real-router/navigation-pluginnpmNavigation API integration + route-level history
@real-router/memory-pluginnpmIn-memory history: back/forward/go (no DOM)
@real-router/hash-pluginnpmHash-based routing (#/path)
@real-router/logger-pluginnpmDevelopment logging with transition tracking
@real-router/persistent-params-pluginnpmParameter persistence across navigations
@real-router/ssr-data-pluginnpmSSR per-route data loading via interceptor
@real-router/rsc-server-pluginnpmRSC per-route ReactNode loading (bundler-agnostic)
@real-router/lifecycle-pluginnpmRoute-level hooks: onNavigate, onEnter, onStay, onLeave
@real-router/preload-pluginnpmPreload on navigation intent (hover, touch)
@real-router/validation-pluginnpmRuntime argument validation for development
@real-router/search-schema-pluginnpmSearch param validation via Standard Schema

Utilities

PackageVersionDescription
@real-router/sourcesnpmReactive subscription sources for UI bindings — per-router cached getTransitionSource / createDismissableError / createActiveNameSelector + canonical params cache
@real-router/rxnpmObservable API: state$, events$, operators, TC39 Observable
@real-router/route-utilsnpmRoute tree queries: getRouteUtils, segment testers, areRoutesRelated
@real-router/loggernpmStructured logging utility

Documentation

Full documentation is available in the Wiki.

Getting Started

API Reference

React

Preact / Solid / Vue / Svelte / Angular

Plugins

Server-Side Rendering & 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{Feature}\text{React}\text{Preact}\text{Solid}\text{Vue}\text{Svelte}\text{Angular}
\text{Basic} \text{routing}\text{basic}\text{basic}\text{basic}\text{basic}\text{basic}\text{basic}
\text{Nested} \text{routes}\text{nested}-\text{routes}\text{nested}-\text{routes}\text{nested}-\text{routes}\text{nested}-\text{routes}\text{nested}-\text{routes}\text{nested}-\text{routes}
\text{Auth} \text{guards}\text{auth}-\text{guards}\text{auth}-\text{guards}\text{auth}-\text{guards}\text{auth}-\text{guards}\text{auth}-\text{guards}
\text{Data} \text{loading}\text{data}-\text{loading}\text{data}-\text{loading}\text{data}-\text{loading}\text{data}-\text{loading}\text{data}-\text{loading}
\text{Lazy} \text{loading}\text{lazy}-\text{loading}\text{lazy}-\text{loading}\text{lazy}-\text{loading}\text{lazy}-\text{loading}\text{lazy}-\text{loading}\text{lazy}-\text{loading}
\text{Async} \text{guards}\text{async}-\text{guards}\text{async}-\text{guards}\text{async}-\text{guards}\text{async}-\text{guards}\text{async}-\text{guards}
\text{Hash} \text{routing}\text{hash}-\text{routing}\text{hash}-\text{routing}\text{hash}-\text{routing}\text{hash}-\text{routing}\text{hash}-\text{routing}\text{hash}-\text{routing}
\text{Persistent} \text{params}\text{persistent}-\text{params}\text{persistent}-\text{params}\text{persistent}-\text{params}\text{persistent}-\text{params}\text{persistent}-\text{params}\text{persistent}-\text{params}
\text{Search} \text{schema}\text{search}-\text{schema}\text{search}-\text{schema}\text{search}-\text{schema}\text{search}-\text{schema}\text{search}-\text{schema}
\text{Error} \text{handling}\text{error}-\text{handling}\text{error}-\text{handling}\text{error}-\text{handling}\text{error}-\text{handling}\text{error}-\text{handling}
\text{Dynamic} \text{routes}\text{dynamic}-\text{routes}\text{dynamic}-\text{routes}\text{dynamic}-\text{routes}\text{dynamic}-\text{routes}\text{dynamic}-\text{routes}\text{dynamic}-\text{routes}
\text{Combined} (\text{all} \text{features})\text{combined}\text{combined}\text{combined}\text{combined}\text{combined}\text{combined}
\text{Framework}-\text{specific}\text{keepAlive}, \text{legacy}-\text{entry}, \text{hmr}, \text{link}-\text{hash}, \text{navigation}-\text{api}, \text{scroll}-\text{restoration}, \text{scroll}-\text{spy}, \text{ink}-\text{demo}\text{store}-\text{based}-\text{state}, \text{use}-\text{link}-\text{directive}, \text{signal}-\text{primitives}\text{plugin}-\text{installation}, \text{v}-\text{link}-\text{directive}, \text{keep}-\text{alive}\text{link}-\text{action}, \text{lazy}-\text{loading}-\text{svelte}, \text{snippets}-\text{routing}, \text{reactive}-\text{source}

</\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)
PipelineReactPreactVueSolidSvelteAngular
Classical SSRssrssrssrssrssrssr
Streaming SSRssr-streamingssr-streamingssr-streamingssr-streamingssr-streamingssr-streaming
Mixed SSR modesssr-mixedssr-mixedssr-mixedssr-mixedssr-mixedssr-mixed
SSGssgssgssgssgssgssg
RSC + Flightssr-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
PipelineReactPreactSolidVueSvelteAngular
Motion animationsmotion-animationsmotion-animationsmotion-animationsmotion-animationsmotion-animationsmotion-animations
Page animationspage-animationspage-animationspage-animationspage-animationspage-animationspage-animations
Route animationsroute-animationsroute-animationsroute-animationsroute-animationsroute-animationsroute-animations
View Transitionsview-transitionsview-transitionsview-transitionsview-transitionsview-transitionsview-transitions

| 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

Quality Gate Status Vitest Playwright Property-Based 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 testingStryker 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)

The repo uses git-tracked symlinks to share source files across framework adapters (packages/*/src/dom-utilsshared/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.

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.

License

MIT © Oleg Ivanov