@real-router/core
v0.52.0
Published
A simple, powerful, view-agnostic, modular and extensible router
Maintainers
Readme
@real-router/core
Simple, powerful, view-agnostic, modular and extensible router for JavaScript applications.
This is the core package of the Real-Router monorepo. It provides the router implementation, lifecycle management, navigation pipeline, and tree-shakeable standalone API modules.
Installation
npm install @real-router/coreQuick Start
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());
await router.start("/");
await router.navigate("users.profile", { id: "123" });Router API
Lifecycle
| Method | Returns | Description |
| ------------- | ---------------- | ---------------------------------------------- |
| start(path) | Promise<State> | Start the router with an initial path |
| stop() | this | Stop the router, cancel in-progress transition |
| dispose() | void | Permanently terminate (cannot restart) |
| isActive() | boolean | Whether the router is started |
Navigation
| Method | Returns | Description |
| ----------------------------------- | ---------------- | ----------------------------------------- |
| navigate(name, params?, options?) | Promise<State> | Navigate to a route. Fire-and-forget safe |
| navigateToDefault(options?) | Promise<State> | Navigate to the default route |
| navigateToNotFound(path?) | State | Synchronously set UNKNOWN_ROUTE state |
| canNavigateTo(name, params?) | boolean | Check if guards allow navigation |
await router.navigate("users.profile", { id: "123" });
await router.navigate("dashboard", {}, { replace: true });
// Cancellable navigation
const controller = new AbortController();
router.navigate("users", {}, { signal: controller.signal });
controller.abort();State
| Method | Returns | Description |
| -------------------------------------------------- | -------------------- | ------------------------------------ |
| getState() | State \| undefined | Current router state (deeply frozen) |
| getPreviousState() | State \| undefined | Previous router state |
| areStatesEqual(s1, s2, ignoreQP?) | boolean | Compare two states |
| isActiveRoute(name, params?, strict?, ignoreQP?) | boolean | Check if route is active |
| buildPath(name, params?) | string | Build URL path from route name |
| isLeaveApproved() | boolean | True when deactivation guards pass |
Events & Plugins
| Method | Returns | Description |
| -------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| subscribe(listener) | Unsubscribe | Listen to successful transitions |
| subscribeLeave(listener) | Unsubscribe | Subscribe to confirmed route departures. Listener may return Promise<void> to block the pipeline (exit animations). Receives AbortSignal for cancellation |
| usePlugin(...plugins) | Unsubscribe | Register plugin factories |
const unsub = router.subscribe(({ route, previousRoute }) => {
console.log(previousRoute?.name, "->", route.name);
});
// Save scroll position when leaving a route (fires ONLY when departure is confirmed)
const unsubLeave = router.subscribeLeave(({ route }) => {
if (route.name === "products") {
sessionStorage.setItem("products:scroll", String(window.scrollY));
}
});
// Async leave: exit animation blocks navigation until complete
router.subscribeLeave(async ({ signal }) => {
await animateOut(document.querySelector(".page"), { signal });
});Standalone API
Tree-shakeable functions imported from @real-router/core/api. Only imported functions are bundled.
import {
getRoutesApi,
getDependenciesApi,
getLifecycleApi,
getPluginApi,
cloneRouter,
} from "@real-router/core/api";| Function | Purpose | Key methods |
| ---------------------------- | --------------------- | ---------------------------------------------------------------------------- |
| getRoutesApi(router) | Dynamic route CRUD | add, remove, update, replace, has, get |
| getDependenciesApi(router) | Dependency injection | get, set, setAll, remove, has |
| getLifecycleApi(router) | Guard registration | addActivateGuard, addDeactivateGuard, remove* |
| getPluginApi(router) | Plugin infrastructure | makeState, matchPath, addInterceptor, extendRouter, emitTransitionError, getRouteConfig |
| cloneRouter(router, deps?) | SSR cloning | Shares route definitions, independent state |
Utilities
SSR/SSG helpers imported from @real-router/core/utils.
import { getStaticPaths, serializeState } from "@real-router/core/utils";
// SSR: XSS-safe data embedding
const json = serializeState({ name: "home", path: "/" });
const html = `<script>window.__STATE__=${json}</script>`;
// SSG: enumerate all URLs for pre-rendering
const paths = await getStaticPaths(router, {
"users.profile": async () => [{ id: "1" }, { id: "2" }],
});
// → ["/", "/users", "/users/1", "/users/2"]| Function | Purpose |
| ---------------------------------- | ------------------------------------------------------------------------------------------- |
| serializeState(data) | XSS-safe JSON serialization for embedding in HTML <script> tags |
| getStaticPaths(router, entries?) | Enumerate leaf routes and build URLs for SSG pre-rendering |
| StaticPathEntries (type) | Type for the entries parameter: Record<string, () => Promise<Record<string, string>[]>> |
getNavigator(router) (main entry)
Frozen read-only subset of router methods for view layers. Pre-bound, safe to destructure. Imported from @real-router/core, not /api.
import { getNavigator } from "@real-router/core";// Dynamic route management
const routes = getRoutesApi(router);
routes.add({ name: "settings", path: "/settings" });
routes.replace(newRoutes); // atomic HMR-safe replacement
// Dependency injection for guards and plugins
const deps = getDependenciesApi(router);
deps.set("authService", authService);
// Global lifecycle guards
const lifecycle = getLifecycleApi(router);
lifecycle.addActivateGuard("admin", (router, getDep) => (toState) => {
return getDep("authService").isAuthenticated();
});
// SSR — clone with request-scoped deps
const requestRouter = cloneRouter(router, { store: requestStore });
await requestRouter.start(req.url);Route Configuration
import type { Route } from "@real-router/core";
const routes: Route[] = [
{
name: "admin",
path: "/admin",
canActivate: (router, getDep) => (toState, fromState, signal) => {
return getDep("authService").isAdmin();
},
children: [
{
name: "dashboard",
path: "/dashboard",
defaultParams: { tab: "overview" },
},
],
},
{
name: "legacy",
path: "/old-path",
forwardTo: "home", // URL alias — guards on source are NOT executed
},
{
name: "product",
path: "/product/:id",
encodeParams: ({ id }) => ({ id: String(id) }),
decodeParams: ({ id }) => ({ id: Number(id) }),
},
];Params Contract
router.navigate(name, params) and router.buildPath(name, params) follow a stable contract for how each value type is serialized into the URL and preserved in state.params:
Input — params object values
| Value | URL path param (:id) | URL query param (?q) | state.params after navigation |
| -------------------------- | ---------------------- | -------------------------------------------------- | ------------------------------- |
| undefined | Error (required) / skip (optional :id?) | stripped — parameter absent from URL | Key absent ("q" in params is false) |
| null | Same as undefined | ?q (key-only, via nullFormat: "default") | null |
| "" (empty string) | Empty segment (caller's responsibility) | ?q= (explicit empty value, distinct from null) | "" |
| string | Encoded per urlParamsEncoding | ?q=value (URI-encoded) | Unchanged |
| number | /users/42 | ?q=42 | 42 (number, via numberFormat: "auto") |
| boolean | /users/true | ?q=true / ?q=false (via booleanFormat: "auto") | true / false |
| 0, false (falsy-defined) | Coerced to string | Preserved (not stripped) | Preserved |
undefined is stripped at the core boundary. This is an explicit public contract, not an implementation detail. Plugins that add undefined values via addInterceptor("forwardState") also have them scrubbed before URL and state.
Output — parsing query strings back (match())
| URL fragment | booleanFormat: "auto" (default) | booleanFormat: "empty-true" | booleanFormat: "none" |
| ------------- | --------------------------------- | ----------------------------- | ----------------------- |
| ?flag | null | true | null |
| ?flag= | "" | "" | "" |
| ?flag=x | "x" | "x" | "x" |
| ?flag=true | true (coerced) | "true" | "true" |
| ?flag=false | false (coerced) | "false" | "false" |
?flag and ?flag= are distinct: three-state expressiveness (absent / explicit empty / has value). Matches search-params engine semantics.
Example
router.navigate("search", {
q: "hello",
page: undefined, // stripped
sort: null, // becomes ?sort (key-only)
filter: "", // becomes ?filter= (explicit empty)
active: true, // becomes ?active=true
});
// URL: /search?q=hello&sort&filter=&active=true
//
// state.params:
// { q: "hello", sort: null, filter: "", active: true }
// ("page" key is absent)Configuration
Query string behavior is configurable via queryParams option on createRouter:
const router = createRouter(routes, {
queryParams: {
booleanFormat: "empty-true", // `true` → ?flag, `false` → ?flag=false
nullFormat: "hidden", // `null` → stripped (vs `default`: ?key)
numberFormat: "none", // `"42"` stays string after parse
arrayFormat: "brackets", // `[1,2]` → ?x[]=1&x[]=2
},
});See @real-router/search-schema-plugin for schema-driven parsing with Zod/Valibot/ArkType — handles booleanFormat interaction and explicit type coercion.
Error Handling
Navigation errors are instances of RouterError with typed error codes:
import { RouterError, errorCodes } from "@real-router/core";
try {
await router.navigate("admin");
} catch (err) {
if (err instanceof RouterError) {
// err.code: ROUTE_NOT_FOUND | CANNOT_ACTIVATE | CANNOT_DEACTIVATE
// | TRANSITION_CANCELLED | SAME_STATES | DISPOSED | ...
}
}See RouterError and Error Codes for the full reference.
Validation
Runtime argument validation is available via @real-router/validation-plugin:
import { validationPlugin } from "@real-router/validation-plugin";
router.usePlugin(validationPlugin()); // register before start()
await router.start("/");The plugin adds descriptive error messages for every public API call. Register it in development, skip in production.
Documentation
Full documentation: Wiki
- Core Concepts — overview and mental model
- Defining Routes — nesting, path syntax, guards
- Navigation Lifecycle — transitions, guards, hooks
- RouterOptions —
defaultRoute,trailingSlash,allowNotFound, and more - Plugin Architecture — interception, extension, events
- Migration from router5
Related Packages
| Package | Description |
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- |
| @real-router/react | React integration (RouterProvider, hooks, Link, RouteView) |
| @real-router/browser-plugin | Browser History API and URL synchronization |
| @real-router/hash-plugin | Hash-based routing |
| @real-router/rx | Observable API (state$, events$, TC39 Observable) |
| @real-router/logger-plugin | Development logging |
| @real-router/persistent-params-plugin | Parameter persistence |
| @real-router/route-utils | Route tree queries and segment testing |
Contributing
See contributing guidelines for development setup and PR process.
