plugin architecture - greydragon888/real-router GitHub Wiki
Plugins are the primary extension point in real-router. They observe the router lifecycle and react to events — URL synchronization, logging, analytics, and more. Unlike guards, which make decisions about whether navigation should proceed, plugins are notified after those decisions are made. They can't block or redirect; they respond.
This separation keeps the core transition pipeline clean. Guards own the "should we go?" question. Plugins own the "we went, now what?" work.
A plugin is a plain object with optional lifecycle methods. Each method corresponds to a router event. When the router starts, stops, or completes a navigation, it calls the matching method on every registered plugin.
The factory pattern wraps that object: instead of passing a plugin directly, you pass a function that creates the plugin. The factory receives the router instance and a dependency getter, so the plugin can access both the router's state and any services your app has registered.
import type { PluginFactory } from "@real-router/core";
const analyticsPlugin: PluginFactory = (router, getDependency) => {
return {
onTransitionSuccess(toState, fromState, opts) {
console.log(`Navigated to ${toState.name}`);
},
};
};
router.usePlugin(analyticsPlugin);Plugins are global — they receive events for every navigation, regardless of which route is involved. That's the right tool for cross-cutting concerns. For per-route logic, use guards instead.
Falsy values (undefined, null, false) are silently skipped, so conditional registration works inline:
router.usePlugin(
browserPluginFactory(),
__DEV__ && validationPlugin(),
hasConsent && analyticsPlugin(),
);See usePlugin for registration details, limits, and error handling.
All seven hooks are optional. Implement only what you need.
| Hook | Signature | When it fires |
|---|---|---|
onStart |
() => void |
Once, when router.start() completes |
onStop |
() => void |
When router.stop() is called |
onTransitionStart |
(toState: State, fromState?: State) => void |
At the beginning of navigation, before guards run |
onTransitionLeaveApprove |
(toState: State, fromState?: State) => void |
After deactivation guards pass, before activation guards run (LEAVE_APPROVED phase) |
onTransitionSuccess |
(toState: State, fromState: State | undefined, opts: NavigationOptions) => void |
After successful navigation, state already updated |
onTransitionError |
(toState: State | undefined, fromState: State | undefined, err: RouterError) => void |
When navigation fails for any reason |
onTransitionCancel |
(toState: State, fromState?: State) => void |
When navigation is cancelled by a concurrent navigation |
teardown |
() => void |
When the plugin is removed via the unsubscribe function or router.dispose()
|
A few things worth noting:
onTransitionSuccess receives three arguments. The third, opts, is the NavigationOptions object from the original navigate() call. The browser-plugin uses it to decide whether to push or replace the history entry.
onTransitionLeaveApprove fires between deactivation and activation. It's the right place for departure side-effects — scroll saving, fetch abort, analytics. router.getState() still returns the current route at this point. If a subsequent canActivate guard blocks the navigation, the side-effects already ran, but that's fine: saving a scroll position is idempotent. See subscribeLeave() for the public API equivalent.
onTransitionSuccess can fire without a preceding onTransitionStart. When navigateToNotFound() is called, onTransitionSuccess fires directly — there is no onTransitionStart before it. Plugin authors should not assume that every onTransitionSuccess is preceded by onTransitionStart. The opts parameter will have replace: true and no signal.
onTransitionError receives toState: State | undefined. If navigation fails because the route doesn't exist, there's no valid target state to report. Your error handler needs to account for that. createErrorSource from @real-router/sources subscribes to onTransitionError events to power RouterErrorBoundary.
onStart won't fire if the router is already running. Register plugins before calling router.start(). If you register after, you'll get a warning and onStart will be skipped.
teardown is your cleanup hook. Remove event listeners, cancel subscriptions, clear timers. It runs when the unsubscribe function returned by usePlugin() is called, and also when router.dispose() shuts everything down.
This diagram shows when each plugin hook fires during a typical navigation:
sequenceDiagram
participant App
participant Router
participant Guards
participant Plugin
App->>Router: router.navigate("users.profile", params)
Router->>Plugin: onTransitionStart(toState, fromState)
Router->>Guards: run deactivation guards
Guards-->>Router: deactivation passed
Router->>Plugin: onTransitionLeaveApprove(toState, fromState)
Note over Router,Plugin: LEAVE_APPROVED phase — departure confirmed,<br/>state not yet changed, subscribeLeave() fires here
Router->>Guards: run activation guards
Guards-->>Router: activation passed
Router->>Router: setState(toState)
Router->>Plugin: onTransitionSuccess(toState, fromState, opts)
Router-->>App: Promise resolves with toState
Note over Router,Plugin: On guard failure or error:
Router->>Plugin: onTransitionError(toState?, fromState, err)
Router-->>App: Promise rejects with RouterError
Note over Router,Plugin: On concurrent navigation:
Router->>Plugin: onTransitionCancel(toState, fromState)
The router calls onTransitionStart before guards run, so the plugin sees the intent to navigate. By the time onTransitionSuccess fires, the state is already committed — router.getState() returns the new state.
The factory signature is:
type PluginFactory<Dependencies = object> = (
router: Router<Dependencies>,
getDependency: <K extends keyof Dependencies>(key: K) => Dependencies[K],
) => Plugin;Both parameters are always available. The factory runs once at registration time, not on every navigation. That's the right place to capture references, set up closures, and store anything the plugin needs across its lifetime.
A plain object can't access the router or your app's services. The factory solves that by receiving both at creation time. The returned plugin object is then frozen — you can't modify it after registration.
getDependency retrieves values registered with setDependency. It's typed against your Dependencies generic, so TypeScript knows what each key returns.
import type { PluginFactory } from "@real-router/core";
interface AppDependencies {
analytics: AnalyticsService;
}
const analyticsPlugin: PluginFactory<AppDependencies> = (
router,
getDependency,
) => {
// Captured once at registration time
const analytics = getDependency("analytics");
return {
onStart() {
analytics.init();
},
onTransitionSuccess(toState, fromState, opts) {
analytics.pageView({
route: toState.name,
params: toState.params,
previousRoute: fromState?.name,
});
},
onTransitionError(toState, fromState, err) {
analytics.trackError({
attempted: toState?.name,
error: err.code,
});
},
teardown() {
analytics.flush();
},
};
};See getDependency and setDependency for the dependency injection API.
The conceptual shape of a plugin follows from what you want to observe. Most plugins care about one or two hooks.
Tracking page views belongs in onTransitionSuccess. By that point, the navigation is complete and toState reflects where the user actually landed. Use fromState to detect the first navigation (it's undefined on the initial load).
Cleaning up belongs in teardown. If your plugin adds event listeners, holds timers, or maintains subscriptions, teardown is where you remove them. The router calls it exactly once, whether the plugin is removed explicitly or the router is disposed.
Accessing current state is straightforward — the router instance passed to the factory has the full public API. Call router.getState() anywhere in your plugin's methods to read the current state.
Handling errors gracefully means checking toState for undefined in onTransitionError. A route-not-found failure won't have a valid target state, so guard against that before accessing toState.name.
Reacting to specific routes leaving or entering requires choosing the right hook for each direction:
Leave logic belongs in onTransitionLeaveApprove(toState, fromState?) or router.subscribeLeave(). Both fire after deactivation guards pass but before activation guards run — guaranteeing the callback only executes when departure is confirmed. At this point router.getState() still returns the current route, so scroll positions, form state, and DOM are all intact.
Enter logic belongs in onTransitionSuccess(toState, fromState?). By that point the state has changed and toState.transition.segments tells you exactly which segments were activated.
This separation means side-effects only run when departure is guaranteed — no more mixing decision and side-effect in a single guard function.
const routeReactionsPlugin: PluginFactory = (router) => {
const leaveHandlers = new Map<
string,
(toState: State, fromState?: State) => void
>();
const enterHandlers = new Map<
string,
(toState: State, fromState?: State) => void
>();
// Register per-route reactions
leaveHandlers.set("users.profile", (toState, fromState) => {
analytics.track("left_profile", { userId: fromState?.params.id });
});
enterHandlers.set("admin.dashboard", (toState) => {
dashboardStore.prefetch(toState.params);
});
return {
onTransitionLeaveApprove(toState, fromState) {
// Departure confirmed — safe to run leave side-effects
const deactivated = fromState
? getDeactivatedSegments(fromState, toState)
: [];
for (const segment of deactivated) {
leaveHandlers.get(segment)?.(toState, fromState);
}
},
onTransitionSuccess(toState, fromState) {
// State has changed — run enter side-effects
const { activated } = toState.transition.segments;
for (const segment of activated) {
enterHandlers.get(segment)?.(toState, fromState);
}
},
};
};A minimal but complete example:
const pageTrackingPlugin: PluginFactory<AppDependencies> = (
router,
getDependency,
) => {
const tracker = getDependency("tracker");
let cleanupFn: (() => void) | undefined;
return {
onStart() {
// Set up any listeners that need the router to be running
cleanupFn = tracker.onSessionEnd(() => {
const state = router.getState();
if (state) tracker.recordFinalRoute(state.name);
});
},
onTransitionSuccess(toState, fromState, opts) {
tracker.recordPageView(toState.name, {
isFirstLoad: fromState === undefined,
replaced: opts.replace ?? false,
});
},
onTransitionError(toState, fromState, err) {
if (toState) {
tracker.recordFailedNavigation(toState.name, err.code);
}
},
teardown() {
cleanupFn?.();
},
};
};Three plugins ship with real-router, each demonstrating a different use of the lifecycle hooks:
Browser Plugin (@real-router/browser-plugin) — Synchronizes router state with the browser's History API. Uses onStart to set up popstate listeners, onTransitionSuccess to call pushState/replaceState, and teardown to remove listeners and restore the original router.start method. Also adds buildUrl, matchUrl, and replaceHistoryState to the router instance.
Logger Plugin (@real-router/logger-plugin) — Logs all router events to the console for debugging. Implements every hook to give a complete picture of what the router is doing during development.
Persistent Params (@real-router/persistent-params-plugin) — Automatically carries specified query parameters across navigations. Useful for locale, theme, or debug flags that should survive route changes without being passed manually every time.
Real-Router preserves all custom fields from route definitions. Plugins access them via getRouteConfig() on the Plugin API, enabling a powerful pattern: the route config becomes the single source of truth for data loading, titles, permissions, and any other concern.
import { getPluginApi } from "@real-router/core/api";
const { getRouteConfig } = getPluginApi(router);
getRouteConfig(routeName: string): Record<string, unknown> | undefinedReturns the custom (non-standard) fields for a route. Standard fields (name, path, canActivate, canDeactivate, forwardTo, defaultParams, encodeParams, decodeParams, children) are excluded — only your custom fields are returned.
Returns undefined if the route does not exist or has no custom fields.
const routes = [
{
name: "dashboard",
path: "/dashboard",
title: "Dashboard",
loadData: (params, api) => api.getDashboardStats(),
cleanup: (store) => store.dashboard.reset(),
requiredRole: "user",
},
{
name: "admin",
path: "/admin",
title: "Admin Panel",
loadData: (params, api) => api.getAdminData(),
requiredRole: "admin",
},
];
// Generic data loading plugin — no hardcoded route names
const dataPlugin: PluginFactory = (router, getDep) => {
const { getRouteConfig } = getPluginApi(router);
return {
onTransitionSuccess(toState, fromState) {
if (fromState) getRouteConfig(fromState.name)?.cleanup?.(getDep("store"));
getRouteConfig(toState.name)?.loadData?.(toState.params, getDep("api"));
},
};
};
// Generic title plugin
const titlePlugin: PluginFactory = (router) => {
const { getRouteConfig } = getPluginApi(router);
return {
onTransitionSuccess(toState) {
document.title = (getRouteConfig(toState.name)?.title as string) ?? "App";
},
};
};
// Generic auth guard
const lifecycle = getLifecycleApi(router);
lifecycle.addActivateGuard("admin", (router, getDep) => (toState) => {
const { getRouteConfig } = getPluginApi(router);
const required = getRouteConfig(toState.name)?.requiredRole as
| string
| undefined;
return !required || getDep("auth").hasRole(required);
});Adding a new route with data loading, title, and access control is a single config entry — no plugin code changes needed.
See Recipes — Config-Driven Plugins for more examples.
Beyond lifecycle hooks, plugins can intercept core router operations through addInterceptor() on getPluginApi(). This is a single, unified mechanism for wrapping any interceptable method.
addInterceptor(method, fn) registers a wrapper around a core router method. Multiple interceptors per method execute in LIFO order (last-registered wraps first), forming an onion-layer chain. Each interceptor receives next (the next function in the chain) followed by the method's original arguments. Returns an Unsubscribe function.
type InterceptorFn<M> = (
next: InterceptableMethodMap[M],
...args: Parameters<InterceptableMethodMap[M]>
) => ReturnType<InterceptableMethodMap[M]>;
addInterceptor<M extends keyof InterceptableMethodMap>(
method: M,
fn: InterceptorFn<M>,
): Unsubscribe;Four methods are interceptable:
| Method | Signature | What it affects |
|---|---|---|
forwardState |
(routeName: string, routeParams: Params) => SimpleState |
Navigation, state building, matching — all paths that resolve route names |
buildPath |
(route: string, params?: Params) => string |
URL construction — facade, wiring, router.buildPath()
|
start |
(path?: string) => Promise<State> |
Router startup — lets plugins inject or transform the initial path |
add |
(routes: Route[], options?: AddOptions) => void |
Dynamic route addition — lets plugins react to or validate newly added routes |
Wrap forwardState to inject or transform params during navigation. This affects all methods that resolve routes internally: buildState, buildNavigationState, navigate, and matchPath.
const api = getPluginApi(router);
const unsubscribe = api.addInterceptor(
"forwardState",
(next, routeName, routeParams) => {
const result = next(routeName, routeParams);
return { ...result, params: { ...result.params, injected: true } };
},
);
// Remove on teardown
unsubscribe();Wrap buildPath to transform params before URL construction:
const api = getPluginApi(router);
const unsubscribe = api.addInterceptor("buildPath", (next, route, params) =>
next(route, { ...params, lang: getCurrentLang() }),
);
// Remove on teardown
unsubscribe();Wrap start to make the path argument optional or inject a default. The browser-plugin uses this to read the current URL from the browser when no path is provided:
const api = getPluginApi(router);
const unsubscribe = api.addInterceptor("start", (next, path) =>
next(path ?? browser.getLocation()),
);When multiple interceptors are registered for the same method, they compose into a chain. Each next call invokes the previous interceptor (or the original method if at the bottom of the chain). An interceptor can short-circuit by not calling next, or transform both inputs and outputs.
The persistent-params-plugin uses both forwardState and buildPath interceptors: the first injects persistent params during navigation, the second injects them during URL building.
The search-schema-plugin uses the forwardState interceptor to validate and sanitize search (query) parameters against a Standard Schema V1 object declared on the route config (searchSchema field). It also uses the add interceptor to validate defaultParams of dynamically added routes in development mode. Because forwardState is the single internal function that resolves route names and params into a state object — called during every navigation, URL matching, and buildState — it is the correct single interception point for search param validation in both URL→State and State→URL directions.
Plugins often need to attach per-navigation metadata to the state object -- for example, the browser plugin records whether navigation came from a popstate event, and the navigation plugin records navigation type and direction. The State Context system provides a structured, conflict-free way to do this.
Every State object has a context field (always present, at least {}). Plugins write to it via a claim-based API that enforces exclusive ownership of namespaces.
At registration time (inside the factory), a plugin calls api.claimContextNamespace("ns") to acquire exclusive write access to state.context.ns:
import { getPluginApi } from "@real-router/core/api";
const myPlugin: PluginFactory = (router) => {
const api = getPluginApi(router);
const claim = api.claimContextNamespace("myData");
return {
onTransitionSuccess(toState) {
claim.write(toState, { timestamp: Date.now() });
},
teardown() {
claim.release();
},
};
};-
Claim at registration:
const claim = api.claimContextNamespace("ns") -
Write in hooks:
claim.write(state, value)-- setsstate.context.ns = value -
Release in teardown:
claim.release()-- allows another plugin to reclaim the namespace
A namespace can be held by at most one claim at a time. If a second plugin tries to claim the same namespace, the router throws CONTEXT_NAMESPACE_ALREADY_CLAIMED:
const claim1 = api.claimContextNamespace("navigation"); // OK
const claim2 = api.claimContextNamespace("navigation"); // throws CONTEXT_NAMESPACE_ALREADY_CLAIMEDAfter claim1.release(), the namespace becomes available again.
The timing of claim.write() depends on which hook or interceptor calls it:
| Write Location | Visible in subscribe() callbacks? |
Visible in guards? | Example Plugin |
|---|---|---|---|
onTransitionStart |
Yes | No | navigation-plugin (captured meta) |
onTransitionSuccess |
Yes | No | browser-plugin, memory-plugin, persistent-params-plugin |
start interceptor |
No (fires after start resolves) | No | ssr-data-plugin |
Key rule: Writes in onTransitionSuccess are visible in subscribe() callbacks because plugins fire before subscribers. Writes in a start interceptor happen after the transition is complete but before the start() promise resolves to the caller -- they are not visible in subscribe() callbacks for that navigation.
Plugins declare their namespace type by augmenting StateContext from @real-router/types:
declare module "@real-router/types" {
interface StateContext {
myData?: { timestamp: number };
}
}After augmentation, state.context.myData is typed. The intersection with Record<string, unknown> in State.context keeps the type open for untyped usage.
See State -- State Context for the full list of built-in context namespaces and component usage examples.
Five plugins ship with context support:
| Namespace | Plugin | Writes In | Data Shape |
|---|---|---|---|
navigation |
navigation-plugin | onTransitionStart + onTransitionSuccess | { navigationType, userInitiated, direction, sourceElement, info? } |
browser |
browser-plugin | onTransitionSuccess | { source: "popstate" | "navigate" } |
memory |
memory-plugin | onTransitionSuccess | { direction: "back" | "forward" | "navigate", historyIndex: number } |
persistentParams |
persistent-params-plugin | onTransitionSuccess |
Params (frozen snapshot of current persistent parameters) |
data |
ssr-data-plugin | start interceptor |
unknown (loader result) |
Beyond observing events and intercepting methods, plugins can add new properties and methods to the router instance via extendRouter():
const api = getPluginApi(router);
const removeExtensions = api.extendRouter({
buildUrl: (name, params) => origin + router.buildPath(name, params),
matchUrl: (url) => api.matchPath(new URL(url).pathname),
});extendRouter assigns properties directly to the router instance. It checks for conflicts first — if any key already exists, it throws RouterError(PLUGIN_CONFLICT) without assigning anything. The returned function removes all added properties, and is typically called in teardown().
For TypeScript support, combine with declare module augmentation so that router.buildUrl(...) type-checks without casts. See extendRouter for the full API.
The browser-plugin uses extendRouter to add buildUrl, matchUrl, and replaceHistoryState to the router.
Some plugins detect errors outside a running navigation — e.g., an unmatched URL on popstate in strict mode, or a synchronous failure in a custom navigation handler. PluginApi.emitTransitionError(error) emits the $$error event so the standard onTransitionError hook fires without having to synthesize a fake navigation.
import { errorCodes, RouterError } from "@real-router/core";
const api = getPluginApi(router);
function onExternalNavigateUnknown(path: string) {
const err = new RouterError(errorCodes.ROUTE_NOT_FOUND, { path });
api.emitTransitionError(err);
// …also restore URL, revert UI, etc. as appropriate
}The current router.getState() is used as fromState; toState is undefined because no transition was attempted. Safe to call at any FSM state — internally delegates to sendFailSafe, which becomes a direct emit when the router is not in READY.
Used by @real-router/browser-plugin, @real-router/navigation-plugin, and @real-router/hash-plugin to surface strict-mode (allowNotFound: false) unmatched URLs — instead of silently falling back to navigateToDefault(), which hides errors from logs and analytics. See RouterOptions#allowNotFound.
The distinction matters when you're deciding where to put logic.
| Guards | Plugins | |
|---|---|---|
| When | During transition, before state change | After transition, state already changed |
| Can block navigation | Yes | No |
| Can redirect | No | No |
| Scope | Per-route | Global (all routes) |
| Receives abort signal | Yes (for cooperative cancellation) | No |
| Use for | Access control, auth checks | Side effects, URL sync, analytics, logging |
Guards run during the transition pipeline. A guard returning false stops navigation entirely — the state doesn't change, and onTransitionError fires instead of onTransitionSuccess. Plugins never see the intermediate state; they only see the outcome.
If you need to check whether a user can visit a route, that's a guard. See addActivateGuard and addDeactivateGuard.
If you need to react to the fact that navigation happened — update the URL, send an analytics event, write to a log — that's a plugin.
The practical rule: guards answer "should we go?", plugins answer "we went, now what?"
Related pages: usePlugin · navigate · start · stop · getDependency · setDependency · browser-plugin · logger-plugin · persistent-params-plugin · navigation-plugin · memory-plugin · ssr-data-plugin · addActivateGuard · addDeactivateGuard · State