npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@canonical/router-core

v0.45.0

Published

Framework-agnostic router core built on flat route triplets and wrapper annotations

Readme

@canonical/router-core

Framework-agnostic routing primitives for Canonical apps. @canonical/router-core gives you flat route definitions, wrapper composition, typed navigation helpers, SSR dehydration, middleware hooks, and accessibility orchestration without locking you into a specific view layer.

Stability: pre-1.0 / experimental. The API is still consolidating and breaking changes may land between minor versions. Every breaking change ships with a conventional-commit subject and a CHANGELOG entry — those are the migration record. Pin a minor version if you need stability today.

Installation

bun add @canonical/router-core

Quick start

1. Define routes

import { route } from "@canonical/router-core";

const routes = {
  home: route({
    url: "/",
    content: () => "Home",
  }),
  account: route({
    url: "/account/:team",
    content: ({ params }) => `Account: ${params.team}`,
  }),
} as const;

2. Create a router with an adapter

The adapter is the one construction axis — it decides where the location lives (browser URL, hash fragment, memory, a fixed server URL):

import { createBrowserAdapter, createRouter } from "@canonical/router-core";

const router = createRouter(routes, { adapter: createBrowserAdapter() });

A router created without an adapter is a pure matcher: match(), buildPath(), and load() work, while navigate() and setSearchParams() throw — there is no location for them to change.

3. Use typed helpers

router.buildPath("account", { params: { team: "web" } });
// "/account/web"

router.navigate("home");
await router.warm("account", { params: { team: "web" } });

4. Render through your framework binding

The core package intentionally stops at route matching, state, dehydration, and accessibility orchestration. For React rendering, pair it with @canonical/router-react.

Mental model

  • Routes are flat. Every route is declared with route().
  • Wrappers are annotations. Reuse layout with wrapper() and group().
  • Middleware is route-to-route transformation. Use it to add auth, i18n, metrics, or shared wrapper policy. Middleware runs once, before the router is created.
  • The adapter is the construction axis. createRouter(routes, { adapter }) is the only constructor; pick createBrowserAdapter(), createHashAdapter(), createMemoryAdapter(), createServerAdapter(), or bring your own PlatformAdapter.
  • warm() is fire-and-forget. It warms caches, preloads assets, or runs side effects at navigation time. It does not provide data to content() — components own their data via their cache library.
  • Errors are state, not exceptions. Every load commits a status onto the location (404 for no match, 400 for a rejected query string, the status of a thrown StatusResponse, 500 otherwise). Nothing is thrown at the view layer.
  • URL params are validated by schemas. Give a route a params or search Standard Schema validator (Zod, Valibot, ArkType, or hand-rolled) and the validated, typed output flows to content(), warm(), and the typed navigation helpers.
  • SSR is built in. dehydrate() preserves navigation state across the server/client boundary.

Design rationale

These are deliberate positions, not gaps. If the router feels smaller than others you have used, that is the design.

The router does not own data

warm() is the only data-adjacent hook and it is fire-and-forget on purpose. It never blocks rendering and never passes data to content(). The supported pattern is cache population: the hook warms the cache your components already read from — fetchQuery into a Relay store, queryClient.prefetchQuery for TanStack Query — and the component's own hook finds the data already there.

Handing loader data to components as props (the loader model other routers use) is rejected as an antipattern here:

  • it couples data lifetime to route lifetime — leaving the route drops data the cache could have kept;
  • it blocks navigation on the slowest fetch instead of letting the page stream in;
  • on a cache-aware app it double-fetches: the loader fetches, then the component's cache hook fetches again or needs bridging glue.

One consequence to know: because the hook never blocks, a redirect or status thrown from an async warm() is applied late — the destination may render briefly before the redirect lands (applied with replace semantics, so Back does not return to it). That flash is the honest price of never blocking render.

Errors are state, not exceptions

HTTP-shaped failures are facts about the location, so they live on the location: getState().location.status. The router owns data errors (no match → 404, rejected query → 400, thrown StatusResponse/Response → its status, anything else → 500) and commits them as state; the view layer owns render errors with its native mechanism (an error boundary, in React). Nothing the router does throws into your component tree. See the two-channel guide in @canonical/router-react's README.

Minimal API, low sugar

One orthogonal way per capability. There are no preset router factories — the adapter is the whole axis, so createRouter(routes, { adapter }) is the entire construction story. Blocking is one method (router.block()) returning one handle. Convenience layers must earn their place; the surface we stabilize at 1.0 should be the smallest one that composes.

Progressive disclosure

Basic route

import { route } from "@canonical/router-core";

const settingsRoute = route({
  url: "/settings",
  content: () => "Settings",
});

Route with warm

warm() is a fire-and-forget navigation-time hook. Use it to warm a cache, preload assets, fire analytics, or run permission checks. It does not return data to the component.

const userRoute = route({
  url: "/users/:id",
  warm: async ({ id }, _search, { signal }) => {
    await queryClient.prefetchQuery({
      queryKey: ["user", id],
      queryFn: () => fetchUser(id),
      signal,
    });
  },
  content: ({ params }) => `User: ${params.id}`,
});

Throwing from warm() is meaningful control flow: redirect(to, status) redirects, throw new StatusResponse(404) sets the location status. Both work from sync and async hooks — an async throw is applied late (after the route rendered) and only while the navigation is still current; any other async rejection is deliberately ignored, because a failed cache warm must never break navigation.

Schema validation for URL params

Routes accept Standard Schema validators for both kinds of URL parameters. Any spec-compliant library schema (Zod ≥3.24, Valibot, ArkType) can be passed directly; raw values always arrive as strings, so use coercion.

Path params — the params field validates and coerces :param segments. A rejected URL is a non-match: matching falls through to the next route and ultimately the not-found route (404), exactly like a pattern mismatch.

import { createBrowserAdapter, createRouter, route } from "@canonical/router-core";
import { z } from "zod";

const productRoute = route({
  url: "/products/:id",
  params: z.object({ id: z.coerce.number().int().positive() }),
  // "/products/abc" → 404; "/products/42" → params.id === 42 (a number)
  content: ({ params }) => `Product #${params.id}`,
});

const router = createRouter(
  { product: productRoute },
  { adapter: createBrowserAdapter() },
);

router.buildPath("product", { params: { id: 42 } }); // "/products/42" — fully typed

Search params — the search field validates the query string. A rejected query throws StatusResponse(400, { issues, message }), which load() commits as a 400 error result on the location — a real 400 under SSR, location.status === 400 on the client. It does not reach an error boundary. Prefer normalizing schemas that supply defaults over rejecting ones — a shared URL with a stale query should not error the page:

const listRoute = route({
  url: "/products",
  search: z.object({
    page: z.coerce.number().int().min(1).catch(1),
    sort: z.enum(["price", "name"]).catch("name"),
  }),
  content: ({ search }) => `page ${search.page}, sorted by ${search.sort}`,
});

No dependency? Hand-roll the Standard Schema v1 shape ({ "~standard": { version: 1, vendor, validate } }, annotated with StandardSchemaV1<In, Out> for inference). See the Router API reference.

Validation runs at match time and is synchronous — async validators (e.g. Zod async refinements) throw with an explanatory error. For semantic checks (does the record exist?) use warm + StatusResponse.

Wrapper composition

import { group, route, wrapper } from "@canonical/router-core";

const appShell = wrapper({
  id: "app:shell",
  component: ({ children }) => `<main>${String(children)}</main>`,
});

const [dashboardRoute, reportsRoute] = group(appShell, [
  route({ url: "/dashboard", content: () => "Dashboard" }),
  route({ url: "/reports", content: () => "Reports" }),
] as const);

Error signalling

Use StatusResponse to give a failure an HTTP status. Thrown from warm(), it becomes the location's status — read it as state (getState().location.status, or useRoute().status in React), never from an error boundary:

import { route, StatusResponse } from "@canonical/router-core";

const adminRoute = route({
  url: "/admin",
  warm: () => {
    if (!isAuthenticated()) {
      throw new StatusResponse(401);
    }
  },
  content: () => "Admin panel",
});

The data argument is optional; pass one to carry a typed payload (new StatusResponse(503, { retryAfter: "5m" })).

Redirects

Runtime redirects — thrown from warm(), sync or async:

import { redirect, route } from "@canonical/router-core";

const privateRoute = route({
  url: "/private",
  warm: () => {
    if (!isAuthenticated()) {
      redirect("/login", 302);
    }
  },
  content: () => "private",
});

Static redirect routes — declared entirely in the route map, no content; they answer with 301 or 308 (the permanent family; redirect() covers the temporary one):

const legacyHome = route({
  url: "/home",
  redirect: "/",
  status: 301,
});

Search param mutation

setSearchParams() patches the current URL's search params without requiring the route name (adapter required — it changes the location):

// Merge into current search params
router.setSearchParams({ page: "2" });

// Functional update
router.setSearchParams((current) => ({
  ...current,
  page: String(Number(current.page ?? "0") + 1),
}));

// Remove a param (set to null)
router.setSearchParams({ filter: null });

// Replace history entry instead of pushing
router.setSearchParams({ page: "2" }, { replace: true });

setSearchParams() is how a page restates its own view — a filter, a grouping, a sort, a tab — so that the address can be copied and reopened on that view. It is not a navigation: the store commits the new location, subscribers are notified, and the adapter records the address (pushed, or replacing the current entry with replace: true), but there is no view transition, no scroll restoration, no focus move and no route announcement. The reader stays on the control they were using. A getTitle option still runs, so the document title follows the address.

Everything else that changes the address stays a navigation, including one that differs only in its search: navigate() to the same page with other search params, the browser's back and forward, and a redirect reached from a search update (which the reader did not ask for) all run the full accessibility effects. Only the caller knows that an address is the same page restated, so the distinction is made by the method called, not inferred from the address — /?user=ana and /?user=bob may well be two different pages.

Navigation blocking

router.block(isActive) registers a blocker and returns a handle. While isActive() returns true, navigate() is intercepted and held until the handle decides it:

const blocker = router.block(() => formHasUnsavedChanges);

// When a navigation is attempted while the blocker is active:
blocker.state; // "blocked"

blocker.subscribe((state) => {
  // "blocked" → show your confirmation UI; "idle" → hide it
});

blocker.proceed(); // continue the blocked navigation
blocker.cancel(); // stay on the current page

// Remove the blocker; a navigation blocked on it is discarded, not resumed
blocker.dispose();

Blockers intercept router.navigate() only — setSearchParams() and adapter-driven back/forward are not intercepted. For React, use useBlocker() from @canonical/router-react.

Middleware

Middleware is a route-to-route transformation (<TRoute extends AnyRoute>(route: TRoute) => TRoute), applied once before the router exists. Pass it through RouterOptions.middleware — the first entry in the array is the outermost transformation (applied last):

import {
  type AnyRoute,
  createBrowserAdapter,
  createRouter,
  route,
} from "@canonical/router-core";

function withBasePath(basePath: string) {
  return <TRoute extends AnyRoute>(currentRoute: TRoute): TRoute => {
    return {
      ...currentRoute,
      url: `${basePath}${currentRoute.url}`,
    } as TRoute;
  };
}

const routes = {
  home: route({ url: "/", content: () => "Home" }),
} as const;

const router = createRouter(routes, {
  adapter: createBrowserAdapter(),
  middleware: [withBasePath("/app")],
});

The standalone applyMiddleware(routes, middleware) export is the lower-level array primitive (readonly AnyRoute[] in, same out) the option is built on; reach for it only when transforming route arrays outside a router.

See the middleware cookbook for auth, i18n, and instrumentation patterns.

Platform adapters

The adapter decides where the location lives; everything else about the router is identical across them:

  • createBrowserAdapter() — auto-detects the best API: uses the Navigation API (window.navigation) when available, falls back to the History API (pushState / popstate) for older browsers.
  • createNavigationAdapter() — explicitly use the Navigation API. Baseline Newly Available since January 2026. Navigations the router makes are intercepted with scroll: "manual" and focusReset: "manual", so the router's own scroll and focus management applies, as it does under the History API; navigations the browser starts (back/forward, reload) keep the browser's handling.
  • createHistoryAdapter() — explicitly use the History API.
  • createHashAdapter() — store the route in window.location.hash; useful where the path is fixed (Storybook, static file hosts).
  • createMemoryAdapter(initialUrl?, options?) — in-memory adapter for testing.
  • createServerAdapter(url) — a fixed URL for server-side rendering; its navigate() throws.
import { createMemoryAdapter, createRouter } from "@canonical/router-core";

const testRouter = createRouter(routes, {
  adapter: createMemoryAdapter("/users/42"),
});

Externally owned location

By default createMemoryAdapter() owns its location: it keeps an internal entries array and index that navigate, back, and forward mutate. Some hosts already own navigation state and need the router as a pure resolver rather than a second state owner — an application store that decides what is shown, a replay harness driving location from a recorded sequence, a state machine where "where we are" is derived state. For those, pass a history delegate and the adapter keeps no entries array and no index of its own:

const adapter = createMemoryAdapter("/", {
  history: {
    getLocation: () => store.currentUrl, // the single source of the current location
    onNavigate: (url, options) => store.go(url, options), // every navigate forwards here
    subscribe: (listener) => store.subscribe(listener), // host announces location changes
    onBack: () => store.back(), // optional — omit and back() becomes a no-op
    onForward: () => store.forward(), // optional — omit and forward() becomes a no-op
  },
});

With a delegate, getLocation reads the delegate, navigate(to, options) forwards to onNavigate and mutates nothing locally, and the adapter's subscribe is the seam through which the host announces changes. back and forward forward to the optional onBack/onForward hooks; when the host omits them they are no-ops, because a host that owns location owns its own history model. The initialUrl argument is ignored when a delegate is present. The entire route-resolution surface — matching, params, group wrappers — is unchanged.

Host values are normalized at the boundary: getLocation reads and subscription notifications both hand consumers a fresh URL, so a host mutating its own URL object cannot reach router internals, and a delegate may return bare path strings. An error thrown by onNavigate propagates to the navigate caller.

The host must notify synchronously. When the router navigates, it suppresses the echo of its own navigation through a guard that only holds for a notification fired synchronously within onNavigate. A host that batches change notifications (microtask, animation frame, or later) will miss the guard and every router-initiated navigation will resolve twice. If your store batches, notify the adapter's listener directly and synchronously inside onNavigate.

SSR and hydration

On the server, create the router with a server adapter, match the URL for the response status, and hydrate the store synchronously so rendering works without awaiting load():

import { createRouter, createServerAdapter } from "@canonical/router-core";

const router = createRouter(routes, {
  adapter: createServerAdapter(url),
  notFound: notFoundRoute,
});
const serverMatch = router.match(url);

if (serverMatch?.kind === "redirect") {
  // Answer with a real HTTP redirect before rendering anything.
  return respondRedirect(serverMatch.status, serverMatch.redirectTo);
}

if (serverMatch?.kind === "route" || serverMatch?.kind === "not-found") {
  router.hydrate({
    href: url,
    kind: serverMatch.kind,
    routeId: serverMatch.kind === "route" ? serverMatch.name : null,
    status: serverMatch.status, // 200, or 404 for the matched not-found route
  });
}

The router dehydrates navigation state only (router.dehydrate() → { href, kind, routeId, status }). Data dehydration is the cache library's responsibility. On the client, pass the serialized state back as hydratedState:

import { createBrowserAdapter, createRouter } from "@canonical/router-core";

const clientRouter = createRouter(routes, {
  adapter: createBrowserAdapter(),
  hydratedState: serializedState ?? undefined,
});

For the full React flow — renderToStream, readDehydratedState, and the __INITIAL_DATA__ contract — see packages/react/router/README.md and the reference app in apps/react/boilerplate-vite.

Accessibility

The router auto-wires browser-side accessibility orchestration:

  • ScrollManager — saves/restores scroll positions across navigations
  • FocusManager — moves focus to <h1> on route change
  • RouteAnnouncer — announces route changes to screen readers
  • ViewTransitionManager — wraps navigations in View Transitions when available

They run for navigations — navigate(), back/forward, and redirects — and not for the initial load or a setSearchParams() update. Override or disable them through RouterOptions.accessibility.

Public API

Functions and classes

  • applyMiddleware()
  • createBrowserAdapter()
  • createHashAdapter()
  • createHistoryAdapter()
  • createMemoryAdapter()
  • createNavigationAdapter()
  • createRouter()
  • createRouterStore()
  • createServerAdapter()
  • createSubject()
  • createTrackedLocation()
  • group()
  • redirect()
  • route()
  • wrapper()
  • FocusManager
  • RouteAnnouncer
  • ScrollManager
  • ViewTransitionManager
  • Redirect
  • StatusResponse

All supporting types (Router, RouterOptions, RouterBlockerHandle, PlatformAdapter, MemoryAdapterOptions, StandardSchemaV1, …) are exported from the package root.

Reference docs