Strongly-typed React router built on the Navigation API. No outlets, no nesting β just routes, loaders, and a URL builder.
Install react-wayfinder using your preferred package manager:
yarn add react-wayfinderDefine your URL patterns in a central urls object so every route definition, router.url() call, and <Route> reference points to a single source of truth. Changing a pattern updates every call site at once:
export const urls = {
home: "/",
user: "/users/:id",
} as const;Define your routes and render the router:
import { createRoot } from "react-dom/client";
import { route, Router } from "react-wayfinder";
import type { Routes } from "react-wayfinder";
const routes = [
route({
url: urls.home,
component() {
return <h1>Home</h1>;
},
}),
route({
url: urls.user,
async loader({ params, signal }) {
return fetchUser(params.id, { signal });
},
component({ status, params, data, error }) {
switch (status) {
case "loading": return <p>Loading…</p>;
case "error": return <p>{error.message}</p>;
case "ready": return <User id={params.id} name={data.name} />;
}
},
}),
] satisfies Routes;
createRoot(document.getElementById("root")!).render(
<Router routes={routes} />
);Routes without a loader receive params and url. Routes with a loader receive a discriminated union β narrow data via status ("loading", "ready", "error"). Use "*" as a catch-all for unmatched routes.
Wrap any navigable element in <Route> to get href, active, pending, and handler. For <a> tags, use href β the Navigation API intercepts the click natively. For <button> elements, attach handler as onClick to navigate via navigation.navigate(). Every <Route> whose href matches the navigation destination shows pending: true while a loader is running:
import { Route, useRouter } from "react-wayfinder";
const router = useRouter();
<Route href={router.url(urls.user, { id: 1 })}>
{route => (
<a href={route.href}>
User 1 {route.pending ? <Spinner /> : null}
</a>
)}
</Route>
<Route href={router.url(urls.user, { id: 1 })}>
{route => (
<button onClick={route.handler}>
User 1 {route.pending ? <Spinner /> : null}
</button>
)}
</Route>| Property | Type | Description |
|---|---|---|
href |
string |
The resolved URL string β use as href on <a> tags |
active |
boolean |
true if this href matches the currently rendered route |
pending |
boolean |
true while navigating to this href |
handler |
(event?) => void |
Attach as onClick on <button> elements β navigates via the Navigation API |
Every loader receives an AbortSignal via signal. The signal is aborted when:
- The user presses Escape during a pending navigation
- A new navigation supersedes the current one (clicking User 2 while User 1 is loading)
async loader({ params, signal }) {
const response = await fetch(`/api/users/${params.id}`, { signal });
return response.json();
}When cancelled, the router restores the previous route and URL β no stale state. Escape only fires when a loader is in-flight; pressing Escape after navigation completes does nothing.
Every loader receives cache β the previously loaded data for that route, or undefined on first visit. The router always calls the loader; you decide the caching strategy:
async loader({ params, signal, cache }) {
if (cache) return cache;
const response = await fetch(`/api/users/${params.id}`, { signal });
return response.json();
}Previously visited routes are preserved in the DOM using React <Activity> β their component state, scroll position, and form inputs survive navigation. The example app's /feed route demonstrates this: scroll down to load more items via the infinite loader, navigate away, then come back β your scroll position and every loaded item are still there.
The router automatically wraps route swaps in document.startViewTransition() when the browser supports it. It sets data-direction="forward" or data-direction="back" on <html> so you can style direction-aware animations with CSS:
:root {
--transition-duration: 250ms;
}
[data-direction="forward"]::view-transition-old(root) {
animation: slide-out-left var(--transition-duration) ease-in-out;
}
[data-direction="forward"]::view-transition-new(root) {
animation: slide-in-from-right var(--transition-duration) ease-in-out;
}
[data-direction="back"]::view-transition-old(root) {
animation: slide-out-right var(--transition-duration) ease-in-out;
}
[data-direction="back"]::view-transition-new(root) {
animation: slide-in-from-left var(--transition-duration) ease-in-out;
}Direction is detected via the Navigation API β "back" when traversing to a lower history index, "forward" otherwise. Cancel clears the data-direction attribute to prevent unwanted animations.
The mode prop controls how the router transitions between routes with loaders:
<Router routes={routes} mode="deferred" />| Mode | Behaviour |
|---|---|
"deferred" (default) |
Keeps the previous page on screen while the loader runs. Inline spinners via <Route> show on the clicked element. |
"immediate" |
Switches to the new route immediately with status: "loading" so you can render skeletons. Escape restores the previous route from the preserved <Activity>. |
When deploying to a sub-path (e.g. https://example.com/my-app/), pass base so the router strips the prefix before matching β route patterns stay root-relative. With Vite, use import.meta.env.BASE_URL to keep it in sync with your config:
<Router routes={routes} base={import.meta.env.BASE_URL} />Use useRouter() for navigation status and the base-aware URL builder:
const router = useRouter();
router.status
router.url(urls.user, { id: 42 })<Route> can be nested freely. A top-level navigation bar uses <Route> for each link, and the page it renders can nest its own <Route> instances for sub-navigation. Each <Route> independently tracks active and pending for its own href:
function Contact() {
const router = useRouter();
return (
<>
<nav>
<Route href={router.url(urls.home)}>
{route => <a href={route.href} className={route.active ? "active" : ""}>Home</a>}
</Route>
<Route href={router.url(urls.contact, { method: "email" })} active={path => path.startsWith("/contact")}>
{route => <a href={route.href} className={route.active ? "active" : ""}>Contact</a>}
</Route>
</nav>
<nav>
<Route href={router.url(urls.contact, { method: "email" })}>
{route => <a href={route.href} className={route.active ? "active" : ""}>Email</a>}
</Route>
<Route href={router.url(urls.contact, { method: "telephone" })}>
{route => <a href={route.href} className={route.active ? "active" : ""}>Telephone</a>}
</Route>
</nav>
</>
);
}The top-level "Contact" link uses a custom active predicate so it stays highlighted regardless of which sub-tab is selected. Each nested <Route> uses the default exact match.