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.
Getting started
Install#
npm i @dmytromykhailiuk/preact-signal-router @preact/signals preact
@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.
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.
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>;
}
<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.
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
Register an eagerly-bundled page.
component: () => JSX.Element | null.
lazyComponent: () => Promise<Component>.
The chunk is imported the first time the route activates.
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.
redirectTo is a string, a
{ redirectTo, replace?, data? } object, or a
function
(ctx) => Redirect | Promise<Redirect>.
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.
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 intosnapshot.params.name. -
**— a catch-all that matches the rest of the path. Great as a finaladdRedirect("**", …)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.
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.
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.
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.
|
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.
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>;
}
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:
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. |
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.
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$.
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.
-
root —
instant, no animation (like
NavController.navigateRoot). Opt into an animation withanimations.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.
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)
},
});
/* 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; }
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.
function App() {
return (
<div class="layout">
<AppHeader />
<RouterOutlet router={router} />
</div>
);
}
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.
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`;
});
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"),
);
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