Why Wayfinder?
Managing navigation by hand means scattered popstate listeners, duplicated path checks, and no shared abstraction for loading data or blocking navigation. Wayfinder moves all of that into one declarative table.
ts
// Before — manual navigation with popstate
window.addEventListener('popstate', () => {
const path = window.location.pathname;
if (path === '/') renderHome();
else if (path.startsWith('/dashboard')) renderDashboard();
else renderNotFound();
});
document.querySelectorAll('a[data-route]').forEach((a) => {
a.addEventListener('click', (e) => {
e.preventDefault();
history.pushState({}, '', (e.currentTarget as HTMLAnchorElement).href);
dispatchEvent(new PopStateEvent('popstate'));
});
});
// After — with Wayfinder
import { createRouter } from '@vielzeug/wayfinder';
const router = createRouter({
routes: {
home: { path: '/' },
dashboard: { path: '/dashboard' },
},
notFound: { component: NotFoundPage },
});
router.subscribe((state) => {
render(state.matches.at(-1)?.component);
});Use Wayfinder when you need named navigation, route-level data loading with cancellation, middleware, or leave guards in a framework-agnostic setup.
Consider a framework's built-in router when you are deep in a single framework ecosystem (React Router, Vue Router) and want first-class component binding with no adapter layer.
| Feature | Wayfinder | page.js | Navigo |
|---|---|---|---|
| Bundle size | 5.8 KB | ~1 kB | ~5 kB |
| History mode | |||
| Memory history (tests / non-browser) | |||
| Typed path params | |||
| Named navigation | Partial | ||
| Middleware | |||
| Data loaders with AbortSignal | |||
| Lazy route loading | |||
| Declarative redirects | |||
| Search param validation | |||
| Error in state | |||
| History state in context | |||
| Leave guards | |||
Hover prefetching (preload()) | |||
| Scroll restoration | |||
| View Transition API | |||
| Zero dependencies |
Installation
sh
pnpm add @vielzeug/wayfindersh
npm install @vielzeug/wayfindersh
yarn add @vielzeug/wayfinderQuick Start
ts
import { createRouter } from '@vielzeug/wayfinder';
const router = createRouter({
routes: {
home: { path: '/' },
dashboard: {
path: '/dashboard',
children: {
index: { index: true },
settings: {
path: 'settings',
data: async () => fetchSettings(),
},
},
},
},
notFound: { component: NotFoundPage },
});
await router.navigate({ name: 'dashboard.settings' });Features
- One declarative route table with nested names (
dashboard.settings) - Named and raw-path navigation through one
navigate()API - Lazy-load route modules on first navigation
- Middleware for guards, analytics, and error boundaries
- Route
data()loaders withAbortSignalcancellation and async-generator streaming - Per-match
statusfor granular loading/streaming feedback in nested layouts - Global
beforeLeaveleave guards with optional route scoping - Typed and coercible search params via
coerceSearch - Per-route
onErrorboundaries for degraded data states - Declarative
notFoundfallback in router options - Hover-prefetch via
router.preload() - Branch resolve without navigation via
router.resolve() - SSR data prefetch via
router.match(url) - Scroll restoration via the
scrolloption - History entry state readable as
ctx.historyState - Errors from data loaders exposed on
router.getSnapshot().error router.waitFor(name)for lifecycle coordination and testing- Memory history for tests and non-browser environments
- Wildcard routes for catch-all cases
- Base-path support for app subdirectories
- View Transition API with per-navigation override
- Debug logging via
debugRouter()(@vielzeug/wayfinder/devtools) — logs every navigation phase change with[wayfinder:nav]prefixes; tree-shaken from production bundles