recipes - greydragon888/real-router GitHub Wiki
Practical patterns for using Real-Router as a data provider โ loading data, managing side effects, and orchestrating application state through the plugin system.
Real-Router maps URLs to data (
State), not components. These recipes show how to use that data to drive your application beyond rendering.
- Config-Driven Plugins โ the core pattern
- Document Title
- Analytics
- Route-Driven Data Loading
- Route-Level Cleanup
- Auth Guard with Redirect
- Declarative Error Handling with RouterErrorBoundary
- Loading Indicator
- Scroll Restoration
- Custom Parameter Serialization
- Scroll Preservation Without keepAlive
- Form Draft Auto-Save
- Analytics: Time on Page
- Combining Recipes
The most powerful pattern in Real-Router. Add custom fields to your route config โ plugins read them at runtime via getRouteConfig() and act accordingly. No hardcoded route names, no switch/case โ the route config becomes the single source of truth.
import { createRouter } from "@real-router/core";
import { getPluginApi } from "@real-router/core/api";
import type { Params, PluginFactory } from "@real-router/core";
// 1. Route config with custom fields
const routes = [
{ name: "home", path: "/", title: "Home" },
{
name: "users",
path: "/users",
title: "Users",
loadData: (params: Params, api: ApiClient) => api.getUsers(params),
cleanup: (store: Store) => store.users.clear(),
},
{
name: "users.profile",
path: "/:id",
title: "User Profile",
loadData: (params: Params, api: ApiClient) => api.getUser(params.id),
},
{
name: "settings",
path: "/settings",
title: "Settings",
// no loadData โ plugin simply skips this route
},
];
// 2. Generic plugin โ reads config, no hardcoded route names
const dataPlugin: PluginFactory = (router, getDep) => {
const { getRouteConfig } = getPluginApi(router);
return {
onTransitionSuccess(toState, fromState) {
// Cleanup previous route if it declared a cleanup function
if (fromState) {
const prevConfig = getRouteConfig(fromState.name);
prevConfig?.cleanup?.(getDep("store"));
}
// Load data for new route if it declared a loader
const config = getRouteConfig(toState.name);
config?.loadData?.(toState.params, getDep("api"));
},
};
};
// 3. Title plugin โ same pattern, different field
const titlePlugin: PluginFactory = (router) => {
const { getRouteConfig } = getPluginApi(router);
return {
onTransitionSuccess(toState) {
document.title = (getRouteConfig(toState.name)?.title as string) ?? "App";
},
};
};
// 4. Register and go
const router = createRouter(routes);
router.usePlugin(dataPlugin, titlePlugin);- Route config is the single source of truth โ developers see what happens on each route without reading plugin code
- Plugins are generic โ one data plugin handles all routes, no matter how many
- Adding a new route = adding a config entry โ no code changes in plugins
- Removing a field = removing behavior โ no cleanup in plugin code needed
- Custom fields are type-safe โ use TypeScript module augmentation to type them:
declare module "@real-router/core" {
interface Route {
title?: string;
loadData?: (params: Params, api: ApiClient) => Promise<void>;
cleanup?: (store: Store) => void;
}
}const routes = [
{
name: "dashboard",
path: "/dashboard",
title: "Dashboard",
loadData: (params, api) => api.getDashboardStats(),
cleanup: (store) => store.dashboard.reset(),
abortOnLeave: true,
},
{
name: "users",
path: "/users?page&sort",
title: "Users",
loadData: (params, api) =>
api.getUsers({
page: params.page ?? 1,
sort: params.sort ?? "name",
}),
cleanup: (store) => store.users.clear(),
},
{
name: "users.profile",
path: "/:id",
title: "User Profile",
loadData: (params, api) => api.getUser(params.id),
cleanup: (store) => store.users.clearProfile(),
},
];
// One plugin manages the entire data lifecycle
const dataLifecyclePlugin: PluginFactory = (router, getDep) => {
const { getRouteConfig } = getPluginApi(router);
let abortController: AbortController | null = null;
return {
onTransitionSuccess(toState, fromState) {
const store = getDep("store");
const api = getDep("api");
// Abort pending requests from previous route
if (fromState) {
const prevConfig = getRouteConfig(fromState.name);
if (prevConfig?.abortOnLeave) abortController?.abort();
prevConfig?.cleanup?.(store);
}
// Load data for new route
const config = getRouteConfig(toState.name);
if (config?.loadData) {
abortController = new AbortController();
config.loadData(toState.params, api, abortController.signal);
}
},
};
};Set document.title on every navigation. Prefer the config-driven approach โ declare title in route config, read it via getRouteConfig():
const routes = [
{ name: "home", path: "/", title: "Home โ My App" },
{ name: "users", path: "/users", title: "Users โ My App" },
{ name: "users.profile", path: "/:id", title: "User Profile โ My App" },
];
const titlePlugin: PluginFactory = (router) => {
const { getRouteConfig } = getPluginApi(router);
return {
onTransitionSuccess(toState) {
document.title =
(getRouteConfig(toState.name)?.title as string) ?? "My App";
},
};
};Dynamic titles โ use params:
const titlePlugin: PluginFactory = (router) => {
const { getRouteConfig } = getPluginApi(router);
return {
onTransitionSuccess(toState) {
const config = getRouteConfig(toState.name);
if (typeof config?.title === "function") {
document.title = config.title(toState.params);
} else {
document.title = (config?.title as string) ?? "My App";
}
},
};
};
// In route config:
// { name: "users.profile", path: "/:id", title: (params) => `User ${params.id} โ My App` }
default:
document.title = titles[toState.name] ?? "My App";
}
},
});Track page views on every navigation.
const analyticsPlugin: PluginFactory = () => ({
onTransitionSuccess(toState) {
gtag("event", "page_view", {
page_title: toState.name,
page_path: toState.path,
});
},
});
router.usePlugin(analyticsPlugin);With transition tracking โ measure navigation duration:
const analyticsPlugin: PluginFactory = () => {
let startTime: number;
return {
onTransitionStart() {
startTime = performance.now();
},
onTransitionSuccess(toState) {
const duration = performance.now() - startTime;
gtag("event", "navigation", {
page_path: toState.path,
duration_ms: Math.round(duration),
});
},
};
};Load data when entering a route, driven entirely by the route name and params.
import type { Params, PluginFactory, State } from "@real-router/core";
type Loader = (params: Params) => Promise<void>;
const loaders: Record<string, Loader> = {
users: (params) => store.users.fetchList(params),
"users.profile": (params) => store.users.fetchProfile(params.id),
dashboard: () => store.dashboard.fetchStats(),
};
const dataPlugin: PluginFactory = () => ({
onTransitionSuccess(toState) {
loaders[toState.name]?.(toState.params);
},
});
router.usePlugin(dataPlugin);const dataPlugin: PluginFactory = (router, getDep) => ({
async onTransitionSuccess(toState) {
const api = getDep("api");
const store = getDep("store");
switch (toState.name) {
case "users":
store.users.setList(await api.getUsers(toState.params));
break;
case "users.profile":
store.users.setProfile(await api.getUser(toState.params.id));
break;
}
},
});
// Register dependencies before start
import { getDependenciesApi } from "@real-router/core/api";
const deps = getDependenciesApi(router);
deps.set("api", apiClient);
deps.set("store", rootStore);Only reload data when relevant params actually changed โ not on every navigation:
const dataPlugin: PluginFactory = () => ({
onTransitionSuccess(toState, fromState) {
// Skip if same route and same id
if (
fromState?.name === toState.name &&
fromState?.params.id === toState.params.id
) {
return;
}
loaders[toState.name]?.(toState.params);
},
});Dispose resources when leaving a route โ cancel subscriptions, clear stores, abort requests.
type Cleanup = () => void;
const cleanups: Record<string, Cleanup> = {
"users.profile": () => store.users.clearProfile(),
dashboard: () => store.dashboard.clearStats(),
};
const cleanupPlugin: PluginFactory = () => ({
onTransitionSuccess(toState, fromState) {
if (fromState) {
cleanups[fromState.name]?.();
}
},
});
router.usePlugin(cleanupPlugin);const dataLifecyclePlugin: PluginFactory = () => ({
onTransitionSuccess(toState, fromState) {
// 1. Cleanup previous route
if (fromState) {
cleanups[fromState.name]?.();
}
// 2. Load new route's data
loaders[toState.name]?.(toState.params);
},
});Block navigation to protected routes and redirect to login.
import { getLifecycleApi } from "@real-router/core/api";
const lifecycle = getLifecycleApi(router);
// Guard: block if not authenticated
lifecycle.addActivateGuard("admin", (router, getDep) => (toState) => {
const auth = getDep("authService");
if (!auth.isAuthenticated()) {
// Navigate to login after guard rejects
setTimeout(() => router.navigate("login", { returnTo: toState.name }), 0);
return false;
}
return true;
});
// After login: return to original route
async function handleLogin(credentials: Credentials) {
await authService.login(credentials);
const returnTo = router.getState()?.params.returnTo ?? "home";
router.navigate(returnTo);
}const roles: Record<string, string[]> = {
admin: ["admin"],
"admin.users": ["admin"],
settings: ["admin", "manager"],
};
lifecycle.addActivateGuard("admin", (router, getDep) => (toState) => {
const user = getDep("authService").getUser();
const allowed = roles[toState.name];
return !allowed || allowed.includes(user.role);
});Show navigation errors as a toast without imperative try/catch:
import { RouterErrorBoundary, Link } from "@real-router/react";
function Navigation() {
return (
<RouterErrorBoundary
fallback={(error, resetError) => (
<div className="error-toast">
{error.code} <button onClick={resetError}>ร</button>
</div>
)}
onError={(error) => console.error("Nav error:", error.code)}
>
<nav>
<Link routeName="home">Home</Link>
<Link routeName="admin">Admin (guarded)</Link>
</nav>
</RouterErrorBoundary>
);
}Auto-resets on successful navigation. See RouterErrorBoundary for details.
Show a global loading indicator during async navigations.
import { useRouterTransition } from "@real-router/react";
function GlobalProgress() {
const { isTransitioning } = useRouterTransition();
if (!isTransitioning) return null;
return <div className="progress-bar" />;
}const loadingPlugin: PluginFactory = () => ({
onTransitionStart() {
document.getElementById("loader")!.style.display = "block";
},
onTransitionSuccess() {
document.getElementById("loader")!.style.display = "none";
},
onTransitionError() {
document.getElementById("loader")!.style.display = "none";
},
onTransitionCancel() {
document.getElementById("loader")!.style.display = "none";
},
});Builtin utility available. Since 2026-04 (see #497), scroll restoration is provided by
shared/dom-utils/createScrollRestorationโ opt-in viaRouterProvider.scrollRestorationon every framework adapter. Prefer the builtin over a custom plugin; it handles direction tracking (via@real-router/navigation-plugin),pagehidepersistence tosessionStorage, single-rAF timing, anchor scrolling, and custom scroll containers.
// React / Preact / Solid
<RouterProvider router={router} scrollRestoration={{ mode: "restore" }}>
<App />
</RouterProvider>// Angular
provideRealRouter(router, { scrollRestoration: { mode: "restore" } });See Scroll Restoration for the behavior matrix, virtual-scroll recipe, and design trade-offs.
Below remains as a reference if you need scroll behavior that differs from the builtin (e.g. scroll-to-top on push without hash handling, or restoration tied to a custom history model):
const positions = new Map<string, number>();
const scrollPlugin: PluginFactory = () => ({
onTransitionStart(toState, fromState) {
if (fromState) {
positions.set(fromState.name, window.scrollY);
}
},
onTransitionSuccess(toState, fromState, opts) {
if (opts?.replace) {
// Back/forward โ restore saved position
const saved = positions.get(toState.name);
window.scrollTo(0, saved ?? 0);
} else {
// New navigation โ scroll to top
window.scrollTo(0, 0);
}
},
});Use encodeParams / decodeParams on a route to transform parameters between application state and URL representation. Both functions receive all params (path + query) and run during buildPath / matchPath respectively.
Key rule: params are frozen โ always return a new object (
{ ...params }), never mutate.
URL params are always strings. Use decodeParams to coerce them to the types your application expects:
{
name: "users",
path: "/users?page&limit&active",
defaultParams: { page: "1", limit: "20" },
decodeParams: (params) => ({
...params,
page: Number(params.page),
limit: Number(params.limit),
active: params.active === "true",
}),
encodeParams: (params) => ({
...params,
page: String(params.page),
limit: String(params.limit),
active: String(params.active),
}),
}
// URL: /users?page=2&limit=50&active=true
// State params: { page: 2, limit: 50, active: true } (number, number, boolean){
name: "events",
path: "/events?from&to",
decodeParams: (params) => ({
...params,
from: params.from ? new Date(params.from as string) : undefined,
to: params.to ? new Date(params.to as string) : undefined,
}),
encodeParams: (params) => ({
...params,
from: params.from instanceof Date
? params.from.toISOString().slice(0, 10) // "2024-03-15"
: params.from,
to: params.to instanceof Date
? params.to.toISOString().slice(0, 10)
: params.to,
}),
}
// navigate("events", { from: new Date(2024, 2, 15), to: new Date(2024, 2, 30) })
// URL: /events?from=2024-03-15&to=2024-03-30When you need to pass an object (e.g., a filter) as a single query parameter:
{
name: "catalog",
path: "/catalog?filter",
decodeParams: (params) => ({
...params,
filter: params.filter ? JSON.parse(params.filter as string) : {},
}),
encodeParams: (params) => ({
...params,
filter: params.filter ? JSON.stringify(params.filter) : undefined,
}),
}
// navigate("catalog", { filter: { status: "active", category: "tools" } })
// URL: /catalog?filter=%7B%22status%22%3A%22active%22%2C%22category%22%3A%22tools%22%7D
// State params: { filter: { status: "active", category: "tools" } }Trade-off: JSON in URL is compact and self-contained but produces long, hard-to-read URLs. Good for internal state; poor for URLs shared with users.
For human-readable nested params (?filter[status]=active&filter[role]=admin), use a third-party library like qs:
import qs from "qs";
{
name: "catalog",
path: "/catalog", // no query params declared โ qs handles them
decodeParams: (params) => {
// Extract the raw query string from the URL and parse with qs
const parsed = qs.parse(location.search, { ignoreQueryPrefix: true });
return { ...params, ...parsed };
},
encodeParams: (params) => {
// qs flattens nested objects into bracket notation
const { filter, ...rest } = params;
return {
...rest,
// qs.stringify produces "filter[status]=active&filter[role]=admin"
// but we need individual params, so use a wrapper approach
...(filter ? qs.parse(qs.stringify({ filter })) : {}),
};
},
}A simpler alternative โ combine JSON encoding with qs only for specific params:
import qs from "qs";
// Helper: reusable encoder/decoder for a set of object params
function qsParams(...keys: string[]) {
const keySet = new Set(keys);
return {
decodeParams: (params: Params) => {
const result = { ...params };
for (const key of keySet) {
if (typeof result[key] === "string") {
result[key] = qs.parse(result[key] as string);
}
}
return result;
},
encodeParams: (params: Params) => {
const result = { ...params };
for (const key of keySet) {
if (result[key] && typeof result[key] === "object") {
result[key] = qs.stringify(result[key] as Record<string, unknown>);
}
}
return result;
},
};
}
// Usage
{
name: "catalog",
path: "/catalog?filter&sort",
...qsParams("filter"), // only "filter" goes through qs; "sort" stays as-is
}If many routes need the same type coercion, extract a factory:
import type { Params } from "@real-router/core";
interface ParamSchema {
[key: string]: "number" | "boolean" | "date" | "json";
}
function typedParams(schema: ParamSchema) {
return {
decodeParams: (params: Params) => {
const result = { ...params };
for (const [key, type] of Object.entries(schema)) {
const raw = result[key];
if (raw == null) continue;
switch (type) {
case "number":
result[key] = Number(raw);
break;
case "boolean":
result[key] = raw === "true";
break;
case "date":
result[key] = new Date(raw as string);
break;
case "json":
result[key] = JSON.parse(raw as string);
break;
}
}
return result;
},
encodeParams: (params: Params) => {
const result = { ...params };
for (const [key, type] of Object.entries(schema)) {
const val = result[key];
if (val == null) continue;
switch (type) {
case "number":
case "boolean":
result[key] = String(val);
break;
case "date":
result[key] =
val instanceof Date ? val.toISOString().slice(0, 10) : val;
break;
case "json":
result[key] =
typeof val === "object" ? JSON.stringify(val) : val;
break;
}
}
return result;
},
};
}
// Usage
const routes = [
{
name: "users",
path: "/users?page&limit&active",
...typedParams({ page: "number", limit: "number", active: "boolean" }),
},
{
name: "events",
path: "/events?from&to",
...typedParams({ from: "date", to: "date" }),
},
{
name: "catalog",
path: "/catalog?filter",
...typedParams({ filter: "json" }),
},
];When keepAlive isn't available (Preact, Solid, Svelte) or you prefer explicit control, use subscribeLeave to save scroll position on confirmed departure and subscribe to restore it on arrival:
// Save when leaving (fires only after canDeactivate approves)
router.subscribeLeave(({ route }) => {
if (route.name === "products") {
sessionStorage.setItem("products:scroll", String(window.scrollY));
}
});
// Restore when entering
router.subscribe(({ route }) => {
if (route.name === "products") {
const saved = sessionStorage.getItem("products:scroll");
if (saved) {
requestAnimationFrame(() => window.scrollTo(0, Number(saved)));
}
}
});requestAnimationFrame defers the scroll until after the DOM has updated. Without it, the page may not have rendered yet and scrollTo would have no effect.
Auto-save form drafts only when the user confirms leaving โ not on every navigation attempt that gets blocked by a guard:
router.subscribeLeave(({ route }) => {
if (route.name === "settings") {
const draft = document.querySelector<HTMLInputElement>("#display-name")?.value;
if (draft) localStorage.setItem("settings:draft", draft);
}
});This pairs naturally with canDeactivate โ the guard asks the user to confirm, and subscribeLeave saves the draft only if they do. If the user cancels, the draft stays in the form and nothing is written to storage.
Track accurate time-on-page. Because subscribeLeave fires only on confirmed departures, the duration reflects actual time spent โ not aborted navigation attempts:
const pageEnterTime = new Map<string, number>();
router.subscribe(({ route }) => {
pageEnterTime.set(route.name, Date.now());
});
router.subscribeLeave(({ route }) => {
const enter = pageEnterTime.get(route.name);
if (enter) {
analytics.track("page_time", {
page: route.name,
duration: Date.now() - enter,
});
}
});subscribe records the entry time on every successful navigation. subscribeLeave computes the duration when the user actually leaves. If a canDeactivate guard blocks the departure, the timer keeps running โ the user is still on the page.
All recipes are composable โ just pass them to usePlugin:
router.usePlugin(
browserPluginFactory(),
persistentParamsPluginFactory(["lang", "theme"]),
dataLifecyclePlugin,
titlePlugin,
analyticsPlugin,
scrollPlugin,
);Each plugin:
- Runs independently โ no shared state between plugins
- Can be removed at any time โ
usePlugin()returns an unsubscribe function - Is testable in isolation โ just call hook methods with mock states
- Accesses shared services via
getDependency()โ no imports, no globals
See Plugin Architecture for the full lifecycle, interception, and extension APIs.
68 standalone Vite apps demonstrating every feature across all 5 frameworks:
| Feature | React | Preact | Solid | Vue | Svelte |
|---|---|---|---|---|---|
| Basic routing | basic | basic | basic | basic | basic |
| Nested routes + breadcrumbs | nested-routes | nested-routes | nested-routes | nested-routes | nested-routes |
| Auth guards + route tree swap | auth-guards | auth-guards | auth-guards | auth-guards | auth-guards |
| Plugin-based data loading | data-loading | data-loading | data-loading | data-loading | data-loading |
| Code splitting | lazy-loading | lazy-loading | lazy-loading | lazy-loading | lazy-loading |
| Async guards + AbortController | async-guards | async-guards | async-guards | async-guards | async-guards |
| Hash-based URLs | hash-routing | hash-routing | hash-routing | hash-routing | hash-routing |
| Persistent query params | persistent-params | persistent-params | persistent-params | persistent-params | persistent-params |
| Error handling | error-handling | error-handling | error-handling | error-handling | error-handling |
| Dynamic route management | dynamic-routes | dynamic-routes | dynamic-routes | dynamic-routes | dynamic-routes |
| All features combined | combined | combined | combined | combined | combined |
Framework-specific: React keepAlive ยท Vue keepAlive ยท Vue plugin-installation ยท Solid store-based-state ยท Svelte snippets-routing ยท Svelte reactive-source
Run any example: cd examples/react/basic && pnpm dev