@k8ordo/router

Links & location

Pages never import the table. Links, navigation and questions about where the browser is are all written with the pattern string alone. This page covers href and navigateTo, bindParams for params every link shares, Register where their types come from, and the hooks that read the location.

Building a path with href

href(pattern, params?) builds a concrete path from a pattern and its params. It uses no table, so any component can call it. Params are inferred from the pattern string, and a pattern without params takes no second argument.

// src/product-links.tsx
import { href } from '@k8ordo/router';

export function ProductLinks({ id }: { id: string }) {
  return (
    <nav>
      <a href={href('/products')}>All products</a>
      <a href={href('/products/:id', { id })}>This product</a>
    </nav>
  );
}

Where Register carries no param types — an application that mounts <Router>, or a pattern no schema covers — a value may be a string, number, bigint or boolean (the ParamValue type): each has exactly one spelling. It is encoded with encodeURIComponent, so a/b becomes a%2Fb, and matching decodes it again on the way out, as a string.

CallResult
href('/products')'/products'
href('/products/:id', { id: 42 })'/products/42'
href('/products/:id', { id: 'a/b' })'/products/a%2Fb'
href('/:locale/products', { locale: 'en' })'/en/products'

The return type keeps the path’s shape — a template literal type with any string where each :param was — so a typed-path consumer such as @k8ordo/state accepts it as it is.

import { href } from '@k8ordo/router';

const path: `/products/${string}` = href('/products/:id', { id: '42' });

Called around the types, href refuses at run time as well, with a TypeError:

  • A pattern with a wildcard"/:locale/*" is a wildcard — it has no href
  • A param without a value"/products/:id" needs a value for ":id"
  • A value with no URL spelling, such as an object"/products/:id" got a value for ":id" that has no URL spelling

Why there is no <Link>

Under the Navigation API a plain <a> is already a client navigation: the browser sends a navigate event for the click, and the router intercepts it. A component wrapping the anchor would add a second way to write the same thing, so the type check lives in href instead.

A link to a pathname the table does not answer is not intercepted: it is an ordinary document load. With a /* at the end of the table, though, the table answers every pathname, so mark a link to a file the host serves with download: the browser then says it is a download at the click, and the router leaves it alone.

Going somewhere with navigateTo

navigateTo(pattern, params?, options?) goes to the path href would build, through navigation.navigate(), and returns the platform’s own { committed, finished }.

import { navigateTo } from '@k8ordo/router';

navigateTo('/products/:id', { id: '42' });
navigateTo('/products/:id', { id: '42' }, { history: 'replace' });
navigateTo('/products');
navigateTo('/products', { history: 'replace' });

The one option, history in NavigateToOptions, is 'push' (the default) or 'replace'. For a pattern without params, the options are the second argument.

The default is push because going to a page is what the back button should undo. @k8ordo/state’s update() defaults the other way for the same reason: refining what is on the page is not something the back button should step through. Changing pages goes through navigateTo; changing state goes through update. @k8ordo/state

finished resolves once the new page is on screen. Await it inside an async action, and isPending shows that a navigation is under way:

// src/open-product-button.tsx
import { navigateTo } from '@k8ordo/router';
import { useTransition } from 'react';

const isAbort = (error: unknown) =>
  error instanceof DOMException && error.name === 'AbortError';

export function OpenProductButton({ id }: { id: string }) {
  const [isPending, startTransition] = useTransition();
  return (
    <button
      disabled={isPending}
      onClick={() => {
        startTransition(async () => {
          try {
            await navigateTo('/products/:id', { id }).finished;
          } catch (error) {
            if (!isAbort(error)) throw error;
          }
        });
      }}
      type="button"
    >
      {isPending ? 'Opening…' : 'Open'}
    </button>
  );
}

A page change never joins the action, so awaiting finished inside @k8ordo/ui’s Button onAction or a <form action> settles as soon as the page is on screen. A page change that starts while some unrelated async action is pending reaches the screen without waiting for it. An event handler can await it just the same.

When another navigation overtakes this one, finished rejects with the abort reason, a DOMException named AbortError. Code that awaits it where it can be overtaken ignores the abort and rethrows anything else, as the example above does. Navigation

Checking patterns with Register

Param inference works with no setup. To check the pattern itself against the application’s real table, declare routes on Register once, in the application.

// types/k8ordo-router.d.ts
import type { routes } from '../src/routes';

declare module '@k8ordo/router' {
  interface Register {
    routes: typeof routes;
  }
}

Once declared, the patterns href, navigateTo, useParams, useMatch and matchPath accept are the table’s. Before it, any string starting with / passes.

import { href, matchPath } from '@k8ordo/router';

href('/products/:id', { id: '42' });
matchPath('/products/*', '/products/42');

// @ts-expect-error
href('/prodcuts/:id', { id: '42' });

// @ts-expect-error
matchPath('/about/*', '/about/team');

Under @k8ordo/static and @k8ordo/server this declaration is generated into .k8ordo/register.gen.ts; writing it by hand there is a second answer to a question already answered. Under the framework

TypeMeaning
RegisteredPatternEvery leaf pattern in the registered table — each page and each /*, never a prefix with no page of its own; before the declaration, any string starting with /
RegisteredNavigablePatternThe linkable patterns, wildcards excluded; before the declaration, any string starting with /
RegisteredParams<P>The params a link takes. On a pattern a schema covers, a param the schema typed takes that type and the rest take a string, as the page receives them; on a pattern no schema covers, every param takes a ParamValue
ParamsOf<P>A pattern’s params: ParamsOf<'/:locale/products/:id'> is { locale: string; id: string }
PathFor<P>The path type a pattern stands for: a template literal type with any string where each :param was
ParamValueA value with one URL spelling: string | number | bigint | boolean

Binding shared params with bindParams

A segment every link would otherwise repeat — a locale, a tenant — is supplied by a function instead. bindParams(source) returns an href and a navigateTo that fill in the params source returns. This site’s own src/links.ts is exactly that:

// src/links.ts
import { bindParams } from '@k8ordo/router';

import { locales } from './i18n';

export const { href, navigateTo } = bindParams(() => ({
  locale: locales.getLocale(),
}));

apps/docs/src/links.ts

Patterns keep their full /:locale/… spelling, so the table’s types apply unchanged. A bound param may be left out, or given to override the source.

import { href, navigateTo } from './links';

href('/:locale/products/:id', { id: '42' });
href('/:locale/products/:id', { locale: 'en', id: '42' });
navigateTo('/:locale', { locale: 'en' }, { history: 'replace' });
navigateTo('/:locale/products', undefined, { history: 'replace' });

// @ts-expect-error
navigateTo('/:locale/products', { history: 'replace' });

source is read at every call, so a value that differs per request or per URL is read where it is current. Which package supplies the value is the application’s business; the router knows a param name and nothing more.

Even when every param of a pattern is bound, navigateTo’s options are the third argument, not the second: params and options are both plain objects, and only whether the pattern names a param decides which is which. Pass undefined in the params slot, then the options. Options in second place are a type error.

The return type is BoundLinks<Bound>, and what source returns is a BoundParams — a read-only record of ParamValues.

useParams and useRoute

A component under <Router> reads the current route’s params from context. useParams(pattern) returns them typed by the pattern string. The values are always strings.

// src/pages/product-page.tsx
import { useParams } from '@k8ordo/router';

export function ProductPage() {
  const { id } = useParams('/products/:id');
  return <h1>Product {id}</h1>;
}

The pattern given to useParams is a claim — this component renders under this pattern — and it is checked at run time: rendered under any other pattern, it throws instead of silently returning params of the wrong shape.

useParams("/products/:id") rendered under "/products"

useRoute() is the untyped form: the winning pattern and its params. Use it in a component shared by several routes that needs to tell which one it is under. It throws when there is no match to read.

// src/breadcrumb.tsx
import { href, useRoute } from '@k8ordo/router';

export function Breadcrumb() {
  const { pattern, params } = useRoute();
  if (pattern !== '/products/:id') {
    return null;
  }
  return (
    <nav aria-label="Breadcrumb">
      <a href={href('/products')}>Products</a> / {params['id']}
    </nav>
  );
}
useRoute must render inside a matched <Router>

Under @k8ordo/static and @k8ordo/server neither works — the browser holds no table — and a page receives params as a prop. Under the framework

Reading the location with usePathname

usePathname() returns the pathname the browser is on, with the trailing slash dropped. It reads the platform rather than the table, so it works the same in an application that mounts <Router> and under the framework.

It re-renders when the pathname changes and never on the search. Leaving the search out is deliberate: a component re-rendering on every search change would defeat @k8ordo/state’s keyed subscriptions, and the split at the ? is the boundary between the two packages.

usePathname changes when the URL changes, not when the new page appears. Interception commits the URL first and the tree arrives once it has loaded, so on a slow navigation a link marks itself active while the previous page is still on screen — the same order as the browser’s own address bar. If the wait needs showing, await navigateTo’s finished, as above.

The value is the URL’s own spelling and is not decoded: characters outside ASCII come back percent-encoded.

During a server render and hydration there is no Navigation API to read, so the renderer supplies the pathname through <PathnameProvider>. <Router> and both mode runtimes mount it themselves; an application never writes it.

Asking about active links with useMatch and matchPath

Whether a link is active is a question you ask, not a prop. useMatch(pattern, options?) returns the params when the current pathname fits the pattern, and null when it does not.

// src/products-nav-link.tsx
import { href, useMatch } from '@k8ordo/router';

export function ProductsNavLink() {
  const onIndex = useMatch('/products') !== null;
  const inSection = useMatch('/products/*', { inclusive: true }) !== null;
  return (
    <a
      aria-current={onIndex ? 'page' : inSection ? 'true' : undefined}
      href={href('/products')}
    >
      Products
    </a>
  );
}

The pattern is one from the table, or a table pattern followed by /* to mean “anywhere below it” (the MatchablePattern type). The pattern’s own page is not below it: /products/* matches /products/42 and not /products. A section link that should be active on the index as much as below it passes { inclusive: true } (the MatchOptions type), which changes nothing for a pattern that does not end in /*.

CallResult
matchPath('/products/:id', '/products/42'){ id: '42' }
matchPath('/products/*', '/products/42'){}
matchPath('/products/*', '/products')null
matchPath('/products/*', '/products', { inclusive: true }){}
matchPath('/:locale/ui/*', '/ja/ui/components/button'){ locale: 'ja' }

matchPath(pattern, pathname, options?) is the same test as a pure function, for a pathname you have in hand. useMatch is built on usePathname, so it re-renders on the pathname only and needs no table — this site’s own sidebar asks useMatch whether a page under /ui/components is showing.

Try matchPath

Edit the pattern and the pathname, and the real matchPath and normalizePathname answer as you type. It starts with the pattern for this page’s section, /:locale/router/*, and the pathname you are on.

Examples
Call
matchPath('/:locale/router/*', '/en/router/links')
normalizePathname
/en/router/links
Result
{"locale":"en"}

normalizePathname and the trailing slash

URLPattern treats /products and /products/ as different pathnames; the router does not. normalizePathname(pathname) is that rule on its own: every trailing slash is dropped, and the root / is kept. Table matching, matchPath, usePathname and <PathnameProvider> all bring a pathname to this form before comparing.

InputOutput
/products//products
/products////products
//
////
//products//products
/caf%C3%A9//caf%C3%A9

It drops trailing slashes and nothing else: repeated slashes in the middle, percent-encoding, a search or a fragment are left as they are. Use it in code that compares pathnames the way the table does.

Sharing typed paths with @k8ordo/state

@k8ordo/state’s Register takes the same line this router does. Declare the same table on both, and @k8ordo/state’s links are checked against the table this router matches against — the two packages agree on what a path is.

// types/k8ordo.d.ts
import type { routes } from '../src/routes';

declare module '@k8ordo/router' {
  interface Register {
    routes: typeof routes;
  }
}

declare module '@k8ordo/state' {
  interface Register {
    routes: typeof routes;
  }
}

RouteOf<typeof routes> is the table’s linkable pathname space as a union. @k8ordo/state derives its paths through it, and any other typed-path consumer can take the same type. @k8ordo/state

import type { RouteOf } from '@k8ordo/router';

import type { routes } from './routes';

type AppPath = RouteOf<typeof routes>;