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

@dmytromykhailiuk/preact-signal-router

v1.0.0

Published

A fully-typed, Angular-style router for Preact built entirely on @preact/signals — typed navigation, guards, resolvers, lazy/preload routes, base-path deploy and opt-in ion-router transitions. Signal-first, zero re-render.

Readme

@dmytromykhailiuk/preact-signal-router

A fully-typed, Angular-style router for Preact, built entirely on @preact/signals. Typed navigation, guards, resolvers, lazy/preload routes, base-path deploy and opt-in ion-router transitions. Signal-first, zero re-render — only the outlet re-renders when the active page changes; everything else you read is a signal you bind directly to the DOM.

Full documentation: open Docs in a browser — every option, with examples, a table of contents and cross-links. This README is the long-form summary.

Highlights

  • Typed navigation. createRouterConfig records every registered path at the type level. navigateForward autocompletes your static paths; parameterised routes go through router.path("/user/:id", { id }), which type-checks the params. Unknown paths and missing params are compile errors.
  • Angular-style config as data. Guards (canActivate / canDeactivate), resolvers, redirects, nested children and composable layouts.
  • Ion-router navigation. navigateForward / navigateBack / navigateRoot over an internal navigation stack. There is no public navigate.
  • Lazy & preload routes. addLazyPage loads on activation; addPreloadPage starts importing immediately and memoizes.
  • Opt-in ion-router transitions. animations: true gives the iOS slide for forward/back and an instant navigateRoot — identical to ion-router. Fully configurable: duration, easing and per-direction classes.
  • Base-path deploy. base: "/web" for apps served from company.com/web/.
  • A signal snapshot. snapshot$, component$, pending$, direction$ and navId$ are read-only signals you consume anywhere with zero re-render.

Install

npm i @dmytromykhailiuk/preact-signal-router @preact/signals preact

Requires @preact/signals ^2.0.0 (the Show/For utilities live in the /utils subpath, a signals v2 feature) and preact >=10.25.0. Both are peer dependencies.

Quick start

import { render } from "preact";
import {
  createRouter,
  createRouterConfig,
  RouterOutlet,
} from "@dmytromykhailiuk/preact-signal-router";

const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>;
const User = () => <h1>User</h1>;

// 1. Describe the route tree. RETURN the chained builder so the paths are typed.
const routes = createRouterConfig((r) =>
  r
    .addPage("/home", Home)
    .addPage("/about", About)
    .addChildren("/user", (u) => u.addPage("/:id", User))
    .addRedirect("**", "/home"),
);

// 2. Create the router — export it and navigate from anywhere.
export const router = createRouter(routes, { animations: true });

// 3. Mount the outlet once. It wires history + renders the active page.
function App() {
  return (
    <div>
      <nav>
        <button onClick={() => router.navigateForward("/home")}>Home</button>
        <button onClick={() => router.navigateForward("/about")}>About</button>
        <button onClick={() => router.navigateForward(router.path("/user/:id", { id: "42" }))}>
          User 42
        </button>
      </nav>
      <RouterOutlet router={router} />
    </div>
  );
}

render(<App />, document.getElementById("app")!);

The signal rules (zero re-render)

The router exposes signals, not values. The whole point is that a component mounts once and never re-renders on navigation. Follow three rules:

  1. Never unwrap .value in the render path. Reading snapshot$.value in a component body subscribes the whole component and re-renders it on every change.
  2. Bind signals directly to JSX text/attributes, or derive with useComputed and bind the result.
  3. Conditionals use <Show>, lists use <For> (from @preact/signals/utils).
import { useComputed } from "@preact/signals";
import { Show } from "@preact/signals/utils";
import { router } from "./router";

// ❌ re-renders on every navigation
function BadCrumb() {
  return <span>{router.snapshot$.value?.path}</span>;
}

// ✅ derive + bind — never re-renders
function Crumb() {
  const label = useComputed(() => router.snapshot$.value?.data.title ?? router.snapshot$.value?.path ?? "");
  return <span>{label}</span>;
}

// ✅ conditional through <Show>
function LoadingBar() {
  return (
    <Show when={router.pending$}>
      <div class="bar" />
    </Show>
  );
}

<RouterOutlet> is the only component that re-renders on navigation — it has to swap the page subtree.

The router instance

createRouter(config, options?) returns a RouterInstance. Export it and use it anywhere — components, event handlers, services.

Signals

| Signal | Type | Meaning | | ------------- | ------------------------------------------------ | ----------------------------------------------------------------- | | snapshot$ | RouteSnapshot \| null | { path, params, query, data } for the active route. | | component$ | PageComponent \| null | The component to render for the active route. | | pending$ | boolean | true while a navigation resolves (guards/resolvers/lazy load). | | direction$ | "forward" \| "back" \| "root" \| null | Direction of the most recent navigation — drives the animation. | | navId$ | number | Increments on every committed navigation. |

const snap = router.snapshot$.peek(); // { path: "/user/42", params: { id: "42" }, query: {}, data: {…} }

Options

createRouter(routes, {
  base: "/web",        // deploy under a sub-path (default "")
  animations: true,    // ion-style transitions (default off)
});

Defining routes

createRouterConfig((r) => …) builds the route tree with a fluent builder. Each method is chainable and returns the builder.

Typed paths: the callback must return the chained builder ((r) => r.addPage(...).addChildren(...)) so the union of paths is inferred. If you write statements instead of returning, navigation still works but falls back to plain strings.

| Method | Adds | | ----------------------------------------------- | -------------------------------------------------------------- | | addPage(path, component, options?) | An eagerly-bundled page () => JSX.Element \| null. | | addLazyPage(path, lazyComponent, options?) | A code-split page loaded when the route activates. | | addPreloadPage(path, preloadComponent, opts?) | A code-split page whose import starts immediately + memoizes. | | addRedirect(path, redirectTo, options?) | A redirect (string / object / function). | | addChildren(path, (child) => …, options?) | A nested group, optionally wrapped in a layout. |

Path patterns

  • /segment — a literal segment.
  • :param — captured into snapshot.params.
  • ** — a catch-all matching the rest of the path (not navigable; excluded from typed paths — use it for fallbacks/redirects).

Child paths are prefixed by their parent: addChildren("/user", (u) => u.addPage("/:id", …)) registers /user/:id.

RouteOptions

Every add* method takes the same options object (last argument):

| Option | Type | Meaning | | --------------- | --------------------------- | ---------------------------------------------------------- | | canActivate | Guard[] | Guards run before entering. Cascade to children. | | canDeactivate | Guard[] | Guards run before leaving the active route. | | resolve | Record<string, Resolver> | Data loaders; results merged into snapshot.data. | | data | Record<string, any> | Static data merged into snapshot.data. | | pathMatch | "full" \| "prefix" | Match strategy (default "prefix"). |

On addChildren, options also accept layout. Guards, resolvers and data on a parent group cascade down and merge with the child's own.

Layouts

A group's layout wraps every descendant page. Layouts compose — an ancestor layout wraps a nested layout wraps the page.

const Shell = ({ children }: { children: JSX.Element | null }) => (
  <div class="shell">
    <Sidebar />
    <main>{children}</main>
  </div>
);

const routes = createRouterConfig((r) =>
  r.addChildren(
    "/app",
    (app) => app.addPage("/home", Home).addPage("/settings", Settings),
    { layout: Shell },
  ),
);

Guards

A Guard is (ctx) => boolean | Redirect | Promise<…>. Return true to allow, false to block, or a redirect to send the user elsewhere. createGuard just types the function.

import { createGuard } from "@dmytromykhailiuk/preact-signal-router";

const authGuard = createGuard(({ to }) => (isLoggedIn() ? true : "/login"));

const adminGuard = createGuard(async ({ to, signal }) => {
  const ok = await hasRole("admin", { signal });
  return ok ? true : { redirectTo: "/403", data: { attempted: to.path } };
});

const routes = createRouterConfig((r) =>
  r
    .addChildren("/admin", (a) => a.addPage("/dashboard", Dashboard), {
      canActivate: [authGuard, adminGuard],
    })
    .addPage("/login", Login),
);

canDeactivate runs on the route you're leaving — useful for "unsaved changes" prompts:

const confirmLeave = createGuard(() => hasUnsavedChanges() ? confirm("Discard changes?") : true);
r.addPage("/edit", Editor, { canDeactivate: [confirmLeave] });

The context object

Every guard, resolver and redirect receives:

| Field | Type | Meaning | | -------- | ------------------------- | ------------------------------------------------------------ | | from | RouteSnapshot \| null | The route being left (null on first navigation). | | to | RouteSnapshot | The target route (params & query already filled in). | | signal | AbortSignal | Aborts when a newer navigation supersedes this one. |

Rapid navigations abort in-flight guards/resolvers automatically; check signal.aborted (or pass signal to fetch) in async work.

Resolvers

A Resolver is (ctx) => value | Promise<value>. Each entry in a route's resolve map runs (in parallel) before the page mounts; the result lands in snapshot.data under its key, so the page reads it synchronously — no in-component loading state.

import { createResolver } from "@dmytromykhailiuk/preact-signal-router";

const userResolver = createResolver(async ({ to, signal }) => {
  const res = await fetch(`/api/users/${to.params.id}`, { signal });
  return res.json();
});

const routes = createRouterConfig((r) =>
  r.addChildren("/user", (u) =>
    u.addPage("/:id", UserPage, { resolve: { user: userResolver } }),
  ),
);

// Inside UserPage — derive + bind, no .value in the render path.
function UserPage() {
  const name = useComputed(() => router.snapshot$.value!.data.user.name);
  return <h1>{name}</h1>;
}

Redirects

addRedirect accepts three forms:

import { createRedirect } from "@dmytromykhailiuk/preact-signal-router";

r
  // 1. a plain string
  .addRedirect("/", "/home")
  // 2. a redirect object (replace history entry, attach data)
  .addRedirect("/legacy", { redirectTo: "/home", replace: true, data: { via: "legacy" } })
  // 3. a function of ctx (async allowed)
  .addRedirect("/enter", createRedirect(({ to }) => (to.query.next === "1" ? "/home" : "/login")))
  // a ** catch-all fallback
  .addRedirect("**", "/home");

Guards can redirect too (return a string or redirect object) — the same three shapes apply.

Lazy & preload

Both split code with a dynamic import(); they differ in when the import starts.

| Method | Import starts… | Use for | | ------------------ | --------------------------------------- | --------------------------------------------------- | | addLazyPage | when the route activates | rarely-visited or heavy pages | | addPreloadPage | immediately at createRouter(...) time | likely-next pages you want warm (result memoized) |

r
  .addLazyPage("/reports", () => import("./pages/Reports").then((m) => m.Reports))
  .addPreloadPage("/checkout", () => import("./pages/Checkout").then((m) => m.Checkout));

Typed navigation

There is no public navigate. Navigation is ion-router style, over an internal stack:

| Method | Does | | ------------------------------- | ------------------------------------------------------------------------------- | | navigateForward(to, opts?) | Push a new route with a forward slide. | | navigateBack(to?, opts?) | Back slide. With to go there; without to pop to the previous entry. | | navigateRoot(to, opts?) | Reset the stack and go to to instantly (no animation, like ion-router). |

navigateBack() at the root of the stack is a no-op.

Same-URL navigation is ignored. Navigating to the route that is already active (same path and query) does nothing — navId$ stays put, and there is no re-render or re-animation (like Angular's onSameUrlNavigation: "ignore"). A different param on the same route (/user/1/user/2) is a real navigation — it's a new view, so it animates.

The path() helper

Static (param-free, non-wildcard) paths may be passed as bare autocompleted strings. Parameterised paths go through router.path, which requires — and type-checks — the params:

router.navigateForward("/home");                                  // static, autocompleted
router.navigateForward(router.path("/user/:id", { id: "42" }));   // params required
router.navigateForward(router.path("/user/:id", { id }, { tab: "posts" })); // + query
router.navigateForward(router.path("/home", { ref: "email" }));   // param-free: 2nd arg is query

Compile-time guarantees:

router.navigateForward("/nope");            // ❌ unknown path
router.navigateForward("/user/42");         // ❌ param path can't be a bare string
router.path("/user/:id", {});               // ❌ missing param `id`

NavigateOptions

| Option | Type | Meaning | | ----------------------- | -------------------------------------------- | ---------------------------------------------------------- | | data | Record<string, any> | Merged into snapshot.data. | | replace | boolean | Use history.replaceState instead of pushState. | | animation | "forward" \| "back" \| "root" \| false | Force a direction, or false to skip the animation. | | disableBackNavigation | boolean | Trap the browser back button on the resulting entry. |

Base-path deployment

Serving from a sub-path (e.g. company.com/web/)? Pass base. Incoming URLs are stripped of it, outgoing browser URLs are written with it, and snapshot.path stays app-relative.

const router = createRouter(routes, { base: "/web" });
await router.navigateForward("/home"); // browser URL → /web/home, snapshot.path → /home

Animations

Opt in with animations. The defaults mirror ion-router:

  • navigateForward / navigateBack — the iOS horizontal slide (entering page slides in, leaving page parallaxes out and dims).
  • navigateRootinstant, no animation (like NavController.navigateRoot, for login/logout/tab-reset flows where a slide would mislead).
  • Defaults: 540 ms, cubic-bezier(0.32, 0.72, 0, 1).

The outgoing page stays mounted for the transition; prefers-reduced-motion is respected; and the outlet itself never re-renders.

// ion-router defaults
createRouter(routes, { animations: true });

// fully configurable — duration, easing, per-direction classes
createRouter(routes, {
  animations: {
    duration: 300,
    easing: "cubic-bezier(0.32, 0.72, 0, 1)",
    forward: { enter: "my-enter-fwd", leave: "my-leave-fwd" },
    back: { enter: "my-enter-back", leave: "my-leave-back" },
    // Opt navigateRoot INTO an animation. Omit it → instant (default).
    // `{}` uses the built-in fade; or pass your own classes + CSS.
    root: {},
  },
});

The default stylesheet is injected once. To fully customise a direction, supply your own enter/leave class names and matching CSS/keyframes. direction$ and navId$ are available if you want to build a bespoke transition around the outlet.

RouterOutlet

<RouterOutlet router={router} />

Mount it once where routed content appears. On mount it calls router.register() (wiring popstate and performing the initial navigation from the current URL). With animations, it keeps the outgoing page mounted during the transition and applies the ion-style classes. The outlet itself never re-renders — only the swapped subtree does.

Manual control (advanced): router.register() returns a cleanup function you can call yourself if you are not using RouterOutlet.

Production example

// guards.ts
export const authGuard = createGuard(({ to }) =>
  store.isAuthed ? true : { redirectTo: "/page/guest", data: { next: to.path } },
);
export const notAuthGuard = createGuard(() => (store.isAuthed ? "/page/event" : true));
export const eventResolver = createResolver(({ to, signal }) =>
  api.getEvent(to.params.eventId, { signal }),
);

// routes.ts
export const routes = createRouterConfig((r) =>
  r.addChildren("/page", (page) =>
    page
      .addRedirect("/login-success", () => (store.isAuthed ? "/page/event" : "/page/guest"))
      .addPage("/guest", GuestPage, { canActivate: [notAuthGuard] })
      .addChildren(
        "/event",
        (event) =>
          event
            .addRedirect("/create", () => `/page/event/${api.newDraftId()}/otl-start`)
            .addChildren("/:eventId", (ev) =>
              ev
                .addPage("/otl-start", OtlStartPage)
                .addPage("/guide", GuidePage)
                .addPreloadPage("/steps/:stepId", () =>
                  import("./pages/Steps").then((m) => m.StepsPage),
                )
                .addLazyPage("/upload", () => import("./pages/Upload").then((m) => m.UploadPage))
                .addPage("/completed", CompletedPage),
              { resolve: { event: eventResolver }, layout: EventLayout },
            ),
        { canActivate: [authGuard] }, // the whole /event tree is auth-gated
      )
      .addPage("/not-found", NotFoundPage),
  ).addRedirect("**", "/page/not-found"),
);

// router.ts
export const router = createRouter(routes, { base: "/web", animations: true });

// navigate from anywhere — typed all the way
router.navigateForward(router.path("/page/event/:eventId/guide", { eventId: "42" }));
router.navigateBack();               // pop a step
router.navigateRoot("/page/guest");  // e.g. after logout

Exports

| Export | Kind | | ----------------------------------------------------------------- | ----- | | createRouterConfig | value | | createRouter | value | | createGuard / createResolver / createRedirect | value | | RouterOutlet | value | | resolveAnimations / ensureAnimationStyles | value | | joinPaths / addSlash / mergePath / parseUrl / matchPath | value | | makeRoutesFlatten / mergeRoutes | value | | RouterInstance, RouterConfig, RouteConfig, RouteSnapshot, Guard, Resolver, Redirect, NavigateOptions, RouterOptions, AnimationConfig, NavDirection, RoutePath, PathParams, … | type |

TypeScript

  • Set jsxImportSource: "preact" (and jsx: "react-jsx") in tsconfig.json.
  • createRouterConfig infers a RouterConfig<Paths> — the union of every registered path. createRouter carries Paths into the navigation methods and path() for full autocomplete and param checking.
  • PathParams<"/user/:id/tab/:tab"> resolves to { id: string; tab: string } if you need the params type directly.

License

MIT © Dmytro Mykhailiuk