@isorouter/core
v1.2.1
Published
Framework-agnostic router built on the Navigation API.
Maintainers
Readme
@isorouter/core
A lightweight, framework-agnostic SPA router built on the browser Navigation API.
Route matching, navigation guards, lazy loading and an async commit state
machine — all in pure TypeScript, with zero runtime dependencies. Use it
directly, or through a thin adapter:
@isorouter/svelte,
@isorouter/react,
@isorouter/vue.
Size
A single ESM file — ~4 KB minified, 1.8 KB gzipped
(Bundlephobia) — with
zero runtime dependencies and "sideEffects": false for full
tree-shaking. The unminified source is 8.3 KB; the published npm package
(incl. type declarations, README and LICENSE) is ~9 KB packed / ~25 KB
unpacked across 9 files.
Install
npm install @isorouter/coreRequirements
Navigation API in the browser. It reached Baseline Newly available in early 2026 (Chrome, Edge, Firefox, Safari) but is not yet Widely available. isorouter ships no History API fallback by design — if you need to support older engines, load a polyfill before starting the router:
if (!window.navigation) { const { applyPolyfill } = await import("@virtualstate/navigation/apply-polyfill"); applyPolyfill(); } router.start();@virtualstate/navigationis the only polyfill we're aware of that implementsnavigate+event.intercept(), which isorouter's interception model depends on. Our e2e suite runs the full test matrix a second time with the native Navigation API hidden, against this polyfill, to verify behavioural parity (npm run test:e2e:polyfill).Known limitation: as of
@virtualstate/[email protected],interceptWindowClicksreportsdownloadRequest: ""(instead ofnull) for plain<a>clicks, so thenavigateevent isn't intercepted and the polyfill falls back to a full-page navigation for link clicks — the route still renders correctly (the fresh page load re-runsrouter.start()), but the transition isn't client-side. Imperative navigation (router.navigate,back,forward, guards, redirects, lazy loading) is unaffected and works identically to native. Tracked upstream at virtualstate/navigation.TypeScript ≥ 6.0 for full type support.
lib.dom.d.tshas shipped the Navigation API types (Navigation,NavigateEvent,NavigationResult, the globalnavigation) since TS 6.0, so no extra@typespackage is needed. On TypeScript < 6, install@types/dom-navigationyourself.
Quick start
import { createCoreRouter, lazy } from "@isorouter/core";
const router = createCoreRouter([
{ path: "/", component: Home },
{ path: "/concerts/:city", component: Concerts },
{ path: "/users/:id", component: lazy(() => import("./User")) },
] as const);
router.subscribe((snapshot) => render(snapshot));
router.start();createCoreRouter is a thin wrapper around new Router(routes, options).
The external-store contract
The router publishes state as an immutable snapshot — a fresh object reference on every commit, stable in between:
interface RouterSnapshot<C> {
/** Matched chain's components, root → leaf (routes with no component removed). */
components: C[];
params: Record<string, string>;
url: URL;
status: "idle" | "navigating" | "not-found" | "error";
error: unknown;
}router.subscribe(fn)— registersfn(snapshot), returns an unsubscribe.router.getSnapshot()— returns the current snapshot (referentially stable until the next commit).
This is the lowest common denominator across reactivity systems: it plugs
straight into React's useSyncExternalStore, Svelte 5's createSubscriber,
Vue's shallowRef, or anything else that reacts to a changed reference.
Route config
interface RouteConfig<C = unknown> {
path?: string;
index?: boolean;
component?: C | LazyComponent<C>;
beforeLoad?: BeforeLoad;
title?: string | ((ctx: GuardContext) => string);
children?: readonly RouteConfig<C>[];
}path—"users/:id"for a param,"files/*"for a catch-all splat (params["*"]gets the remaining path, decoded). Static segments win over params, which win over splats, regardless of declaration order; ties are broken by source order.index— matches when the parent's path is matched exactly (no remaining segments).component— a value, orlazy(() => import("./Page"))for code-splitting. Routes with nocomponentare matched (e.g. as pass-through layouts) but contribute nothing tosnapshot.components.children— nested routes. A matched parent with no matching child still resolves on its own if the path is fully consumed.title— setsdocument.titleon commit. The deepest route in the matched chain that defines atitlewins.
Guards
type BeforeLoad = (ctx: GuardContext) => Awaitable<void | boolean | string>;
interface GuardContext {
params: Record<string, string>;
url: URL;
pathname: string;
/** Aborts when this navigation is superseded by a newer one. */
signal: AbortSignal;
navigationType: "reload" | "push" | "replace" | "traverse";
}beforeLoad runs root → leaf over the matched chain before any component
commits:
- return nothing /
true→ allow - return
false→ block (the current URL is restored) - return a
string→ redirect (replace) to that path
Lazy loading
import { lazy } from "@isorouter/core";
const User = lazy(() => import("./User"));The dynamic import runs once on first match and its default export is
cached on the LazyComponent for subsequent navigations.
Imperative navigation
router.navigate("/concerts/kyiv");
router.navigate("/concerts/kyiv", { replace: true, state: { from: "search" } });
router.back();
router.forward();navigate throws if navigation is unavailable (no polyfill loaded). back
and forward are no-ops in that case.
Active-link helper
router.isActive("/concerts"); // true for "/concerts" and "/concerts/kyiv"
router.isActive("/concerts", { exact: true }); // true only for "/concerts"Type-safe navigation
Declare routes as const to get compile-time path templates — navigate
only accepts known paths, with an optional ?query or #hash:
// Href<typeof routes> -> "/" | "/concerts" | `/concerts/${string}` | `/users/${string}`
router.navigate("/concerts/kyiv"); // ok
router.navigate("/concerts/kyiv?from=search"); // ok
router.navigate("/no-such-route"); // type errorExtractParams<"/concerts/:city"> resolves to { city: string } for typing
route params elsewhere.
Lifecycle
router.start(); // begins intercepting same-origin navigations
router.stop(); // removes the listener, aborts any in-flight commitstart() is a no-op if navigation is unavailable. Adapters call this for
you on mount/unmount.
Options
interface RouterOptions {
scroll?: "after-transition" | "manual";
onError?: (err: unknown) => void;
onCommit?: (snapshot: RouterSnapshot<unknown>) => void;
}License
MIT © Mykhailo Pidkhvatylin
