@k8ordo/router

Get Started

@k8ordo/router is a router for the pathname of the URL. You write one route table, and the matching, the typed links and the navigation all follow from it. This page builds a small application end to end: the table, mounting it in the browser, and checking links against it in the type system.

What it owns

This package owns one part of the URL: the pathname. Search params and history-entry state belong to @k8ordo/state, and the boundary between the two is the URL’s own ?.

Part of the URLExampleOwned by
Pathname/products/42@k8ordo/router
Search?sort=price@k8ordo/state
History-entry state(not in the URL)@k8ordo/state
Fragment#reviewsThe browser (on a page change, the router scrolls to it)

There is no useSearchParams, and the raw search string is never handed out. A component that reads one field of the search should re-render when that field changes and not otherwise, which is a job for keyed subscriptions, not for a router. @k8ordo/state

It does not fetch either. There is no loader, no route-level data API and no cache. Data belongs to the component that needs it — use() and <Suspense> in a client application, the server under the framework — and a router that owned fetching would be a second answer to a question React already answers.

Installation

In an application that renders in the browser, install it alongside React and React DOM. An application on @k8ordo/static or @k8ordo/server depends on this package directly as well.

npm install @k8ordo/router react react-dom

The peer dependencies are below. There are no runtime dependencies.

  • react >= 19.3.0
  • typescript >= 7.0.2 and @types/react >= 19.3.0 (both optional; needed for the shipped type declarations)
  • The Navigation API and URLPattern are the platform’s. Both have reached Baseline newly available, and no polyfill or fallback ships with the package.
  • It ships as ESM only.

Build a minimal application

Four steps: write the table, mount it, link from the pages, and check the links against the table.

1. The route table

Pass defineRoutes a table keyed by pathname patterns. A branch under / adds nothing to the URL, and its layout wraps every page. Matching takes the first pattern that fits in the order written, so /*, which fits anything, goes last.

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

import { Home } from './pages/home';
import { NotFound } from './pages/not-found';
import { ProductList } from './pages/product-list';
import { ProductPage } from './pages/product-page';
import { RootLayout } from './root-layout';

export const routes = defineRoutes({
  '/': {
    layout: RootLayout,
    children: {
      '/': Home,
      '/products': ProductList,
      '/products/:id': ProductPage,
      '/*': NotFound,
    },
  },
});

2. Mount it

Put <Router routes> at the root of the application, once. A layout renders what it wraps through <Outlet />. <Router> reads where the browser is when it mounts, so it is for an application that renders in the browser; to render on a server or at build time, use @k8ordo/static or @k8ordo/server.

// src/main.tsx
import { Router } from '@k8ordo/router';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

import { routes } from './routes';

const root = document.querySelector('#root');
if (root === null) {
  throw new Error('#root is missing');
}

createRoot(root).render(
  <StrictMode>
    <Router routes={routes} />
  </StrictMode>,
);
// src/root-layout.tsx
import { href, Outlet } from '@k8ordo/router';

export function RootLayout() {
  return (
    <>
      <nav>
        <a href={href('/')}>Home</a>
        <a href={href('/products')}>Products</a>
      </nav>
      <main>
        <Outlet />
      </main>
    </>
  );
}

3. Link from the pages

Pages never import the table. href, navigateTo and useParams all take the pattern as a string. Only <Router> holds the table’s value, so the cycle of a table importing pages that import the table cannot form.

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

const products = [
  { id: '1', name: 'Desk lamp' },
  { id: '2', name: 'Notebook' },
];

export function ProductList() {
  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          <a href={href('/products/:id', { id: product.id })}>
            {product.name}
          </a>
        </li>
      ))}
    </ul>
  );
}
// src/pages/product-page.tsx
import { href, navigateTo, useParams } from '@k8ordo/router';

export function ProductPage() {
  const { id } = useParams('/products/:id');
  return (
    <article>
      <h1>Product {id}</h1>
      <a href={href('/products')}>Back to the list</a>
      <button
        onClick={() => {
          navigateTo('/');
        }}
        type="button"
      >
        Home
      </button>
    </article>
  );
}

A link is a plain <a>. Under the Navigation API the router receives the navigate event the browser sends anyway, so the anchor is already a client navigation. The type useParams returns is inferred from the pattern string, so id is a string.

// src/pages/home.tsx
export function Home() {
  return <h1>Home</h1>;
}
// src/pages/not-found.tsx
import { usePathname } from '@k8ordo/router';

export function NotFound() {
  return <p>Nothing at {usePathname()}</p>;
}

4. Check links against the table

Params are already inferred from the pattern string, so forgetting :id fails to compile. To check the pattern itself against the real table, augment Register once.

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

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

Add the declaration file to include in tsconfig.json.

{
  "include": ["src", "types"]
}
import { href } from '@k8ordo/router';

href('/products/:id', { id: '1' });

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

// @ts-expect-error
href('/products/:id');

Now a pattern the table does not have is a type error too. Before the augmentation any string starting with / passes. Augment only in an application: a library that did it would impose its table on every consumer.

Next steps