Documentation menu

Routing

@gyral/router keeps routing inside the loop. The route is model state, derived from the URL; navigating is a command; and every URL change arrives as a message. The route table is pure, so the server matches requests with the same table.

npm install @gyral/router

Route tables

// src/routes.ts
import { routes } from '@gyral/router';

export const app = routes({
  home: '/',
  user: '/users/:id',
  settings: '/settings',
});
  • app.match('/users/7/') returns { name: 'user', params: { id: '7' }, path: '/users/7' }, or undefined. The result is a union discriminated by name, and params is typed from the pattern. path is the route's canonical path, href(name, params): see One URL per page.
  • app.href('user', { id: 'a b' }) builds /users/a%20b.
  • Patterns support literal segments, required :params, optional :params? and trailing :path* captures, with the same behavior in URLPattern and Gyral's fallback. One trailing slash is ignored, an empty segment (/users//7) never matches, params are decoded, and the first match in table order wins.
  • Matching never touches window, so it's safe on a server.

Optional segments and catch-all routes

// src/content-routes.ts
import { routes } from '@gyral/router';

export const contentRoutes = routes({
  product: '/products/:id?',
  docs: '/docs/:path*',
  notFound: '/:path*',
});

export const products = contentRoutes.href('product', {}); // /products
export const guide = contentRoutes.href('docs', { path: ['guide', 'a/b'] });
// /docs/guide/a%2Fb: an encoded slash stays in one captured segment.

An omitted optional parameter is absent from params. A catch-all always returns a readonly array, including [] when it captures no segments. Catch-all segments must be last, and the first matching route wins: put the not-found route last and explicitly return HTTP 404 from your server when it matches. Selecting that route alone does not change the response status.

href rejects missing required parameters, empty or dot segments, and ambiguous optional omissions. For /:first?/:second?, { second: 'x' } would match as { first: 'x' }; use a literal separator instead. Bare wildcards, regexes and groups are outside the route grammar.

Listening to navigation

// src/shell.ts
import { define, focus, html, type Head } from '@gyral/core';
import { listen, setHead, type RouteLocation, type RouteMatch } from '@gyral/router';
import { app } from './routes.js';

const ORIGIN = 'https://example.com';

type Route = RouteMatch<typeof app.table> | undefined;

export interface State {
  readonly route: Route;
}

export type Msg = { readonly _tag: 'Routed'; readonly location: RouteLocation };

/** One pure function names every page, so the server can render the same <title>. */
export const pageTitle = (route: Route): string =>
  route === undefined
    ? 'Not found'
    : route.name === 'user'
      ? `User ${route.params.id}`
      : route.name;

/** The page's head, used by the server's renderPage() and by setHead() after a navigation. */
export const pageHead = (route: Route): Head => ({
  title: `${pageTitle(route)} · Example`,
  description: 'Users and settings.',
  ...(route === undefined
    ? { robots: 'noindex' }
    : { canonical: new URL(route.path, ORIGIN).href }),
});

export const Shell = define<State, Msg>()('my-shell', {
  // The page itself, so its content is light DOM and it captures same-origin link clicks.
  shadow: false,
  init: () => ({ route: undefined }),
  // Every location, for as long as the shell is on the page.
  subscriptions: () => [listen((location): Msg => ({ _tag: 'Routed', location }))],
  update: {
    Routed: (_s, m) => {
      const route = app.match(m.location.href);
      const head = setHead(pageHead(route));
      // After a navigation (not on the page load), move focus to the new page's heading.
      return [{ route }, m.location.seq === 0 ? [head] : [head, focus('main h1')]];
    },
  },
  view: (s) => html`
    <nav aria-label="Main">
      <a href=${app.href('home', {})}>Home</a>
      <a href=${app.href('user', { id: '7' })}>User 7</a>
      <a href=${app.href('settings', {})}>Settings</a>
    </nav>
    <main><h1 tabindex="-1">${pageTitle(s.route)}</h1></main>
  `,
});

listen(toMsg) is a long-running command, listed in the component's subscriptions. It delivers the current location at once, then every change (link clicks, navigate(), back and forward), while the component is on the page. Link capture is the router's own option: the app's entry provides the router once, provideDrivers(document.body, { router: makeRouter({ captureLinks: true }) }). Each location carries a seq, which counts the URL changes the router has seen: 0 is the URL the page loaded with. The view is a pure function of the route in state.

Navigation is a command, returned from a reducer like any other:

Command Does
navigate(url, options?) Push a history entry (replace: true replaces it; scroll, focusReset: see below)
setHead(head) Make the document's head match a Head (see below)

For a Back or Forward button, send the router a traversal: command(router, { _tag: 'Traverse', delta: -1 }), with router from @gyral/router.

// src/save.ts
import type { Command } from '@gyral/core';
import { navigate } from '@gyral/router';
import { app } from './routes.js';

/** After saving, go to the user's page without adding a history entry. */
export const afterSave = (id: string): Command<never> =>
  navigate(app.href('user', { id }), { replace: true });

The router uses the Navigation API where the browser has it and the History API elsewhere, so both behave the same. Browsers with the Navigation API never download the History API code.

Scroll and focus

After a navigation, the router does what the browser does for a page load, once the new page has rendered:

  • Scroll: to the #fragment target, or to the top, after a push. Back and forward restore the position the page was left at.
  • Focus: to the first [autofocus] element, or back to the start of the page, so the next Tab starts at the top of the new page instead of on a link that's gone.

A replace leaves both alone, because apps replace the entry to keep the URL in step with what the user is doing (Updating the query); pass scroll: true or focusReset: true to opt in. Both happen with or without the Navigation API. The browser looks for a fragment's target in the document only, so render targets in light DOM: page-level components with shadow: false, like the shell above.

Focus at the start of the page tells a screen reader user little about where they landed. Move it to the new page's heading instead, as the shell does: focus('main h1') from the Routed reducer, with tabindex="-1" on the heading so it can take focus. Skip it for the first location (location.seq === 0), which is the page load, not a navigation. Focus that your app moves during a navigation wins: the router doesn't reset it. If the heading appears only after data loads, focus it from the message that brings the data.

To manage scroll or focus yourself, turn either off for one navigation, with navigate(url, { scroll: false, focusReset: false }), or for every navigation the router starts, link clicks and back and forward included, with makeRouter({ scroll: false, focusReset: false }). Memory history ignores both.

Updating the query

A search box or a set of filters often keeps its state in the query string, so the URL can be shared and survives a reload. Update the query with navigate too, not with history.replaceState, which the router never sees: its location, and every listen(), would be one step behind. Replace the entry, so each keystroke doesn't add one. A replace leaves scroll and focus where they are:

// src/product-filters.ts
import { define, html } from '@gyral/core';
import { listen, navigate, type RouteLocation } from '@gyral/router';

export interface State {
  readonly query: string;
}

export type Msg =
  | { readonly _tag: 'Routed'; readonly location: RouteLocation }
  | { readonly _tag: 'Typed'; readonly query: string };

export const ProductFilters = define<State, Msg>()('my-product-filters', {
  init: () => ({ query: '' }),
  subscriptions: () => [listen((location): Msg => ({ _tag: 'Routed', location }))],
  intent: { Typed: ({ value }) => ({ _tag: 'Typed', query: value ?? '' }) },
  update: {
    // The URL is the source of truth: a shared link or back and forward set the box.
    Routed: (_s, m) => ({ query: new URLSearchParams(m.location.search).get('q') ?? '' }),
    Typed: (_s, m) => [
      { query: m.query },
      [navigate(`?q=${encodeURIComponent(m.query)}`, { replace: true })],
    ],
  },
  view: (s, i) => html`
    <search>
      <label for="q">Filter products</label>
      <input id="q" type="search" value=${s.query} data-intent=${i.Typed} />
    </search>
  `,
});

The page stays where it is and focus stays in the box while the user types. A relative URL such as ?q=… keeps the current path.

Links stay plain <a href> elements. They work before JavaScript loads, open in new tabs, and are what crawlers follow.

Link capture is opt-in: makeRouter({ captureLinks: true }) turns same-origin clicks into in-app navigations, and only while some component is listening. That's right for an app that owns the whole page. In a multi-page, server-rendered site where only one component routes, leave it off, so ordinary links keep loading pages.

Captured clicks skip modified clicks (new tab), target other than _self, download, rel="external", other origins and same-page #hash links. Anchors inside shadow roots work. linkRoot: element limits capture to one part of the page.

The head

A page's head is one value, a Head from @gyral/core: title, description, canonical, robots, meta (Open Graph and other name/property tags), links (alternates, icons), jsonLd, lang and dir. Compute it with a pure function, pageHead above, and use it twice:

  • On the server, spread it into renderPage(), which writes each entry once, right after <title>, marked data-gyral-head.
  • In the browser, return setHead(pageHead(route)) from the Routed reducer. It changes only the elements it manages: entries the new head drops are removed, and head markup you wrote yourself is left alone. The first Routed after hydration finds the server's head and changes nothing.

So a client navigation leaves exactly the head a page load would, and the two can't disagree. Titles that depend on data, such as a product's name, come from state the same way. The page owns its head: components don't add to it, because the server writes the head before any component renders.

  • canonical is absolute: build it from match().path and your origin (One URL per page).
  • Stylesheets, preloads and the charset aren't head entries. They belong to renderPage()'s own options (stylesheets, modulepreload, headScripts) or extraHead, because changing them on navigation would unstyle the page or refetch modules. Development builds reject them in a Head.
  • JSON-LD is data, not script, so script-src doesn't apply to it. Under an enforced Trusted Types policy, setHead leaves JSON-LD as the server wrote it and warns once.

Server-side routes

On the server, match the request with the same table, choose the status, and render:

// server/app.ts
import { Hono } from 'hono';
import { html } from '@gyral/core';
import { renderPage } from '@gyral/ssr';
import { app as routes } from '../src/routes.js';
import { pageHead } from '../src/shell.js';

export const server = new Hono();

server.get('*', (c) => {
  const url = new URL(c.req.url);
  const route = routes.match(url);
  // One URL per page: /users/7/ redirects to /users/7.
  if (route && route.path !== url.pathname) {
    return Response.redirect(new URL(route.path + url.search, url), 301);
  }
  return renderPage(
    {
      ...pageHead(route),
      body: html`<my-shell></my-shell>`,
      scripts: ['/src/entry-client.ts'],
    },
    { status: route === undefined ? 404 : 200 },
  );
});

One URL per page

The router ignores a trailing slash, so /users/7 and /users/7/ match the same route. A page that answers at two URLs splits its links and its search ranking, and caches store it twice. match() returns the route's canonical path (also the canonical spelling of percent-escapes), so the server redirects every other spelling to it, as above, and keeps the query string. path is a fixed point: matching it gives the same route and the same path.

The first render

The server doesn't run listen() (commands never run on the server), so the shell above is server-rendered with no route and gets one only after hydration, when listen() delivers the URL. Pass the request path as a prop and start from it in init if the first render must show the route, as Gyral's isomorphic example does: then listen() delivers the same URL and nothing changes.

Memory history

makeRouter({ history: 'memory', initial: '/settings' }) keeps history in memory: push, replace, back and forward work, and the real URL and title are never touched. Use it in tests, and on servers. driver.snapshot() returns the router's { href, head, length } for assertions, head being the last setHead() value.