Routing

Client-side navigation with dynamic params, nested layouts, middleware, and view transitions.

Basic Setup

Define your routes and render the Router component. Each route maps a URL path to a component:

import { mount } from 'what-framework';
import { Router, defineRoutes } from 'what-framework/router';

import Home from './pages/Home';
import About from './pages/About';
import NotFound from './pages/NotFound';

const routes = defineRoutes({
  '/': Home,
  '/about': About,
});

mount(
  <Router routes={routes} fallback={NotFound} />,
  '#app'
);

defineRoutes accepts a plain object where keys are URL paths and values are components. The fallback prop renders when no route matches (404).

Use Link for client-side navigation. It intercepts clicks, updates the URL via history.pushState, and re-renders the router without a full page reload:

import { Link } from 'what-framework/router';

function Nav() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/about">About</Link>
      <Link href="/blog">Blog</Link>
    </nav>
  );
}

Link automatically adds an active class when the current path is the href or one of its sub-paths (/ is the exception: it is active only on /). When the path and the href are identical it also adds exact-active:

// Customize active class names
<Link href="/blog" activeClass="nav-active" exactActiveClass="nav-exact">
  Blog
</Link>

// Replace history entry instead of pushing
<Link href="/login" replace>Sign In</Link>

Programmatic Navigation

Use navigate() to change routes from event handlers or effects:

import { navigate } from 'what-framework/router';

function LoginForm() {
  async function handleSubmit(e) {
    e.preventDefault();
    await login(email, password);
    navigate('/dashboard');
  }

  return <form onSubmit={handleSubmit}>...</form>;
}

// Options
navigate('/settings', { replace: true });   // Replace history entry
navigate('/profile', { state: { from: 'nav' } }); // Pass state
navigate('/page', { transition: false });     // Skip view transition

The route Object

Access the current route state from anywhere in your app using the reactive route object:

import { route } from 'what-framework/router';

// All properties are reactive getters
route.path     // "/users/42"
route.params   // { id: "42" }
route.query    // { tab: "posts" }
route.hash     // "#section"
route.isNavigating  // true during navigation

No Parentheses Needed

route.path, route.params, etc. are property getters that internally read signals. You access them like normal properties, with no () call needed. They are still reactive and will trigger re-renders when they change.

route.query is a null-prototype object, so a crafted query string cannot reach Object.prototype. Reading, destructuring, spreading, Object.keys() and JSON.stringify() all behave normally, but inherited members are gone: use 'tab' in route.query or Object.prototype.hasOwnProperty.call(route.query, 'tab') rather than route.query.hasOwnProperty('tab'). A repeated key collects into an array, so ?tag=a&tag=b gives { tag: ["a", "b"] }.

Dynamic Params

Use :param syntax in route paths to capture dynamic segments. The matched values are passed to your component as params:

const routes = defineRoutes({
  '/': Home,
  '/users/:id': UserProfile,
  '/posts/:slug': BlogPost,
  '/files/*': FileViewer,          // Catch-all: params.rest
  '/docs/[...path]': DocsPage,     // Named catch-all
});

function UserProfile({ params }) {
  return <h1>User #{params.id}</h1>;
}

function DocsPage({ params }) {
  return <p>Path: {params.path}</p>;  // "guides/getting-started"
}

File-based syntax like [id] and [...path] also works: it's converted to :id and catch-all internally.

Nested Layouts

Wrap groups of routes in shared layouts. The layout component receives the matched route's element as children:

import { Link, nestedRoutes } from 'what-framework/router';

function DashboardLayout({ children }) {
  return (
    <div class="dashboard">
      <aside>
        <Link href="/dashboard">Overview</Link>
        <Link href="/dashboard/settings">Settings</Link>
      </aside>
      <main>{children}</main>
    </div>
  );
}

const routes = [
  { path: '/', component: Home },
  ...nestedRoutes('/dashboard', [
    { path: '/', component: DashboardHome },
    { path: '/settings', component: Settings },
    { path: '/users/:id', component: UserDetail },
  ], { layout: DashboardLayout }),
];

Route Guards & Middleware

Protect routes with middleware functions that run before the route renders. Middleware can allow, reject, or redirect:

import { guard } from 'what-framework/router';

// Sync guard: redirect if not authenticated
const requireAuth = guard(
  () => isLoggedIn(),
  '/login'           // Redirect path on failure
);

const ProtectedDashboard = requireAuth(Dashboard);

// Or use middleware in route config
const routes = defineRoutes({
  '/dashboard': {
    component: Dashboard,
    middleware: [
      ({ path, params }) => {
        if (!isLoggedIn()) return '/login'; // Redirect
        if (!isAdmin()) return false;      // Block
        return true;                       // Allow
      }
    ],
  },
});

The two shapes are not interchangeable. guard() and asyncGuard() wrap a component and hand back a new one, so their result belongs in component:. Entries in a middleware array are predicates called with the match context: return true to allow, a path string to redirect, or false to block. Blocking renders the Router's fallback component when one is set (the same component your 404s use) and a built-in 403 screen when it is not, so redirect to a dedicated route if you need to tell the two apart.

For async checks (like verifying a session with the server), use asyncGuard:

import { asyncGuard } from 'what-framework/router';

const requireSession = asyncGuard(
  async () => {
    const res = await fetch('/api/session');
    return res.ok;
  },
  { fallback: '/login', loading: Spinner }
);

// asyncGuard returns a wrapper, so apply it to the component
const ProtectedSettings = requireSession(Settings);

Route Groups

Apply one layout and one middleware list to a set of routes. routeGroup never touches the paths, so write each URL out in full; the group name is a label for your own code:

import { routeGroup } from 'what-framework/router';

const routes = [
  ...routeGroup('marketing', [
    { path: '/', component: Home },
    { path: '/about', component: About },
    { path: '/pricing', component: Pricing },
  ], { layout: MarketingLayout }),

  ...routeGroup('app', [
    { path: '/dashboard', component: Dashboard },
    { path: '/settings', component: Settings },
  ], { layout: AppLayout, middleware: [() => isLoggedIn() || '/login'] }),
];

Loading & Error States

Specify per-route loading and error components:

const routes = defineRoutes({
  '/dashboard': {
    component: Dashboard,
    loading: DashboardSkeleton,  // Shown while a navigation is pending
    error: DashboardError,       // Shown on render error
  },
});

The loading component renders while route.isNavigating is true. asyncGuard never sets that flag, so a route it protects shows the guard's own loading option while the check is pending, never the route's. When both the route being left and the route being entered declare a loading component, the destination's wins.

The error component is wrapped in an ErrorBoundary that catches exceptions during rendering. It is called with { error, reset }, and reset re-runs the failed subtree:

function DashboardError({ error, reset }) {
  return (
    <div class="route-error">
      <p>{error.message}</p>
      <button onClick={reset}>Retry</button>
    </div>
  );
}

View Transitions

What Router uses the browser's View Transitions API by default for smooth page transitions. No configuration needed, just add CSS:

/* Fade in/out by default */
::view-transition-old(root) {
  animation: fade-out 150ms ease-out;
}
::view-transition-new(root) {
  animation: fade-in 150ms ease-in;
}

@keyframes fade-out { to { opacity: 0; } }
@keyframes fade-in { from { opacity: 0; } }

Name specific elements for independent transitions:

import { viewTransitionName } from 'what-framework/router';

function Card({ id, title }) {
  return (
    <div {...viewTransitionName(`card-${id}`)}>
      <h2>{title}</h2>
    </div>
  );
}

Scroll Restoration

The router already saves the scroll position on every navigation and restores it on back/forward, with no setup. enableScrollRestoration() applies three rules to ordinary forward navigations as well, checked in this order:

import { enableScrollRestoration } from 'what-framework/router';

enableScrollRestoration();
// 1. Hash links scroll to the target element
// 2. Returning to a URL you already scrolled restores that position
// 3. Everything else scrolls to top

The order matters. A URL carrying a hash always goes to its element, even when a position is saved for that exact URL, so clicking the same #install link twice lands on the section both times. Positions are keyed by the full URL, path and query and hash together, so /list?tab=inbox and /list?tab=archive each remember their own place.

Rule 2 is the one that surprises people: a nav-bar link back to a page you scrolled earlier in the session lands where you left it rather than at the top, and switching back to a tab you have already opened does the same. Saved positions live in memory for the life of the page, so a reload starts clean and the browser's own history.scrollRestoration takes over.

Prefetching

Link components prefetch on hover by default. You can also prefetch programmatically:

import { prefetch } from 'what-framework/router';

// Appends <link rel="prefetch"> for this URL
prefetch('/dashboard');

// Disable prefetch on a specific Link
<Link href="/heavy-page" prefetch={false}>Heavy Page</Link>

This is a hint to the browser to fetch that URL's document early. It does not pull in a lazy() route's JavaScript chunk, and each URL is prefetched at most once per page load.

useRoute Hook

Access reactive route state inside any component with the useRoute hook. Returns computed signals for each property:

import { useRoute } from 'what-framework/router';

function Breadcrumbs() {
  const { path, params, query, navigate } = useRoute();

  return (
    <nav class="breadcrumbs">
      <span>You are at: {path()}</span>
    </nav>
  );
}

route vs useRoute

The global route object uses property getters (route.path) while useRoute() returns computed signals (path()). Both are reactive. Use route for quick access, useRoute when you want to destructure or pass around individual properties.