@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.
Maintainers
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.
createRouterConfigrecords every registered path at the type level.navigateForwardautocompletes your static paths; parameterised routes go throughrouter.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/navigateRootover an internal navigation stack. There is no publicnavigate. - Lazy & preload routes.
addLazyPageloads on activation;addPreloadPagestarts importing immediately and memoizes. - Opt-in ion-router transitions.
animations: truegives 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 fromcompany.com/web/. - A signal snapshot.
snapshot$,component$,pending$,direction$andnavId$are read-only signals you consume anywhere with zero re-render.
Install
npm i @dmytromykhailiuk/preact-signal-router @preact/signals preactRequires
@preact/signals^2.0.0 (theShow/Forutilities live in the/utilssubpath, a signals v2 feature) andpreact>=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:
- Never unwrap
.valuein the render path. Readingsnapshot$.valuein a component body subscribes the whole component and re-renders it on every change. - Bind signals directly to JSX text/attributes, or derive with
useComputedand bind the result. - 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 intosnapshot.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 queryCompile-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 → /homeAnimations
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).navigateRoot— instant, no animation (likeNavController.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 logoutExports
| 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"(andjsx: "react-jsx") intsconfig.json. createRouterConfiginfers aRouterConfig<Paths>— the union of every registered path.createRoutercarriesPathsinto the navigation methods andpath()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
