white-label-router
v6.3.0
Published
Framework-independent TypeScript router for progressive enhancement in browser, server-rendered, and Node.js applications, with scoped links, guards, and accessible navigation.
Maintainers
Readme
white-label-router
Turn URLs into application intent without hiding the web platform.
white-label-router is a framework-independent TypeScript router for browser and server applications. It progressively enhances real links with the History API in the browser and dispatches explicit request URLs on the server through the same matching, guard, lifecycle, query, focus, and mediator contracts.
Documentation · API reference · Demo site
Responsibility: translate navigation into application intent. Nothing more.
Why it exists
White Label keeps URLs, links, history, and server requests visible rather than replacing them with a proprietary navigation model. Router adds an application boundary around those platform primitives while preserving normal browser behavior whenever enhancement does not apply.
Use it independently or compose it with the rest of White Label:
white-label-mediatorcan publishrouter:navigateintent.white-label-modelcan own state changed by a route.white-label-viewcan own the rendering lifecycle started by a route.generator-white-labeldemonstrates the complete composition.
The package has no runtime dependency on the other White Label packages.
Where it fits
Router is especially useful when URL-backed behavior should be added without turning the whole page into a client-side application. Typical fits include progressively enhanced public sites, server-rendered applications, commerce/CMS frontends, account areas, search/filter experiences, and existing applications being modernized one region at a time.
For incremental adoption, navigationRoot can limit link interception to one owned DOM region while the rest of the page keeps its existing navigation behavior. This makes it possible for multiple independently owned enhancements to coexist without requiring one document-wide router.
Router is not a replacement for server routes, authentication/authorization, CDN or API-gateway routing, or a backend framework. Highly client-owned applications can still use it, but its strongest advantage is preserving real links and server-requestable URLs while adding client behavior selectively.
See the incremental server-rendered application guide for the broader integration pattern.
Requirements
- Node.js
^22.18.0or>=24.11.0for installation, development, and server execution. - npm, Yarn, and pnpm are supported for installation; see
PACKAGE_MANAGERS.md. - Browser History API, location, and standard DOM events only when using browser navigation behavior.
- A directly requestable server route for every public browser route so enhanced links remain meaningful without JavaScript.
Install
npm install white-label-router
# or: yarn add white-label-router
# or: pnpm add white-label-routerimport Router from 'white-label-router';One route contract, two runtimes
Browser
Omit the URL to dispatch the current browser location and attach browser navigation listeners:
const router = new Router();
router.routes = {
'/products': (_scope, location) => console.log(location.data.url),
defaultRoute: () => true
};
router.initialize();Server
Pass a fully qualified request URL when possible. No window or document is required:
const router = new Router();
router.routes = {
'/products': (_scope, location) => console.log(location.data.url),
defaultRoute: () => true
};
router.initialize('https://example.com/products/42?color=blue');Browser-only history, click interception, focus, and document-title effects are skipped on the server. Scope mutable routing state per request when appropriate. If a host exposes only a raw relative HTTP request target, preserve that target's path semantics at the adapter boundary before handing Router an absolute URL; see Serverless and function runtimes.
Progressive enhancement first
Public navigation should remain a real link:
<a href="/products/42" data-pushstate>View product 42</a>data-pushstate opts the link into client-side enhancement. Router intercepts a click on an <a href data-pushstate> (HTML or SVG; Router reads the href attribute, resolved against document.baseURI) only when it can actually handle it. These clicks stay native:
- links without
data-pushstate, or clicks whose default action was already prevented; - non-left clicks and clicks with Meta, Ctrl, Shift, or Alt held;
- links with
download, or atargetother than_self; - links to a
#fragmentof the page already shown (same path and query), which the browser handles as in-page fragment navigation; the resultingpopstatefollows the hash-only rule; - cross-origin or unparseable URLs;
- URLs that match no route, or only the placeholder
defaultRoutethe constructor installs (adefaultRouteyou configure yourself does intercept unmatched links); - routes with no runnable view behavior;
- links whose guard returns anything but
true, or throws (the guard's error still propagates).
Once a route has been committed, the click's default action is cancelled even if the route's view throws, so the browser does not navigate a second time.
This preserves keyboard behavior, context menus, no-JavaScript navigation, and crawler discovery. Public routes should have directly requestable URLs that return meaningful HTML and appropriate title, description, canonical, and robots metadata.
Scope enhancement to one existing region
By default, Router listens for opted-in links across document. Existing server-rendered/CMS/commerce applications can constrain that ownership:
const router = new Router();
router.navigationRoot = document.querySelector('[data-product-refinements]');
router.routes = {
'/search': (_scope, location) => {
applyFilters(location.data.query);
}
};
router.initialize();Only click events that reach navigationRoot are considered by that Router. Links elsewhere on the page keep the host application's behavior. Reassigning navigationRoot while listeners are active moves the owned click listener to the new root. Setting it to null restores document-wide interception.
navigationRoot is deliberately separate from scope: navigationRoot controls where enhanced-link clicks are observed; scope is application data passed to route callbacks.
navigationRoot does not limit focus. After a navigation, the route's focus selector (default main h1) is looked up in the whole document, so a heading outside the owned region still receives focus. Use a more specific focus selector, or focus: false, when the region should keep focus itself.
Define routes
A route can be a function:
router.routes = {
'/help': (scope, location) => {
scope.textContent = `Help page: ${location.url}`;
}
};Or an object describing route lifecycle:
router.routes = {
'/orders': {
title: 'Orders',
focus: '#orders-title',
secure(scope, location) {
return Boolean(window.currentUser);
},
view: {
initialize(scope, location) {
scope.textContent = 'Order history';
},
destroy(scope, nextLocation) {
scope.replaceChildren();
}
}
}
};Guards
secure(scope, location) runs synchronously with the destination location, before anything is torn down. It must return exactly true to allow navigation; any other value (including a Promise) rejects it and navigate() returns false. An error thrown by a guard propagates to the caller and leaves Router state unchanged. Client guards are interface behavior, not server authorization.
Lifecycle order
A successful navigation runs these steps in order:
- Match the route and run its guard. Rejection stops here with no state change.
- Commit
router.url,router.route, androuter.locationDatafor the destination. - Call the previous route's
destroy(scope, nextLocation), if it had one. - Run the new route: a function route, a
viewfunction, orview.initialize(scope, location). - Apply the route's title and focus (browser only; see Title and focus).
- Set
router.previousRoute, then push a history entry (browser only; skipped forinitialize()and back/forward).
Router keeps the destroy callback of the lifecycle that actually initialized, so replacing or mutating the route table later does not change which teardown runs. That lifecycle stays owned even if initialize() throws, or title/focus work throws after it: the route remains committed, the error is rethrown to the caller, and its destroy() still runs before the next successful route starts. Title, focus, and history are skipped for a route whose initialize() threw.
If the previous route's destroy() throws, its error propagates out of navigate() and that teardown is never called again: Router releases it before calling it. The destination stays committed (router.url, router.route, router.locationData), but its view does not run, so it owns no destroy(), and title, focus, previousRoute, and history are skipped. The next navigation therefore starts without a teardown; navigate() with no URL re-runs the committed destination to initialize it.
Navigating from inside a route
navigate() is re-entrant. If initialize(), destroy(), a focus handler, or a mediator event starts another navigation, the newer navigation wins: the outer one stops at that point, applies no title, focus, or history of its own, and returns the router. For example, navigating to /two from inside /one's initialize() runs /one's destroy() and then /two's initialize(), and leaves /two as the active route.
Matching
Routes match complete path boundaries. /products matches /products and /products/42, but not /products-old.
When multiple route prefixes match, Router selects the longest matching route, so specific routes win without depending on object insertion order. Only own route-table properties participate in matching. If nothing matches and no own defaultRoute is configured, navigation is rejected; in browser click interception the real link remains native.
router.routes = {
'/products': productsRoute,
'/products/sale': saleRoute,
defaultRoute
};/products/sale selects saleRoute even though /products was defined first. When no prefix matches, defaultRoute runs if configured.
Route keys are encoded the same way URL parsing encodes paths, so '/café' matches a request for /caf%C3%A9. Keys that don't start with /, or that contain ? or #, can never match a pathname and are ignored (apart from defaultRoute). If two keys normalize to the same path (for example /x/../same and /same), the key already in normalized form wins, otherwise the alphabetically lowest; insertion order never decides.
A trailing / is part of the key, not a wildcard. '/docs' matches /docs, /docs/, and /docs/x, but '/docs/' matches only /docs/ itself (and paths below its empty last segment, such as /docs//x). It doesn't match /docs or /docs/x. Declare '/docs' to cover the section. If both keys exist, /docs/ selects '/docs/' as the longer match. '/' follows the same rule: it matches only the root path, so other unmatched paths go to defaultRoute.
Large route tables
Each match reads the keys of routes again so that keys added or removed in place (router.routes['/x'] = fn) take effect immediately. That costs time in proportion to the number of keys: about 50 µs per match at 1,000 routes in npm run benchmark. For a large table that doesn't change after setup, call router.indexRoutes() once the table is ready. Matching then uses the stored index (about 0.15 µs at 1,000 routes) until routes is assigned a different object, which drops the index. After adding or removing keys on the same object, call indexRoutes() again. Until you do, an added key is not matched, and navigation to a removed key's paths returns false. Replacing the value of an existing key needs no re-index.
router.routes = buildRouteTable(); // thousands of keys
router.indexRoutes();
router.initialize();A new Router starts with a placeholder defaultRoute that does nothing, so programmatic navigation to an unmatched URL succeeds until you replace routes. That placeholder never intercepts link clicks.
Location contract
Routes receive the configured scope and a location object:
{
url: '/products/42?color=blue',
data: {
url: ['42'],
mediator: {source: 'featured-products'},
query: {color: 'blue'}
}
}urlis the path, query, and hash (/products/42?color=blue#reviews), never a full absolute URL. This holds on the server too:initialize('https://example.com/products/42?color=blue')producesurl: '/products/42?color=blue'.router.urlholds the same value.data.urlcontains the URI-decoded path segments after the matched route prefix (all segments fordefaultRoute). A segment with malformed percent-encoding is passed through undecoded.data.querycontains query values decoded withURLSearchParams; for duplicate keys the last value wins.data.mediatorcontains navigation data passed tonavigate()or throughCustomEvent.detail. It is{}for link clicks,initialize(), and back/forward, andundefinedwhennavigate()is called without data.
Applications must still validate these untrusted values.
Programmatic navigation
const result = router.navigate('/products/42', {
source: 'featured-products'
});In browsers, successful navigation updates history unless the call represents popstate. On servers it dispatches without History API effects.
Rejected navigation is atomic from Router's perspective: malformed or wrong-type URL candidates, cross-origin browser URLs, URLs with no matching route/default route, failed guards, and routes without runnable view behavior return false without destroying the current route, pushing browser history, or replacing the last successful URL, selected route, or location payload.
Calling navigate() without a URL re-runs Router's last committed URL. In a browser, that re-run adds a history entry only when the address bar does not already show that URL, so it never duplicates the current entry. Before anything has been committed it uses the current browser URL, or / on a server; server applications should normally supply a fully qualified request URL explicitly. Errors thrown by a guard or a route callback propagate out of navigate().
Mediator integration
Assign any standards-based event target that provides addEventListener() and removeEventListener(). white-label-mediator is the first-party implementation:
import Mediator from 'white-label-mediator';
const mediator = new Mediator();
const router = new Router();
router.mediator = mediator;
router.initialize();
mediator.dispatchEvent(new CustomEvent('router:navigate', {
detail: {
url: '/account',
reason: 'Session refreshed'
}
}));The CustomEvent.detail object becomes location.data.mediator, and its url (if any) is the destination. A detail without url, or a null/missing detail, re-runs the current URL without adding a duplicate history entry; a detail that is not a plain object (a string, number, or array) is ignored. Mediator navigations to a url push browser history like navigate(). destroy() removes the listener owned by Router. Repeated listener initialization is idempotent. Reassigning router.mediator while listeners are active moves the router:navigate subscription to the replacement.
Router does not import or require white-label-mediator; the integration is structural so another compatible EventTarget can be used instead.
Serverless and function runtimes
Pass the incoming request URL explicitly when Router runs inside a serverless function. A Web Request already supplies a fully qualified URL, so preserve it directly:
const router = new Router();
router.routes = routes;
router.initialize(request.url);Do not parse that absolute URL and rebuild it from pathname + search; a pathname beginning // can change meaning when reparsed.
Some Node HTTP frameworks expose only a raw origin-form request target such as /products/42?color=blue. Prefix that raw target with the application's configured origin without feeding the target through new URL(raw, origin) first:
const applicationOrigin = 'https://example.com';
router.initialize(`${applicationOrigin}${req.originalUrl}`);This assumes the adapter's raw request target begins with /. If a host can also supply absolute-form request targets, detect and preserve those separately. A raw target beginning // is a path in HTTP origin-form but a network-path reference to WHATWG URL; handing //... directly to Router as a relative URL changes its meaning.
Create a request-scoped Router when its location state, mediator, route lifecycle, or other mutable configuration belongs to one invocation. Warm function processes may serve sequential or overlapping requests, so sharing one mutable Router can mix location/lifecycle state unless that process-wide lifetime is intentional.
Router handles application routing inside the invocation. It does not replace API Gateway routes, CDN routing, Vercel/Netlify/Cloudflare route configuration, load balancing, authentication infrastructure, or other cloud request plumbing.
The package currently documents Node.js as its supported server runtime. Its DOM-free server path uses standard URL APIs, but that is not a blanket compatibility claim for every edge provider; verify the actual target runtime before deployment.
Title and focus
Object routes can define browser title and focus behavior:
router.routes = {
'/account': {
title: 'Account',
focus: '#account-title',
view: renderAccount
}
};After a route renders, Router sets document.title from title and focuses the first element in the document matching focus (default main h1), regardless of navigationRoot, adding tabindex="-1" if the element has no tabindex. Set focus: false for an in-page refinement that should preserve current focus. The first dispatch from initialize() sets the title but does not move focus, because the browser already owns focus on a freshly loaded page; later navigations do. Server runtimes retain pageTitle as route state but do not mutate a document or focus target.
Browser history behavior
Initialization dispatches the current URL without adding a duplicate history entry. Back/forward navigation reads window.location—including path, query, and hash—even when history state is null or belongs to another application. History state is not treated as the authoritative URL. Rejected navigation does not add a history entry.
When back/forward, or a click on a link to a #fragment of the current page, changes only the hash (footnotes, skip links), Router updates router.url and locationData.url but does not re-run the route, call destroy(), or move focus. A change to the path or query re-runs the route as usual.
Public API
| Member | Behavior | Returns |
| --- | --- | --- |
| routes | Route table of functions or lifecycle route objects; matching uses the longest own path prefix, not insertion order. | Configuration property. |
| scope | Application scope passed to route callbacks. | Configuration property. |
| navigationRoot | Optional browser Document/Element that owns data-pushstate click interception; null uses document. Active listeners move when reassigned. | Configuration property. |
| mediator | Optional EventTarget-compatible source for router:navigate; active subscriptions move when this property changes. | Configuration property. |
| initialize(url?) | Dispatch the browser URL or an explicit server URL and attach applicable listeners. | The same Router instance. |
| navigate(url?, data?, isPopState?) | Match and run a route; update browser history when appropriate. Invalid/malformed URL candidates and other rejected navigation preserve current route state. | The same Router on success; false for rejected navigation. |
| addListeners() | Attach browser and optional mediator listeners once. | The same Router instance. |
| removeListeners() | Release listeners owned by this router. | The same Router instance. |
| indexRoutes() | Index the current route keys and reuse that index until routes is reassigned or this is called again (see Large route tables). | The same Router instance. |
| destroy() | Release routing listeners. Does not call the active route's destroy(). | The same Router instance after cleanup. |
| parseQueryString(query) | Decode a query string using URLSearchParams. | A plain object containing the last value for each query key. |
| setLocationData(data?) | Rebuild the current route location payload. | undefined; updates locationData in place. |
| applyPageContext(route) | Update route title/focus context when applicable. | The same Router instance. |
| eventPushStateClick(event) | Handle an eligible browser push-state click; same-page #fragment links stay native (see Progressive enhancement first). | The same Router after handled navigation; true when native/default handling should continue. |
| eventPopState() | Dispatch the current browser history location; a hash-only change just updates url. | The same Router instance. |
Router records navigation state on public properties that applications should treat as read-only: url, route, previousRoute, locationData, and pageTitle.
TypeScript
Implementation uses strict TypeScript and emits JavaScript, source maps, and declarations into dist.
const router = new Router();
router.navigationRoot = document.querySelector('main');
const greeting: Router.Route = (scope, location) => {
console.log(location.data.query.name);
};
router.routes['/hello'] = greeting;
router.initialize();Router.Route, Router.Location, Router.Navigation, and Router.Handler expose the supported contracts. Browser DOM types are present because the same class supports browser scopes and events; server hosts do not need DOM globals at runtime.
Development
npm ci --ignore-scripts
npm run lint && npm run coverage && npm run auditnpm run coverage builds (which typechecks), runs the consumer type checks, and runs every Node test, so npm run typecheck and npm test don't need separate runs. For a quick loop while iterating, use npm run build && node --test test/<file>.test.js. npm run lint covers src/, test/, and benchmark/. dist/ is generated and not committed; npm run build (also run by prepare for git installs) creates it.
After npm run build:
npm run test:browserrunstest/browser/in real Chromium through Playwright (real link clicks, back/forward, fragments, focus,navigationRoot) against pages served by a localnode:httpserver. It is not part ofnpm test. Install a browser once withnpx playwright install chromium, or pointPLAYWRIGHT_CHROMIUM_EXECUTABLEat an existing Chromium.npm run benchmarktimesmatchRoute,navigate, and link clicks with 10 and 1,000 routes.npm run bench:ciis a short version that fails if any operation is more than 3x slower thanbenchmark/baseline.jsonor a deterministic counter differs. Refresh the baseline after an intended performance change withnode benchmark/router.bench.js --ci --update-baselineand commit the result.
Tests cover browser behavior, scoped progressive navigation, DOM-free server routing, atomic rejected navigation, mediator reassignment, and parity of the shared route contract. Coverage enforces 100% statements, branches, functions, and lines per implementation file. CI verifies the documented Node minimum and primary Node line, runs the short benchmark and the Chromium browser tests, audits dependencies, packs the package, and verifies the public API across npm, Yarn, and pnpm.
Edit src/index.ts; dist is build output and is not edited or committed. See CONTRIBUTING.md.
Design boundary
Router owns URL-to-intent translation. It intentionally does not own application state, rendering, data loading, authentication policy, or server infrastructure. Real links and server URLs remain the foundation; Router enhances them instead of replacing them.
