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.33.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.

Installation

bun add @canonical/router-core

Quick start

1. Define routes

import { createRouter, 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

const router = createRouter(routes);

3. Use typed helpers

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

router.navigate("home");
await router.prefetch("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.
  • prefetch() 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.
  • 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(), prefetch(), and the typed navigation helpers.
  • SSR is built in. dehydrate() preserves navigation state across the server/client boundary.

Progressive disclosure

Basic route

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

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

Route with prefetch

prefetch() 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",
  prefetch: async ({ id }, _search, { signal }) => {
    await queryClient.prefetchQuery({
      queryKey: ["user", id],
      queryFn: () => fetchUser(id),
      signal,
    });
  },
  content: ({ params }) => `User: ${params.id}`,
});

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 { 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}`,
});

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 }): load() commits it as a 400 error result (a real 400 under SSR, an error-boundary render on the client). Prefer normalizing schemas that supply defaults over rejecting ones — a shared URL with a stale query should not crash 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 schema — either the Standard Schema v1 shape ({ "~standard": { version: 1, vendor, validate } }, annotate with StandardSchemaV1<In, Out> for inference) or the legacy type-only shape ({ "~standard": { output, validate } }). See the Router API reference for both.

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 prefetch + 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 handling

The router does not ship an error boundary component. Errors from prefetch() are thrown into the React render tree and caught by standard React error boundaries. Use StatusResponse to signal HTTP-like errors:

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

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

In your React tree, catch these with any error boundary:

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

function ErrorFallback({ error }) {
  const status = error instanceof StatusResponse ? error.status : 500;
  return <ErrorPage status={status} />;
}

Redirects

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

const loginRequired = route({
  url: "/private",
  prefetch: async () => {
    redirect("/login", 302);
  },
  content: () => "private",
});

Search param mutation

setSearchParams() patches the current URL's search params without requiring the route name:

// 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 });

Navigation blocking

Register blockers to prevent navigation when there is unsaved state. Blockers are checked before any navigation proceeds:

const blockerId = "edit-form";

// Register a blocker that checks whether to block
router.registerBlocker({
  id: blockerId,
  isActive: () => formHasUnsavedChanges,
});

// When navigation is attempted while a blocker is active:
router.blockerState; // "blocked"

// The consumer decides whether to proceed or cancel
router.proceedNavigation(); // continue the blocked navigation
router.cancelNavigation();  // stay on the current page

// Remove the blocker when the form is submitted or discarded
router.unregisterBlocker(blockerId);

For React, use the useBlocker() hook from @canonical/router-react instead of these core primitives.

Middleware

Middleware runs once, before the router is created. Apply it to route definitions with applyMiddleware():

import { applyMiddleware, createRouter } from "@canonical/router-core";
import type { AnyRoute } 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 = applyMiddleware([withBasePath("/app")], rawRoutes);
const router = createRouter(routes);

See the migration guide for middleware patterns and migration from other routers.

Router factories

Convenience functions that create a router with a pre-configured platform adapter:

import {
  createBrowserRouter,
  createStaticRouter,
  createMemoryRouter,
} from "@canonical/router-core";

// Client — auto-detects Navigation API with History fallback
const router = createBrowserRouter(routes);

// Server — matches URL on construction, exposes router.match for status codes
const serverRouter = createStaticRouter(routes, req.url);

if (!serverRouter.match) { res.status(404); }
else if (serverRouter.match.kind === "redirect") {
  return res.redirect(serverRouter.match.status, serverRouter.match.redirectTo);
}

// Testing — in-memory adapter, supports navigation
const testRouter = createMemoryRouter(routes, "/users/42");

createStaticRouter fires prefetch() eagerly on construction, so caches start warming before React renders.

The low-level createRouter(routes, { adapter }) is still available for cases that need explicit adapter control.

SSR and hydration

The router dehydrates navigation state only (matched route, params, search, URL). Data dehydration is the cache library's responsibility.

const serverRouter = createStaticRouter(routes, "/users/42");
const navigationState = serverRouter.dehydrate();

const clientRouter = createBrowserRouter(routes, {
  hydratedState: navigationState ?? undefined,
});

For a full React SSR flow, see packages/react/router/README.md and apps/react/boilerplate-vite.

Platform adapters

The router factories use platform adapters internally. You can also use them directly with createRouter():

  • 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.
  • createHistoryAdapter() — explicitly use the History API.
  • createMemoryAdapter() — in-memory adapter for testing.
  • createServerAdapter() — static URL for server-side rendering.
import { createRouter, createMemoryAdapter } 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.

createMemoryRouter(routes, initialUrl, { history }) forwards the delegate to its internal adapter, so the convenience factory supports externally owned location too.

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

Override or disable them through RouterOptions.accessibility.

Public API

Functions and classes

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

Reference docs