-
-
Notifications
You must be signed in to change notification settings - Fork 1
Navigation Plugin
- Name: Navigation Plugin
-
Package:
@real-router/navigation-plugin - Purpose: Integrates Real Router with the browser Navigation API, synchronizing router state with browser URL and providing route-level history access unavailable in the History API.
- Typical scenarios: SPAs targeting modern browsers where you need to inspect session history entries, check which routes a user has visited, or jump directly to a past history entry without stepping through intermediate pages.
npm install @real-router/navigation-plugin
# or
pnpm add @real-router/navigation-pluginimport { createRouter } from "@real-router/core";
import { navigationPluginFactory } from "@real-router/navigation-plugin";
const router = createRouter(routes);
router.usePlugin(navigationPluginFactory(options));Peer dependency: @real-router/core
| Option | Type | Default | Description |
|---|---|---|---|
base |
string |
"" |
Base path for all routes (e.g., "/app") |
forceDeactivate |
boolean |
true |
Force deactivation of current route even if canDeactivate returned false
|
Same two options as browser-plugin. The plugins are interchangeable at the options level.
Looking for hash-based routing? Use
@real-router/hash-plugininstead.
// Minimal configuration
navigationPluginFactory();
// With base path
navigationPluginFactory({ base: "/app" });
// URL: example.com/app/users
// Disable force deactivation (respect canDeactivate guards on back/forward)
navigationPluginFactory({ forceDeactivate: false });The plugin performs runtime validation at factory time and throws a plain Error with a descriptive message on any violation.
Type checks
- Validates option types against defaults (string for
base, boolean forforceDeactivate).
base rules (via safeBaseRule from the shared browser-env)
- Must not contain control characters (
\u0000-\u001F,\u007F). - Must not contain
..path segments (e.g.,/app/../evil). - Normalised to canonical form via
normalizeBase: leading/, no trailing/, no//runs."app"→"/app","/app/"→"/app","//app//"→"/app","/"→"".
navigationPluginFactory({ base: "../evil" }); // throws: must not contain '..' segments
navigationPluginFactory({ base: "/app\nX" }); // throws: must not contain control charactersThe Navigation API (~89% global coverage as of 2026) gives you access to the full session history as structured data. Unlike the History API, you can inspect every entry, check what routes the user has visited, and traverse directly to a specific past entry.
// Not possible with browser-plugin:
router.peekBack(); // what's one step back?
router.hasVisited("checkout"); // did the user visit checkout?
router.getVisitedRoutes(); // all routes in this session
router.traverseToLast("users.list"); // jump back to the last users listBrowser support: Chrome 102+, Firefox 147+, Safari 26.2+, Opera 88+. For broader support, use @real-router/browser-plugin as a fallback (see Feature Detection).
navigation-plugin runs in desktop WebViews too, but availability depends on the host:
- Electron — always supported (Chromium ships the Navigation API since Chrome 102, 2022).
- Tauri Windows / Android — supported (Chromium-based WebView).
- Tauri macOS / iOS / Linux — depends on the WebKit version. See the full matrix in the Desktop Integration Guide.
On an unsupported WebView the factory throws immediately:
Error: [navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.
This is intentional (fail-fast) — the exclusive methods (peekBack, hasVisited, traverseToLast, etc.) cannot be emulated without the API.
The plugin implements the following hooks:
| Hook | Implemented | Description |
|---|---|---|
onStart |
✅ | Subscribes to Navigation API navigate events |
onStop |
✅ | Unsubscribes from navigate events |
onTransitionStart |
✅ | Writes captured NavigationMeta to state.context.navigation
|
onTransitionSuccess |
✅ | Updates NavigationHistoryEntry state and browser URL |
onTransitionError |
✅ | Clears capturedMeta and pendingTraverseKey
|
onTransitionCancel |
✅ | Clears capturedMeta and pendingTraverseKey
|
teardown |
✅ | Cleans up listeners, removes router extensions |
- Subscribes to the Navigation API
navigateevent - Removes existing listener if plugin is restarted
- Determines:
push(new entry) orreplace(replacement) viashouldReplaceHistory -
replaceis used when:- First navigation (
fromStateis absent) andreplaceis not explicitlyfalse -
replace: trueoption -
reload: trueoption with equal states
- First navigation (
- Preserves hash fragment when navigating to the same path (or on the first navigation with no
fromState) - Note: passing
replace: falseon the first navigation forcespush— this is an explicit user override
- Removes event listeners
- Removes start interceptor
- Removes router extensions (
buildUrl,matchUrl,replaceHistoryState, and all exclusive extensions) viaextendRouterunsubscribe - Releases
"navigation"context namespace claim
The plugin adds the following methods to the router instance via extendRouter():
| Method | Returns | Description |
|---|---|---|
buildUrl(name, params?) |
string |
Build full URL with base path |
matchUrl(url) |
State | undefined |
Parse URL to router state |
replaceHistoryState(name, params?) |
void |
Update browser URL without triggering navigation. Preserves location.hash — symmetric with onTransitionSuccess
|
router.buildUrl("users", { id: "123" });
// => "/app/users/123" (with base "/app")
router.matchUrl("/app/users/123");
// => { name: "users", params: { id: "123" }, path: "/users/123" }
// Update URL silently (no transition, no guards)
router.replaceHistoryState("users", { id: "456" });router.buildPath("users", { id: 1 }); // "/users/1" — core, no base
router.buildUrl("users", { id: 1 }); // "/app/users/1" — plugin, with baserouter.replaceHistoryState(name, params); // URL only, no transition
router.navigate(name, params, { replace: true }); // Full transition + URL updateThese methods are only available with navigation-plugin. They have no equivalent in browser-plugin.
| Method | Returns | Description |
|---|---|---|
peekBack() |
State | undefined |
State of the previous history entry |
peekForward() |
State | undefined |
State of the next history entry |
hasVisited(routeName) |
boolean |
Whether any history entry matches the route |
getVisitedRoutes() |
string[] |
Unique route names across all history entries |
getRouteVisitCount(routeName) |
number |
How many history entries match the route |
traverseToLast(routeName) |
Promise<State> |
Navigate to the last history entry for a route |
canGoBack() |
boolean |
Whether there's a previous history entry |
canGoForward() |
boolean |
Whether there's a next history entry |
canGoBackTo(routeName) |
boolean |
Whether any previous entry matches the route |
Preview where back/forward would take the user without navigating:
const prev = router.peekBack();
if (prev) {
console.log(`Back goes to: ${prev.name}`);
}
const next = router.peekForward();
if (next) {
console.log(`Forward goes to: ${next.name}`);
}Inspect the session history to understand where the user has been:
// Check if the user has been to a route in this session
if (router.hasVisited("checkout")) {
showResumeCheckoutBanner();
}
// Get all routes visited in this session
const visited = router.getVisitedRoutes();
// => ["home", "users.list", "users.view", "checkout"]
// How many times did the user visit the product page?
const count = router.getRouteVisitCount("products.view");Jump directly to the last time the user was on a given route. Skips intermediate entries without stepping through them one by one:
await router.traverseToLast("users.list");Drive UI state for navigation controls:
// Disable back/forward buttons when there's nowhere to go
const backDisabled = !router.canGoBack();
const forwardDisabled = !router.canGoForward();
// Show "back to list" only if the user actually came from the list
if (router.canGoBackTo("users.list")) {
showBackToListButton();
}The navigation plugin writes navigation metadata to state.context.navigation on every transition. This replaces the former getNavigationMeta() router extension -- metadata is now directly on the state object, available in subscribe callbacks and components without calling a separate method.
interface NavigationMeta {
/** Type of navigation: push, replace, traverse, or reload */
navigationType: "push" | "replace" | "traverse" | "reload";
/** Whether the navigation was initiated by the user (back/forward button, link click) */
userInitiated: boolean;
/** Ephemeral info passed via navigation.navigate({ info }) -- lost on page reload */
info?: unknown;
/** Direction of navigation in the history stack */
direction: "forward" | "back" | "unknown";
/** The DOM element that initiated the navigation (e.g., anchor tag), or null for programmatic */
sourceElement: Element | null;
}The plugin augments StateContext from @real-router/types:
declare module "@real-router/types" {
interface StateContext {
navigation?: NavigationMeta;
}
}Import the plugin package to activate type inference:
import "@real-router/navigation-plugin";// In subscribe callback
router.subscribe((state) => {
const nav = state.context.navigation;
if (nav) {
console.log(nav.navigationType); // "push" | "replace" | "traverse" | "reload"
console.log(nav.userInitiated); // true if user clicked back/forward/link
console.log(nav.direction); // "forward" | "back" | "unknown"
console.log(nav.info); // data passed via navigation.navigate({ info })
}
});// In a React component
import { useRoute } from "@real-router/react";
import "@real-router/navigation-plugin";
function NavigationIndicator() {
const { route } = useRoute();
const nav = route?.context.navigation;
if (nav?.direction === "back") {
return <SlideRightTransition />;
}
return <SlideLeftTransition />;
}The plugin writes context in two hooks:
-
onTransitionStart: Writes captured metadata from the Navigation APInavigateevent (browser-initiated navigations) -
onTransitionSuccess: Writes or overwrites with the final metadata including derivednavigationTypeanddirection(all navigations)
Both writes are visible in subscribe() callbacks.
Use navigationPluginFactory when the Navigation API is available, fall back to browserPluginFactory otherwise:
import { browserPluginFactory } from "@real-router/browser-plugin";
import { navigationPluginFactory } from "@real-router/navigation-plugin";
const plugin =
"navigation" in globalThis
? navigationPluginFactory({ base })
: browserPluginFactory({ base });
router.usePlugin(plugin);If "navigation" in globalThis is false and no custom browser is injected, the factory throws immediately:
Error: [navigation-plugin] Navigation API is not supported. Use @real-router/browser-plugin instead.
Router Navigation:
navigate() → Promise<State> → onTransitionSuccess → navigation.navigate()
Browser Navigate Event:
navigate event → handler → router.navigate() / navigateToNotFound() / navigateToDefault()
The Navigation API serializes navigation via event.intercept() — only one navigation runs at a time. There's no deferred queue needed (unlike browser-plugin's popstate handling, which requires race condition protection).
All navigation methods return Promise<State>:
-
router.start(path?)— path is optional; the plugin injectsbrowser.getLocation()when no path is given -
router.navigate()— used in the navigate event handler withawaitand try/catch -
router.navigateToNotFound(path?)— called whenallowNotFound: trueand the URL doesn't match any route -
strict mode unmatched URL (
allowNotFound: false) — emits$$errorwithROUTE_NOT_FOUNDviaapi.emitTransitionError(), then rejectsevent.intercept()so the Navigation API auto-rolls back the URL. No silentnavigateToDefault()fallback.
-
URL Building:
buildUrlcorrectly addsbasepath -
URL Matching:
matchUrlparses URL via URL API, supports IPv6, Unicode -
Browser History:
pushfor new transitions,replacefor replacement - Navigate Event: Handles back/forward buttons with full lifecycle transition
- Hash preservation: Hash is always preserved when navigating to the same path. Navigating to a different route clears the hash.
-
SSR safety: In a non-browser environment, the plugin falls back to no-ops via
createNavigationFallbackBrowser. No errors, methods return safe defaults. -
CANNOT_DEACTIVATE auto-rollback: When a guard blocks navigation, the Navigation API automatically rolls back the URL via
event.intercept()rejection. No manualreplaceStateneeded (simpler than browser-plugin's manual recovery). -
Error recovery:
recoverFromNavigateErrorrestores the URL on non-RouterError exceptions by callingbrowser.navigate(url, { history: "replace" }). -
replaceHistoryState fires navigate event: The plugin sets
isSyncingFromRouter = truebefore callingbrowser.replaceStateto prevent the navigate handler from triggering a full navigation. -
Missing state / unmatched URL: When
allowNotFound: true— callsnavigateToNotFound(browser.getLocation())to preserve the URL. WhenallowNotFound: false— emits$$errorwithROUTE_NOT_FOUNDviaapi.emitTransitionError()and throws in theevent.intercept()handler so the Navigation API auto-rolls back the URL. Router state is unchanged.defaultRouteis not consulted as an implicit fallback — see RouterOptions#allowNotFound for the userland migration snippet. -
Defensive entry URL parsing:
entryToStateand thetraverseToLastentry lookup delegate URL parsing tosafeParseUrl(viaextractPathFromAbsoluteUrl). Malformed entry URLs from mocks, extensions, or non-spec sources resolve toundefinedinstead of throwing from thenavigateevent handler — the Navigation API event proceeds through the "no matching route" branch. -
nav.navigateoptions forwarding:browser.navigate(url, options)forwards the fulloptionsobject to the Navigation API. Callers can passinfo,downloadRequest, and any future Navigation API options transparently.
entry.getState() = {
name: "users.view",
params: { id: "123" },
path: "/users/123",
};Navigation metadata is available on state.context.navigation after each transition -- not stored in the history entry itself.
| Browser | Minimum Version | Notes |
|---|---|---|
| Chrome | 102+ | Full support |
| Firefox | 147+ | Full support |
| Safari | 26.2+ | Full support |
| Opera | 88+ | Full support |
~89% global coverage as of 2026. For the remaining ~11%, use the Feature Detection pattern to fall back to browser-plugin.
Switching from browser-plugin to navigation-plugin is a one-line import change. Options are identical.
// Before
import { browserPluginFactory } from "@real-router/browser-plugin";
router.usePlugin(browserPluginFactory({ base: "/app" }));
// After
import { navigationPluginFactory } from "@real-router/navigation-plugin";
router.usePlugin(navigationPluginFactory({ base: "/app" }));All existing behavior is preserved. The exclusive extensions (peekBack, hasVisited, etc.) become available immediately after the swap.
Set forceDeactivate: false to respect canDeactivate guards on back/forward:
router.usePlugin(navigationPluginFactory({ forceDeactivate: false }));
import { getLifecycleApi } from "@real-router/core/api";
const lifecycle = getLifecycleApi(router);
lifecycle.addDeactivateGuard(
"checkout",
(router, getDep) => (toState, fromState) => {
return !hasUnsavedChanges(); // false blocks back/forward
},
);import { createRouter } from "@real-router/core";
import { navigationPluginFactory } from "@real-router/navigation-plugin";
const routes = [
{ name: "home", path: "/" },
{ name: "users", path: "/users" },
{ name: "users.view", path: "/view/:id" },
];
const router = createRouter(routes, {
defaultRoute: "home",
});
router.usePlugin(navigationPluginFactory());
await router.start();
// Navigation
await router.navigate("users.view", { id: "123" });
// Building URL
const url = router.buildUrl("users.view", { id: "123" });
// => "/users/view/123"
// Matching URL
const state = router.matchUrl("https://example.com/users/view/456");
// => { name: "users.view", params: { id: "456" }, ... }
// History inspection
const prev = router.peekBack();
const visited = router.getVisitedRoutes();import { createRouter } from "@real-router/core";
import { browserPluginFactory } from "@real-router/browser-plugin";
import { navigationPluginFactory } from "@real-router/navigation-plugin";
import { loggerPluginFactory } from "@real-router/logger-plugin";
import { persistentParamsPluginFactory } from "@real-router/persistent-params-plugin";
const routes = [
{ name: "home", path: "/" },
{ name: "users", path: "/users?page&sort" },
];
const router = createRouter(routes, {
defaultRoute: "home",
queryParamsMode: "default",
});
const base = "/app";
const plugin =
"navigation" in globalThis
? navigationPluginFactory({ base, forceDeactivate: false })
: browserPluginFactory({ base, forceDeactivate: false });
// Plugin order matters!
router.usePlugin(loggerPluginFactory());
router.usePlugin(persistentParamsPluginFactory(["lang", "theme"]));
router.usePlugin(plugin);
await router.start();- @real-router/browser-plugin — History API integration (broader browser support)
- @real-router/hash-plugin — Hash-based routing (no server config needed)
- Plugin Architecture — How plugins work in Real Router
-
Guards — Navigation guards and
canDeactivate
- View-Agnostic Design
- Core Concepts
- Navigation Lifecycle
- Guards
- Plugin Architecture
- Hash Fragment Support
- Accessibility (A11y)
- Server-Side Rendering
- Data Loading
- Streaming SSR
- SSR Cancellation
- RSC Integration
- Testing
- Glossary
Tree-shakeable functions — import only what you need.
- add (addRoute)
- remove (removeRoute)
- replace (replaceRoutes)
- clear (clearRoutes)
- get (getRoute)
- has (hasRoute)
- update (updateRoute)
- subscribeChanges (Routes Mutation Event)
- get (getDependency)
- getAll (getDependencies)
- set (setDependency)
- setAll (setDependencies)
- has (hasDependency)
- remove (removeDependency)
- reset (resetDependencies)
For plugin authors, not for general use.
- makeState
- buildState
- buildNavigationState
- forwardState
- getForwardState
- setForwardState
- matchPath
- setRootPath
- getRootPath
- navigateToState
- addEventListener
- getRouteConfig
- getOptions
- addBuildPathInterceptor
- extendRouter
- getTree
- React Integration Guide
- Preact Integration Guide
- Solid Integration Guide
- Vue Integration Guide
- Svelte Integration Guide
- Ink (Terminal UI) Integration Guide
- Desktop (Electron, Tauri) Integration Guide
- useRouter
- useRoute
- useRouteNode
- useNavigator
- useRouteUtils
- useRouterTransition
- useRouteExit
- useRouteEnter
- useRouteStore (Solid only)
- useRouteNodeStore (Solid only)
SSR-feature subpath —
@real-router/{adapter}/ssr. Symmetric across React/Preact/Solid/Vue/Svelte.
- Lazy (Svelte only — dynamic component import)
- Await — read deferred SSR data by key
- Streamed — Suspense-style boundary
- ClientOnly — server fallback → client children swap
- ServerOnly — server children, removed after hydration
- HttpStatusCode — render-time HTTP status declaration
-
HttpStatusProvider — provides sink to descendant
<HttpStatusCode> - useDeferred — read deferred Promise by key