preact-signal-router
preact-signal-router 1.0.0

A fully-typed, signal-first router for Preact. Zero re-render.

Contents

Contents

preact-signal-router

A fully-typed, Angular-style router for Preact, built entirely on @preact/signals. You describe your route tree as data with a fluent, chainable builder; the router matches the URL, runs guards and resolvers, code-splits pages, and exposes the whole navigation state as a handful of read-only signals.

Signal-first, zero re-render. The active route, its component, params, query and resolved data all live in signals you can read anywhere. Only the <RouterOutlet> re-renders when the page changes — everything else that reads the snapshot updates through fine-grained signal subscriptions.

Navigation is strictly typed: navigate methods know exactly which paths your config registered, and a path() helper fills in :params with compile-time checking. Ships with guards, resolvers, redirects, lazy/preload routes, sub-path deployment, and opt-in ion-router-style page transitions.

typed navigation zero re-render guards & resolvers lazy + preload ion-style transitions base-path deploy

Getting started

Install#

sh
npm i @dmytromykhailiuk/preact-signal-router @preact/signals preact
Peer dependencies

@preact/signals ^2.0.0 and preact >=10.25.0. The package is ESM + CJS with full .d.ts types and has no other runtime dependencies.

Getting started

Quick start#

Three steps: describe the routes, create a router, and mount an outlet. Here is a complete app with three pages and a fallback redirect.

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

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

// 2. Create the router instance (export it — you can navigate from anywhere).
export const router = createRouter(routes);

// 3. Mount one outlet where routed content should appear.
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")!);

A page is any component of type () => JSX.Element | null. The outlet renders the one that matches the current URL. That is the whole loop — everything below is optional depth: guards, resolvers, code-splitting, typed params, and animation.

Mental model

Core concepts#

createRouter returns a plain object — the router instance. You export it and call navigation methods on it from anywhere: event handlers, effects, other modules. Its state is exposed as read-only signals.

Signal Type What it holds
snapshot$ RouteSnapshot | null The active route: { path, params, query, data }. null before the first navigation.
component$ Component | null The component to render for the active route (already loaded, layouts composed).
pending$ boolean true while a navigation is resolving — running guards, resolvers, or loading a lazy chunk.
direction$ "forward" | "back" | "root" | null Direction of the most recent navigation. Drives the outlet's animation.
navId$ number Increments on every committed navigation. Lets the outlet key transitions reliably.

Consume these anywhere — but follow the signal rules so nothing re-renders: bind the signal directly to JSX, derive with useComputed, and render conditionals with <Show>. Never unwrap .value in the render path — that subscribes the whole component and re-renders it on every change.

tsx
import { useComputed } from "@preact/signals";
import { Show } from "@preact/signals/utils";
import { router } from "./router";

// A loading bar that reacts to navigation without touching the outlet.
// <Show> reads the signal internally, so LoadingBar never re-renders.
function LoadingBar() {
  return (
    <Show when={router.pending$}>
      <div class="bar" />
    </Show>
  );
}

// Derive the text with useComputed and bind the resulting signal directly —
// no .value in the render path.
function Breadcrumb() {
  const label = useComputed(() => {
    const snap = router.snapshot$.value;
    return snap?.data.title ?? snap?.path ?? "";
  });
  return <span>{label}</span>;
}
Zero re-render

<RouterOutlet> is the only component that re-renders on navigation — it has to swap the page subtree. Every other component stays mounted: bind snapshot$/pending$ straight to the DOM, or read them inside useComputed / useSignalEffect. Reading .value in a component body throws that away and re-renders the component.

Configuration

Defining routes#

createRouterConfig hands you a fluent builder. Every add* call is chainable and returns the builder, so a whole tree reads as one expression. The return value is a RouterConfig branded with the union of every path you registered — that brand is what makes navigation typed later.

Return the builder

For typed paths the callback must return the chained builder — (r) => r.addPage(...).addPage(...), not a statement block that throws the chain away. Same rule inside every addChildren callback. If you forget, everything still works at runtime; you just lose path autocompletion.

Builder methods

addPage
(path, component, options?)

Register an eagerly-bundled page. component: () => JSX.Element | null.

addLazyPage
(path, lazyComponent, options?)

lazyComponent: () => Promise<Component>. The chunk is imported the first time the route activates.

addPreloadPage
(path, preloadComponent, options?)

Same signature as lazy, but the import starts immediately at createRouter(...) time and is memoized — the page is usually ready before the user reaches it.

addRedirect
(path, redirectTo, options?)

redirectTo is a string, a { redirectTo, replace?, data? } object, or a function (ctx) => Redirect | Promise<Redirect>.

addChildren
(path, (child) => child…, options?)

A nested group. Child paths are prefixed by the parent path. options may add a layout plus guards / resolvers / data that cascade to every descendant.

tsx
const routes = createRouterConfig((r) =>
  r
    .addPage("/", DashboardPage)
    .addLazyPage("/reports", () => import("./reports").then((m) => m.ReportsPage))
    .addPreloadPage("/checkout", () => import("./checkout").then((m) => m.CheckoutPage))
    .addChildren("/settings", (s) =>
      s.addPage("/profile", ProfilePage).addPage("/billing", BillingPage),
    )
    .addRedirect("/old-home", "/")
    .addRedirect("**", "/"),
);

Path patterns

  • /segment — a literal segment.
  • :name — a dynamic segment, captured into snapshot.params.name.
  • ** — a catch-all that matches the rest of the path. Great as a final addRedirect("**", …) fallback. Wildcards are not navigable targets — they are excluded from the typed path union.

By default matching is "prefix": /event matches /event/42. Pass pathMatch: "full" in options to require an exact segment-count match.

Layouts

A group's layout wraps every descendant page. Layouts compose: an ancestor layout wraps a nested layout wraps the leaf page — exactly the nesting you wrote.

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

const routes = createRouterConfig((r) =>
  r.addChildren(
    "/app",
    (a) => a.addPage("/home", HomePage).addPage("/inbox", InboxPage),
    { layout: AppShell }, // wraps HomePage and InboxPage
  ),
);

RouteOptions reference

Every builder method accepts an optional options object:

Option Type Meaning
canActivate Guard[] Run before entering. Any false blocks; a Redirect reroutes.
canDeactivate Guard[] Run before leaving the currently-active route.
resolve Record<string, Resolver> Resolved in parallel; each result is merged into snapshot.data under its key.
data Record<string, any> Static data merged into the snapshot (titles, roles, flags…).
pathMatch "full" | "prefix" Matching strategy. Default "prefix".
layout ({ children }) => JSX (addChildren only) wraps every descendant page.

On addChildren, canActivate, canDeactivate, resolve and data cascade down and are merged with each child's own options.

Navigation control

Guards#

A guard decides whether a navigation may proceed. Wrap the function in createGuard for full typing (it simply returns the function). A guard receives the navigation context and returns:

  • true — allow.
  • false — block; the navigation is abandoned.
  • a Redirect (string, { redirectTo, replace?, data? }) — reroute instead.

Any guard may be async — the router awaits it.

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

export const authGuard = createGuard(({ to }) => {
  if (auth.isLoggedIn) return true;
  // remember where the user wanted to go
  return { redirectTo: "/login", data: { next: to.path } };
});

export const roleGuard = createGuard(async ({ to }) => {
  const role = await auth.role();
  return role === "admin" ? true : "/not-authorized";
});

const routes = createRouterConfig((r) =>
  r
    .addChildren("/admin", (a) => a.addPage("/users", UsersPage), {
      canActivate: [authGuard, roleGuard], // both cascade to /admin/users
    })
    .addPage("/login", LoginPage),
);

canDeactivate

canDeactivate guards run on the route you are leaving — perfect for an "unsaved changes" prompt.

tsx
const confirmLeave = createGuard(() =>
  form.isDirty ? window.confirm("Discard unsaved changes?") : true,
);

r.addPage("/edit", EditPage, { canDeactivate: [confirmLeave] });

The context object

Field Type Meaning
from RouteSnapshot | null The route being left (null on first navigation).
to RouteSnapshot The target route, with params and query already parsed.
signal AbortSignal Aborts if a newer navigation supersedes this one — forward it to fetch.
Abort on rapid navigation

If a second navigation starts while a guard or resolver is still awaiting, the first is aborted: its signal fires and its result is discarded, so only the latest navigation ever commits. Pass ctx.signal to any async work to cancel it cleanly.

Navigation control

Resolvers#

A resolver loads data before the page renders. Declare a map of them in resolve; they run in parallel and each result lands in snapshot.data under its key. The page can read it synchronously — no in-component loading state.

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

import { useComputed } from "@preact/signals";

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 — the data is already there. Derive + bind the signal;
// no .value in the render path.
function UserPage() {
  const name = useComputed(() => router.snapshot$.value!.data.user.name);
  return <h1>{name}</h1>;
}
Cancellation

Resolvers receive the same signal as guards. A superseded navigation aborts its resolvers and never commits their output, so stale data can't flash on screen.

Navigation control

Redirects#

addRedirect takes three forms of target:

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

const routes = createRouterConfig((r) =>
  r
    // 1. a plain string
    .addRedirect("/old", "/new")

    // 2. a redirect object (control history + attach data)
    .addRedirect("/legacy", { redirectTo: "/new", replace: true, data: { from: "legacy" } })

    // 3. a function — decide at navigation time using ctx
    .addRedirect(
      "/enter",
      createRedirect(({ to }) => (auth.isLoggedIn ? "/dashboard" : "/login")),
    )

    // a ** fallback — the classic "not found" catch-all (put it last)
    .addPage("/dashboard", DashboardPage)
    .addPage("/login", LoginPage)
    .addRedirect("**", "/login"),
);

Redirect functions may be async and can build their target from ctx.to.params / ctx.to.query — handy for turning a short link into a full deep link. Guards can redirect too (by returning a Redirect); use a redirect route when the rule is about the URL, and a guard when it is about permission.

Performance

Lazy & preload#

Both split a page into its own chunk with a dynamic import(). They differ in when the chunk loads.

Method Import starts Use when
addLazyPage The first time the route activates. Rarely-visited routes — keep them out of the initial bundle entirely.
addPreloadPage Immediately at createRouter(...), then memoized. Likely-next routes — split the chunk but have it ready before the user clicks.
tsx
const routes = createRouterConfig((r) =>
  r
    // loaded on demand
    .addLazyPage("/settings", () => import("./pages/settings").then((m) => m.SettingsPage))

    // import fired at startup; the page is usually ready by the time it's reached
    .addPreloadPage("/checkout", () => import("./pages/checkout").then((m) => m.CheckoutPage)),
);

While a lazy chunk is loading, pending$ is true — drive a top-level loading bar off it. Guards and resolvers on a lazy/preload route still run before the component shows.

Deployment

Base-path deployment#

When the app is served from a sub-path — company.com/web/ rather than the origin root — pass base. Incoming URLs are stripped of it before matching; browser URLs are written with it. Your route config and snapshot.path stay clean and app-relative.

tsx
export const router = createRouter(routes, { base: "/web" });

router.navigateForward("/home");
// snapshot.path === "/home"            (app-relative — unchanged)
// window.location.pathname === "/web/home"  (browser URL — prefixed)

You author routes and navigate exactly as before — base is applied only at the boundary with the browser URL. Set it once; nothing else in your app needs to know about it.

Transitions

Animations#

Opt into ion-router-style page transitions by passing animations to createRouter. The outlet keeps the outgoing page mounted alongside the incoming one for the duration of the transition and applies enter/leave classes derived from direction$.

tsx
export const router = createRouter(routes, { animations: true });

The defaults mirror ion-router:

  • forward — the iOS slide: the new page slides in from the right while the current page parallaxes left and dims.
  • back — the reverse iOS slide.
  • rootinstant, no animation (like NavController.navigateRoot). Opt into an animation with animations.root.

Defaults: 540ms, easing cubic-bezier(0.32, 0.72, 0, 1). The stylesheet is injected once; per-router duration and easing ride on inline CSS custom properties, so multiple routers can animate differently. prefers-reduced-motion collapses the durations.

Customising

Everything is configurable. Pass an AnimationConfig to tune duration and easing, or to swap the enter/leave CSS class names per direction. Each direction is { enter?, leave? }. root is special: omit it and navigateRoot stays instant; pass {} for the built-in fade, or your own classes for a custom root transition.

tsx
const router = createRouter(routes, {
  animations: {
    duration: 300,
    easing: "cubic-bezier(0.32, 0.72, 0, 1)",
    // keep the iOS forward/back slide, but give navigateRoot a bespoke feel
    root: { enter: "fade-scale-in", leave: "fade-scale-out" },
    // (or `root: {}` for the built-in fade; omit `root` entirely to stay instant)
  },
});
css
/* pair your custom class names with keyframes */
@keyframes fade-scale-in  { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: none; } }
@keyframes fade-scale-out { from { opacity: 1; } to { opacity: 0; } }
.fade-scale-in  { animation: fade-scale-in  var(--psr-dur) var(--psr-ease) both; }
.fade-scale-out { animation: fade-scale-out var(--psr-dur) var(--psr-ease) both; }
Roll your own

Prefer to animate yourself? Leave animations off and drive your own transition from direction$ and navId$ — the outlet still exposes both. The --psr-dur / --psr-ease custom properties are set on the outlet element for your CSS to consume.

Rendering

RouterOutlet#

Mount <RouterOutlet router={router} /> once, wherever routed content belongs. It renders the active component$ — animated if the router opted in — and on mount it calls router.register() for you, which wires the popstate listener and performs the initial navigation from the current URL.

tsx
function App() {
  return (
    <div class="layout">
      <AppHeader />
      <RouterOutlet router={router} />
    </div>
  );
}
Manual registration

Rendering without an outlet (SSR probes, tests)? Call const stop = router.register() yourself and invoke stop() to tear down the listener. Calling register() twice is a safe no-op.

Putting it together

Production example#

A realistic authenticated app: a /page group with entry redirects, an auth-gated /event section, a nested :eventId group with a layout and a data resolver, a preloaded steps page and a lazy upload page, plus a catch-all. It deploys under /web with animations on.

tsx guards.ts
import { createGuard, createResolver, createRedirect } from "@dmytromykhailiuk/preact-signal-router";
import { auth, i18n, api } from "./services";

export const authGuard = createGuard(({ to }) =>
  auth.isLoggedIn ? true : { redirectTo: "/page/not-authorized", data: { next: to.path } },
);

export const notAuthGuard = createGuard(() => (auth.isLoggedIn ? "/page/event/create" : true));

export const translationGuard = createGuard(async ({ signal }) => {
  await i18n.ensureLoaded({ signal });
  return true;
});

export const openStepsGuard = createGuard(({ to }) => api.canOpenStep(to.params.stepId));
export const uploadingGuard = createGuard(() => auth.canUpload);

export const eventResolver = createResolver(async ({ to, signal }) => {
  const res = await fetch(`/api/events/${to.params.eventId}`, { signal });
  return res.json();
});

export const enterRedirect = createRedirect(() =>
  auth.isLoggedIn ? "/page/event/create" : "/page/guest",
);

export const createEventRedirect = createRedirect(async () => {
  const { id } = await api.createEvent();
  return `/page/event/${id}/otl-start`;
});
tsx routes.ts
import { createRouterConfig } from "@dmytromykhailiuk/preact-signal-router";
import {
  authGuard, notAuthGuard, translationGuard, openStepsGuard, uploadingGuard,
  eventResolver, enterRedirect, createEventRedirect,
} from "./guards";
import { EventLayout } from "./layouts";
import {
  GuestPage, OtlStartPage, GuidePage, CompletedPage, InterruptedPage,
  NotFoundPage, NotAuthorizedPage,
} from "./pages";

export const routes = createRouterConfig((root) =>
  root
    .addChildren("/page", (page) =>
      page
        // entry points bounce the user to the right place
        .addRedirect("/tmp", enterRedirect)
        .addRedirect("/continue", enterRedirect)
        .addRedirect("/login-success", enterRedirect)

        .addPage("/guest", GuestPage, { canActivate: [notAuthGuard, translationGuard] })

        .addChildren(
          "/event",
          (event) =>
            event
              .addRedirect("/create", createEventRedirect)
              .addChildren(
                "/:eventId",
                (ev) =>
                  ev
                    .addPage("/otl-start", OtlStartPage)
                    .addPage("/guide", GuidePage)
                    // preloaded: the chunk starts loading at startup
                    .addPreloadPage(
                      "/steps/:stepId",
                      () => import("./pages/steps").then((m) => m.StepsPage),
                      { canActivate: [openStepsGuard] },
                    )
                    // lazy: loaded only when reached
                    .addLazyPage(
                      "/upload",
                      () => import("./pages/upload").then((m) => m.UploadPage),
                      { canActivate: [uploadingGuard] },
                    )
                    .addPage("/completed", CompletedPage)
                    .addPage("/interrupted", InterruptedPage),
                {
                  canActivate: [translationGuard],
                  resolve: { event: eventResolver }, // available to every child
                  layout: EventLayout,
                },
              ),
          { canActivate: [authGuard] }, // the whole /event tree is auth-gated
        )

        .addPage("/not-found", NotFoundPage, { canActivate: [translationGuard] })
        .addPage("/not-authorized", NotAuthorizedPage, {
          canActivate: [notAuthGuard, translationGuard],
        }),
    )
    .addRedirect("**", "/page/not-found"),
);
tsx router.ts + App
import { createRouter, RouterOutlet } from "@dmytromykhailiuk/preact-signal-router";
import { Show } from "@preact/signals/utils";
import { routes } from "./routes";

// deployed at company.com/web/ with ion-style transitions
export const router = createRouter(routes, { base: "/web", animations: true });

export function App() {
  return (
    <div class="app">
      <Show when={router.pending$}>
        <TopLoadingBar />
      </Show>
      <RouterOutlet router={router} />
    </div>
  );
}

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

Exports#

Values

createRouterConfig · createRouter · createGuard · createResolver · createRedirect · RouterOutlet · ensureAnimationStyles · resolveAnimations · joinPaths · addSlash · mergePath · parseUrl · matchPath · makeRoutesFlatten · mergeRoutes

Types

RouterInstance · RouterConfig · RouterBuilder · RouterOptions · RouteConfig · RouteConfigBase · RouteConfigWithComponent · RouteConfigWithLazyComponent · RouteConfigWithPreloadComponent · RouteConfigWithRedirect · RouteConfigWithChildren · RouteOptions · ChildrenOptions · RouteSnapshot · RouteParams · RouteData · FnCtx · Guard · GuardResult · Resolver · Redirect · RedirectObject · RedirectFn · NavigateOptions · NavigateTarget · NavDirection · PathFn · PathArgs · PathParams · ExtractParams · NoParamPaths · RoutePath · PageComponent · LazyComponent · LayoutComponent · AnimationsOption · AnimationConfig · AnimationLayers · ResolvedAnimations · MatchResult · MatchStrategy