npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nisli/router

v0.5.1

Published

Typed application routing for Nisli browser, Vite, and static applications.

Readme

@nisli/router

Typed application routing for Nisli. Define routes once and use the same route catalog, URL matcher, query codecs, and page renderers in the browser, the Vite development server, and static production builds.

Install

npm add @nisli/router

Requires @nisli/core@>=0.51.0 as a peer dependency. Releases publish through the repository's trusted-publishing CI (auto-tag.yml).

Define routes in 30 seconds

import { component, html } from '@nisli/core';
import { defineRouter, enumParam, route } from '@nisli/router';

const HomePage = component('home-page', () => html`<h1>Home</h1>`);
const UserPage = component<{ userId: string; tab: 'profile' | 'activity' }>(
  'user-page',
  (props) => html`
    <h1>User ${props.userId.value}</h1>
    <p>${props.tab.value}</p>
  `,
);

export const AppRouter = defineRouter({
  home: route('/', { render: async () => HomePage({}) }),
  user: route('/users/:userId', {
    query: { tab: enumParam(['profile', 'activity']).default('profile') },
    render: async ({ params, query }) => UserPage({
      userId: params.userId,
      tab: query.tab,
    }),
  }),
});

const App = component('my-app', () => html`${AppRouter({})}`);
html`${App({})}`.mount(document.body);

const href = AppRouter.routes.user.href({
  params: { userId: '42' },
  query: { tab: 'activity' },
});

// Native anchors remain the default navigation API.
html`<a href="${href}">Activity</a>`;

AppRouter({}) connects the injectable Router browser service and renders the current route. Defining AppRouter is lazy and DOM-free; the outlet registers on the first AppRouter({}) call, so Vite and SSG can consume the same route catalog without an extra provideRouter step. Route matching itself is pure and environment-neutral, and the literal path drives href() inference without extra generics.

Vite direct routes

The dev-only route fallback matches HTML requests with the application router before returning Vite's transformed shell. It composes with core HMR and does not add a second transform or hot-update protocol.

import { defineConfig } from 'vite';
import { nisliHmr } from '@nisli/core/vite-hmr';
import { nisliRoutes } from '@nisli/router/vite';
import { AppRouter } from './src/app-router.js';

export default defineConfig({
  plugins: [nisliHmr(), nisliRoutes(AppRouter)],
});

Static builds pass the same AppRouter to buildStaticSite({ router }) from @nisli/ssg; dynamic routes expand their typed entries() through the same href() and matcher used by browser and Vite navigation.

The package progressively enhances eligible same-origin anchors while preserving native external, modifier-key, target, download, hash-only, and opt-out navigation behavior. Same-origin URLs outside the connected matcher also remain native so server-owned documents and resources are not swallowed by the SPA outlet.

Scroll, focus, and history

Navigation effects run after the new route has rendered:

| Navigation | Scroll | Focus | | --- | --- | --- | | router.navigate(href) / intercepted link | Scrolls to the top by default; { scroll: 'preserve' } keeps the current position. | Moves focus to the route outlet with preventScroll (unless the URL has a hash). | | router.replace(href) | Preserves by default; { scroll: 'top' } opts into scrolling to the top. | Preserves focus. | | Back/forward (popstate) | Restores the remembered scroll position of the target history entry (the router sets history.scrollRestoration = 'manual'). | Preserves focus. | | Any routed URL with a hash | After rendering, finds the decoded fragment ID and calls scrollIntoView(). | Preserves focus. |

That last row is the cross-page hash contract: a client-side navigation such as /docs#install must wait for /docs to render, so the router emulates the fragment jump with scrollIntoView(). A same-document hash link is not intercepted at all when its pathname and query already match; the browser keeps its native fragment navigation and history behavior.

Initial direct loads render in place. If the initial URL has a hash, the same post-render fragment lookup is used; otherwise the router does not alter scroll or focus. navigate() writes with history.pushState(), replace() writes with history.replaceState(), and popstate renders without creating another history entry.

Accessibility: the main landmark

The outlet is the application's <main> landmark (role="main", tabindex="-1", focused on push navigation). Give it a stable id and/or aria-* via defineRouter's outletAttrs — for a skip link or a labelled main region:

const AppRouter = defineRouter(catalog, {
  outletAttrs: { id: 'main-content', 'aria-label': 'Main content' },
});

// html`<a href="#main-content" class="skip-link">Skip to content</a>`

Only id and aria-* are accepted (a type error otherwise); the managed role/tabindex are applied last and cannot be overridden.

SEO metadata

Route metadata is a declarative, typed contract applied by the SSG shell at build and by the browser service on navigation. title and meta are joined by property (OpenGraph <meta property>), canonical (<link rel="canonical">), and alternates (<link rel="alternate" hreflang>):

route('/:locale/posts/:slug', {
  params: { locale: enumParam(['en', 'ka']) },
  metadata: ({ params }) => ({
    title: `Post — ${params.locale}`,
    meta: { description: 'A localized post.' },
    property: { 'og:title': 'Post', 'og:type': 'article' },
    canonical: `https://example.com/${params.locale}/posts/${params.slug}`,
    alternates: [
      { hreflang: 'en', href: `https://example.com/en/posts/${params.slug}` },
      { hreflang: 'ka', href: `https://example.com/ka/posts/${params.slug}` },
    ],
  }),
});

The browser router owns the SEO tag types it manages — title, <meta name>, <meta property>, <link rel="canonical">, <link rel="alternate">, <html lang>/<html dir>, and keyed JSON-LD blocks. It marks each element data-nisli-managed (adopting a matching server-rendered tag rather than duplicating it), and on every navigation creates/updates the desired set and removes the ones a later route omits, so a canonical or og:* tag never lingers after a client navigation. title, lang, and dir reconcile the same way, falling back to the value present when the router connected. Head elements outside those types (charset, viewport, stylesheets, unmanaged scripts) are never touched.

route('/:locale/business', {
  params: { locale: enumParam(['en', 'ka']) },
  metadata: ({ params }) => ({
    lang: params.locale,                 // <html lang="ka">
    canonical: `https://example.com/${params.locale}/business`,
    // Keyed JSON-LD: set/update/removed by key across navigations.
    jsonLd: {
      business: { '@context': 'https://schema.org', '@type': 'LocalBusiness', name: 'Example' },
    },
  }),
});

Managed JSON-LD adopts a server-rendered application/ld+json block (whether or not it is pre-tagged) rather than duplicating it, then keeps it in sync and removes it when a later route drops the key. If a route render throws, the router atomically resets all managed head state so the previous route's tags cannot survive the error. An explicit navigation that produces a true no-match applies the same reset.

Typed path segments

Path parameters may declare per-segment codecs (the same codecs used for queries). An invalid segment is a matcher no-match — the URL falls through to the next route or notFound, not a render-time error:

route('/:locale', {
  params: { locale: enumParam(['en', 'ka']) },
  render: ({ params }) => Page({ locale: params.locale }), // 'en' | 'ka'
});
// "/en" and "/ka" match; "/fr" does not — it falls to notFound.

href() re-serializes typed segments through the codec, so a numberParam segment is constructed from a number and produces a string.

Client-side redirects

redirect() declares a route that resolves to another href with replace semantics — no history entry for the redirect source. The target is a fixed string or a function of the matched params:

export const AppRouter = defineRouter({
  user: route('/users/:id', { render: async ({ params }) => UserPage(params) }),
  legacyUser: redirect('/u/:id', ({ params }) => `/users/${params.id}`),
  legacyHome: redirect('/start', '/'),
});

This is the client half only. Server 301s and canonical HTML belong in your Worker/host; the router replaces the client history entry so back/forward does not return to the redirecting URL.

Active links (aria-current)

Router.isActive(href, { exact? }) reads the reactive url signal, so it re-evaluates in templates on each navigation:

const router = inject(Router);
html`<a href="/docs" aria-current="${router.isActive('/docs') ? 'page' : nothing}">Docs</a>`;

Pathname-prefix match by default; { exact: true } and the root path / require an exact match.

Lossless URL state (counterpart-locale links, attribution)

href() accepts open-ended search and hash alongside the typed params/query. Arbitrary query parameters — including ones not in the route's query schema — are merged under the declared query (declared wins), and the fragment is preserved. This is one builder for campaign-attribution passthrough and for a language switcher that rebuilds the same page in the other locale without dropping inquiry selection, attribution params, or the anchor:

const url = inject(Router).url.value; // current location

const counterpart = AppRouter.routes.page.href({
  params: { locale: 'ka' },     // swap the locale segment
  search: url.searchParams,     // preserve ?inquiry=…&utm_source=…
  hash: url.hash,               // preserve #section
});

html`<a href="${counterpart}" hreflang="ka">ქართული</a>`;

A declared query key set to its default value clears any carried-over copy, so declared parameters stay canonical while undeclared ones pass through.

Worker / edge usage (one catalog, no adapter)

The matcher, route builders, and query codecs are environment-neutral (they import @nisli/core for types only). Import them from the side-effect-free @nisli/router/catalog subpath and an edge Worker consumes the same authored catalog the browser does — for HTTP status, redirect targets, and the initial canonical/hreflang metadata — without ever loading the component runtime.

Author the identity once (render-less), then bind render targets on the client. This keeps a strict shared package free of any client reference — render is optional, so the catalog carries only paths, codecs, and metadata:

// routes.catalog.ts — shared, environment-neutral (no client import)
import { route, redirect, notFound, enumParam } from '@nisli/router/catalog';

export const catalog = {
  home: route('/', { metadata: { title: 'Home' } }),
  about: route('/:locale/about', {
    params: { locale: enumParam(['en', 'ka']) },
    metadata: ({ params }) => ({ lang: params.locale, canonical: `https://x.dev/${params.locale}/about` }),
  }),
  legacy: redirect('/old', '/'),
  notFound: notFound({ metadata: { title: 'Not found' } }),
};
// client.ts — browser: bind render targets (keyed, compile-time exhaustive)
import { defineRouter, bindRenders } from '@nisli/router';
import { catalog } from './routes.catalog.js';

export const AppRouter = defineRouter(bindRenders(catalog, {
  home:  () => import('./pages/home.js').then((m) => m.HomePage({})),
  about: ({ params }) => import('./pages/about.js').then((m) => m.AboutPage(params)),
  notFound: () => import('./pages/nf.js').then((m) => m.NotFound({})),
}));

bindRenders is exhaustive: a missing or extra route name is a type error, and each renderer's params/query carry their codec types. Route render may also be authored inline (single-package apps) — bindRenders is for the strict client/server/shared split where render targets must stay out of shared.

// worker.ts — edge (no @nisli/core runtime in the bundle)
import { createMatcher, normalizePathname } from '@nisli/router/catalog';
import { catalog } from './routes.catalog.js';

const match = createMatcher(catalog); // same flat catalog, no adapter

export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    // Canonicalization (e.g. trailing-slash / www) is a raw-path Worker concern,
    // done before matching; the client only ever generates canonical hrefs.
    if (url.pathname.length > 1 && url.pathname.endsWith('/')) {
      url.pathname = normalizePathname(url.pathname);
      return Response.redirect(url.toString(), 308);
    }

    const m = match(url);
    if (!m) return new Response('Not found', { status: 404 });
    if (m.redirect) return Response.redirect(new URL(m.redirect, url).toString(), 308);
    if (m.notFound) return new Response(shell(m.metadata), { status: 404, headers: html });
    return new Response(shell(m.metadata), { status: 200, headers: html }); // m.metadata.canonical, .alternates, …
  },
};

The router is HTTP-status-agnostic: client redirects use replaceState, and the Worker chooses its own status (e.g. 308 for root/legacy/trailing/www). The @nisli/router/catalog import graph is guarded to never reach the @nisli/core runtime, so this stays true across refactors. @nisli/core remains a peer dependency for types; import type is erased, so the Worker's runtime bundle is core-free.

See ADR 0026: Typed Application Router for the architecture, shared browser/Vite/SSG contract, and scope.