Skip to content

Wildhoney/Wayfinder

Repository files navigation

react-wayfinder

build

Strongly-typed React router built on the Navigation API. No outlets, no nesting β€” just routes, data, and a URL builder.

react-wayfinder demo

Table of Contents

  1. Getting Started
  2. Navigation State
  3. Programmatic Navigation
  4. Redirects
  5. Cancellation
  6. Caching
  7. View Transitions
  8. Router Modes
  9. Nested Routes

Getting Started

Install react-wayfinder using your preferred package manager:

yarn add react-wayfinder

Define 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, type Routes } from "react-wayfinder";

const routes = [
  route({
    url: urls.home,
    match() {
      return <h1>Home</h1>;
    },
  }),
  route({
    url: urls.user,
    async data({ params, signal }) {
      return fetchUser(params.id, { signal });
    },
    match({ status, params, data, error }) {
      switch (status) {
        case "loading": return <p>Loading&hellip;</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 data function receive params, url, and router. Routes with a data function additionally receive a discriminated union β€” narrow data via status ("loading", "ready", "error"). Use "*" as a catch-all for unmatched routes. The router argument is the same handle returned by useRouter() β€” useful when you need type-safe URL building or programmatic navigation outside of a hook context.

Navigation State

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 route's data function 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

Pass replace to <Route> to replace the current history entry instead of pushing a new one. This works for both <a> clicks and handler invocations:

<Route href={router.url(urls.login)} replace>
  {route => <a href={route.href}>Sign in</a>}
</Route>

Programmatic Navigation

useRouter() returns a navigate(href, options?) function for navigating outside of a <Route>. Pair it with the url() builder to keep URLs type-safe:

const router = useRouter();

router.navigate(router.url(urls.user, { id: 1 }));               // push
router.navigate(router.url(urls.login), { replace: true });      // replace

The same router handle is passed to every route's match and redirect callback β€” so you can navigate type-safely from places where hooks aren't available.

Redirects

A route with a redirect prop replaces the current history entry with the resolved target instead of rendering anything. Use it as a catch-all or to canonicalise an incomplete URL:

const routes = [
  route({
    url: urls.cat,
    match({ params }) {
      return <Viewer index={Number(params.index)} />;
    },
  }),
  route({
    url: "*",
    redirect: ({ router }) => router.url(urls.cat, { index: 0 }),
  }),
] satisfies Routes;

redirect accepts either a string or a callback receiving { params, url, router }. The callback form gives you access to the type-safe router.url() builder so you don't have to hard-code paths. Redirects always replace the current history entry β€” the browser back button skips past the redirected-from URL.

Cancellation

Every data function 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 data({ 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 data function is in-flight; pressing Escape after navigation completes does nothing.

Caching

Every data function receives cache β€” the previously loaded data for that route, or undefined on first visit. The router always calls data; you decide the caching strategy:

async data({ 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-scroll data function, navigate away, then come back β€” your scroll position and every loaded item are still there.

View Transitions

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.

Router Modes

The mode prop controls how the router transitions between routes that fetch data:

<Router routes={routes} mode="deferred" />
Mode Behaviour
"deferred" (default) Keeps the previous page on screen while the data function 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 })

Nested Routes

<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.

About

πŸ•οΈ Strongly-typed React router built on the Navigation API. No outlets, no nesting β€” just routes, loaders, and a URL builder.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages