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

@luwiostack/router

v0.1.1

Published

Locale-aware route registry for TanStack Router — one file per route, translated URL segments per locale.

Readme

@luwiostack/router

Locale-aware routing for TanStack Router. Define one route per file, give each a translated URL segment per locale, register it, and expand the whole registry into a real TanStack route tree — one localized sub-tree per locale.

Part of LuwioStack — standalone, but pairs with @luwiostack/locale.

/en-BE/about        /nl-BE/over-ons        /fr-BE/pour-nous

Runnable example: apps/showcase — a small localized app that uses this router (localized routes, a layout route, a locale switcher) alongside the other @luwiostack packages. pnpm --filter @luwiostack/showcase dev.

Why

TanStack's file-based routing keys each path to a filename, so about.tsx can only ever produce the segment about. Real multilingual sites need /nl-BE/over-ons, /fr-BE/pour-nous, … This package keeps every TanStack feature (loader, beforeLoad, validateSearch, layout routes, …) and adds locale-translated segments on top, driven by config at runtime.

Install

npm install @luwiostack/router @luwiostack/locale

@luwiostack/locale is a direct dependency of the router, so npm install @luwiostack/router alone already pulls it into node_modules (which in turn pulls in @luwiostack/country and @luwiostack/language). Install it explicitly anyway — as shown above — because your app imports @luwiostack/locale directly (Locale, ILocale, useLocale), and a package you import should be a declared dependency rather than a transitive one that could change or disappear when the router bumps its range.

react (18+) is the only peer dependency. @tanstack/react-router is bundled in — you don't install or import it directly. This package vendors it and surfaces only the structural bindings you need — RouterProvider, Outlet, HeadContent — re-exported from @luwiostack/router itself:

import { RouterProvider, Outlet } from '@luwiostack/router'

Everything else goes through the router object (useRouter().router, or context.router in a beforeLoad / loader): URL generation (href / path / absolute), navigation (navigate), and locale-aware redirect / notFound. There is no <Link>; render <a href={router.href(...)}> and call router.navigate(...) from your own onClick.

Usage

Define a route and export it — createRoute takes every TanStack option plus an id; add a slug per locale with .alias. No manual registration: the file just exports the route, and the tooling (below) collects it — the same shape as TanStack's export const Route = ….

// routes/about.route.tsx
import { createRoute } from '@luwiostack/router'
import { Locale } from '@luwiostack/locale'
import { About } from './About'

export default createRoute({
  id: 'about',
  // title() + description() are REQUIRED on every URL route (SEO — see below).
  title: ({ context }) => context.t('about.title'),
  description: ({ context }) => context.t('about.desc'),
  beforeLoad: ({ context }) => ({ crumb: 'About' }), // context.locale is the active ILocale
  loader: ({ context }) => fetchTeam(context.locale.country_code),
  component: About,
})
  .alias(Locale.new({ languageOrLocale: 'en-BE' }), 'about')
  .alias(Locale.new({ languageOrLocale: 'nl-BE' }), 'over-ons')
  .alias(Locale.new({ languageOrLocale: 'fr-BE' }), 'pour-nous')

The index route. Alias a route to the empty string to mount it at the locale root (/en-BE) rather than a named segment. Every URL-bearing route needs at least one alias — createRouter throws on a route defined without one (only layout routes are exempt) — so the empty alias is how you deliberately make a route the index:

export default createRoute({
  id: 'home',
  title: ({ context }) => context.t('home.title'),
  description: ({ context }) => context.t('home.desc'),
  component: Home,
})
  .alias(Locale.new({ languageOrLocale: 'en-BE' }), '') // → /en-BE
  .alias(Locale.new({ languageOrLocale: 'nl-BE' }), '') // → /nl-BE

Dynamic path params

An alias is a full TanStack path pattern, so a $-prefixed segment is a dynamic param and a bare $ is a splat (catch-all). It can be multi-segment, so the static parts and the param live in one alias — and both may differ per locale, while the param name stays the same:

export default createRoute<{ postId: string }>({ // generic types params in beforeLoad/loader/head
  id: 'blog.post',
  title: ({ context }) => context.t('post.title'),       // required on every URL route (SEO)
  description: ({ context }) => context.t('post.desc'),
  loader: ({ params, context }) => fetchPost(context.locale, params.postId),
  component: Post,
})
  .alias(Locale.new({ languageOrLocale: 'en-BE' }), 'blog/$postId') // /en-BE/blog/$postId
  .alias(Locale.new({ languageOrLocale: 'nl-BE' }), 'post/$postId') // /nl-BE/post/$postId

// a splat — matches the rest of the path into params['*'] (title/description omitted for brevity):
createRoute({ id: 'docs.catchAll', title, description }).alias(enBE, 'docs/$') // /en-BE/docs/*

(You can still split the hierarchy across routes with parent when a real layout/guard sits at the static prefix — see layout routes. A multi-segment alias is the shortcut when the prefix is just a URL prefix.)

Read them in a component with useRouteParams() (path params) and useRouteQuery() (the query string). Both are thin wrappers over TanStack Router's useParams / useSearch, pinned to the current route (strict: false), so no from id is needed — pass a type argument, or let the route's validateSearch shape the query:

import { createRoute, useRouteParams, useRouteQuery } from '@luwiostack/router'

function Post() {
  const { postId } = useRouteParams<{ postId: string }>()   // from the /blog/$postId segment
  const { page = 1 } = useRouteQuery<{ page?: number }>()   // from ?page=2 (typed by validateSearch)

  return <Article id={postId} page={page} />
}

// Define the query shape once on the route; useRouteQuery reads it back.
createRoute<{ postId: string }, { page: number }>({
  id: 'blog.post',
  validateSearch: (s): { page: number } => ({ page: Number(s.page ?? 1) }),
  // … + required title/description
})

These read the active route. To read another route's params/query type-safely, call TanStack's useParams / useSearch with a from route id directly.

Watch just one value. Pass a select function to subscribe to a single param or query value — the component re-renders only when that value changes, not on every params/search update. It's the same select as TanStack's useParams / useSearch, so structural sharing skips no-op re-renders:

// re-renders only when postId changes, even if the query string or other params change:
const postId = useRouteParams((p) => p.postId)

// select AND map one query value (parse ?page=2 → 2):
const page = useRouteQuery((s) => Number(s.page ?? 1))

// type it explicitly when params/search aren't inferred:
const id = useRouteParams((p: { postId: string }) => p.postId)

Build a localized href or navigate by id + params — the router fills each $segment from params (the splat reads params['*']) and throws if a required one is missing, so you never ship a half-built URL:

const { router } = useRouter()
router.href({ id: 'blog.post', params: { postId: '42' } })              // '/en-BE/blog/42'
router.href({ id: 'blog.post', params: { postId: '42' }, locale: 'nl-BE' }) // '/nl-BE/blog/42'
router.navigate({ id: 'blog.post', params: { postId: '42' }, query: { page: 2 } })

params / query are loosely typed by default; augment RouteRegister once to make href / navigate require the right params and type the query per id — see Type safety.

Expand the registry into a router once, at boot:

// router.ts
import 'virtual:@luwiostack/router/routes' // every *.route file is now registered (Vite plugin, below)
import { createRouter, routeRegistry } from '@luwiostack/router'
import { Locale } from '@luwiostack/locale'

export const router = createRouter(routeRegistry, {
  locales: ['en-BE', 'nl-BE', 'fr-BE'].map((l) => Locale.new({ languageOrLocale: l })),
  defaultLocale: Locale.new({ languageOrLocale: 'en-BE' }),
})
import { RouterProvider } from '@luwiostack/router' // re-exported; no @tanstack/react-router install

<RouterProvider router={router} />

Auto-registration

Route files only export their routes — something has to import those files and collect the exports into the registry. Two ways:

Recommended — the Vite plugin. Add it once; it scans your routes directory and registers every exported route through a virtual module, so app code stays free of globs. This is how TanStack Router's own plugin works.

// vite.config.ts
import { luwioRouter } from '@luwiostack/router/vite'

export default defineConfig({
  plugins: [luwioRouter()], // defaults to scanning src/routes/**/*.route.{ts,tsx}
})
// src/vite-env.d.ts — types for the virtual module
/// <reference types="@luwiostack/router/vite-client" />

Then import 'virtual:@luwiostack/router/routes' once (shown above). Adding or removing a route file reloads automatically. Options: dir, suffix, virtualId.

Or a plain glob with registerModules, if you'd rather not add a plugin:

import { registerModules } from '@luwiostack/router'

registerModules(import.meta.glob('./routes/**/*.route.tsx', { eager: true }))

Either way: without one of these, the registry comes up empty — the files are never collected.

The locale in context

Before each route's own beforeLoad runs, createRouter injects { routeId, locale } into the route context, where locale is the full ILocale — so loader and beforeLoad can read context.locale.language(), .country(), .country_code, etc. The locale layout also wraps the tree in @luwiostack/locale's provider, so useLocale() works in every component.

The active locale & a language switcher

createRouter mounts one sub-tree per locale and seeds each route's context with its { routeId, locale } (above). useRouter() reads that back reactively from the active match, so router.locale is always the locale of the page you're on — /nl-BE/… resolves to nl-BE. It's the same value as useRouteLocale() and a route's context.locale, and it falls back to defaultLocale before the first match resolves, so it's never undefined. Every router.href / navigate without an explicit locale uses it — you never thread the current locale through yourself.

const { router } = useRouter()
router.locale        // the active ILocale
router.locale.code   // 'nl-BE'
router.routeId       // the active route id

A language switcher is therefore just navigation to the same route id in another locale — the id is stable, so the URL translates itself (/en-BE/blog/42 → /nl-BE/post/42). Walk router.locales, disable the ones the current page has no alias for (availableLocales), and carry params across so dynamic routes keep their values:

import { useRouteParams, useRouter } from '@luwiostack/router'

function LanguageSwitcher() {
  const { router } = useRouter()
  const activeId = router.routeId ?? 'home'
  const params = useRouteParams()                                 // carry params across the switch
  const available = new Set(router.availableLocales(activeId).map((l) => l.code))

  return (
    <nav aria-label="Language">
      {router.locales.map((l) => (
        <button
          key={l.code}
          type="button"
          disabled={!available.has(l.code)}                       // no alias on this page → not switchable
          aria-current={l.code === router.locale.code}            // router.locale = the active locale
          onClick={() => router.navigate({ id: activeId, locale: l, params })}
        >
          {l.language().machine_name}                                     {/* 'Dutch', 'English', … */}
        </button>
      ))}
    </nav>
  )
}

Route options

createRoute(config) takes this package's own three keys plus TanStack Router's route options. The common ones are typed and documented below (with params/search/locale wired in); any other TanStack route option is accepted and forwarded untouched.

| Option | Type | What it does | | ------ | ---- | ------------ | | id * | string | Stable, unique id. Wires parent and identifies the route for navigation. | | parent | string | Parent route id. Omit for a top-level route. | | layout | boolean | Pathless route — contributes no URL segment but wraps its children (guards, shells). Takes no aliases. | | beforeLoad | (ctx) => unknown | Runs before load; its return merges into context for this route + descendants. ctx.context.locale is the active locale. | | loader | (ctx) => unknown | Loads data before render (useRouteLoaderData()). | | loaderDeps | ({ search }) => object | The search slice the loader depends on, so it re-runs when those change. | | validateSearch | (search) => T | Parse/validate the query string into typed search. | | title / description | (ctx) => string | Required for URL routes (SEO). The router emits them (plus canonical, hreflang, og) into the head. ctx.context.locale is the active locale. See below. | | head | (ctx) => RouteHead | Optional extra head tags beyond the SEO essentials the router already emits. See below. | | component | component | The page (or a layout shell around <Outlet />). | | pendingComponent | component | Shown while the loader is pending. | | errorComponent | component | Shown when the route (or a child) throws. | | notFoundComponent | component | Shown when notFound() is thrown beneath it. | | staleTime / gcTime | number (ms) | Loader-data freshness / cache lifetime. | | shouldReload | boolean \| (ctx) => boolean | Whether the loader re-runs on navigation. | | caseSensitive | boolean | Case-sensitive path matching. | | wrapInSuspense | boolean | Wrap the component in <Suspense>. | | …anything else | | Forwarded to TanStack's createRoute untouched. |

* required. path and getParentRoute are not accepted — the URL segment comes from .alias(locale, slug) per locale, and the parent from parent. Type params / search with the generics: createRoute<{ postId: string }, { page: number }>({ … }).

SEO & document head

Every URL route must declare title() and description() — SEO essentials, enforced (createRouter throws if one is missing; layout routes are exempt). Both receive the head ctx, so context.locale (and anything an ancestor put on context) is available. The router is translation-agnostic — it knows nothing about @luwiostack/translations or any i18n library. You translate inside title()/description() yourself, typically via a context.t a layout's beforeLoad puts on context (return { t }).

From those two functions plus its own locale knowledge, the router auto-emits the full i18n SEO head — <title>, <meta name="description">, <link rel="canonical">, one <link rel="alternate" hreflang> per locale the route is aliased in (+ x-default), and og:title/og:description/og:locale/og:url. Canonical/hreflang need an origin — the origin config option, defaulting to window.location.origin (set it for SSR). head() stays optional, for anything bespoke on top (JSON-LD, extra og/twitter, preloads).

import { createRoute } from '@luwiostack/router'

export default createRoute({
  id: 'about',
  title: ({ context }) => context.t('about.title'),
  description: ({ context }) => context.t('about.desc'),
  head: ({ context }) => ({ meta: [{ property: 'og:type', content: 'website' }] }), // optional
})
  .alias(Locale.new({ languageOrLocale: 'en-BE' }), 'about')

Mount <HeadContent /> once, in your root or app-shell layout's head:

import { HeadContent, Outlet } from '@luwiostack/router'

const AppShell = () => (
  <>
    <HeadContent /> {/* writes the merged title/meta/link tags */}
    <Outlet />
  </>
)

TanStack merges head across the matched routes (the deepest route wins for title and each meta name/property), and the router emits canonical/hreflang from the leaf only, so links never duplicate. Use <Scripts /> (also re-exported) for body scripts under SSR.

Don't put a static <title> in index.html. <HeadContent /> renders its own, so a static one leaves the page with two title tags. The router sets a title on every route — the HTML shell needs none.

Loading states & skeletons

Two patterns, both driven by the route's loader — the layout and shell stay mounted throughout, so either one renders inside your chrome.

1. A route skeleton — pendingComponent. Navigation awaits the loader; if it runs longer than pendingMs, the skeleton takes the route's <Outlet /> slot. pendingMinMs keeps it visible long enough not to flicker. The skeleton can read useRouteLocale(), so even the loading state is localized.

createRoute({
  id: 'explore',
  title, description,                // required on every URL route (SEO) — see below
  loader: ({ context }) => fetchThings(context.locale.country_code),
  component: Explore,
  pendingComponent: ExploreSkeleton, // shown once the loader passes pendingMs
  pendingMs: 200,                    // start sooner than the ~1s default
  pendingMinMs: 300,                 // ...and stay ≥300ms, so it can't flash
})

Router-wide defaults go through the router passthrough: createRouter(reg, { …, router: { defaultPendingComponent, defaultPendingMs: 200 } }).

2. Render through, defer the slow part. Return a promise from the loader instead of awaiting it — the component renders immediately and only the deferred piece shows a fallback. Consume it with <Await> (re-exported here) or React 19's use():

createRoute({
  id: 'post',
  title, description,                           // required on every URL route (SEO)
  loader: ({ params, context }) => ({
    post: fetchPost(context.locale, params.id), // fast — awaited
    comments: fetchComments(params.id),         // slow — return the PROMISE, don't await
  }),
  component: Post,
})
import { Await, useRouteLoaderData } from '@luwiostack/router'

function Post() {
  const { post, comments } = useRouteLoaderData()
  return (
    <>
      <Article post={post} />
      <Await promise={comments} fallback={<CommentsSkeleton />}>
        {(list) => <Comments items={list} />}
      </Await>
    </>
  )
}

Await, useAwaited and defer are re-exported from @luwiostack/router — no @tanstack/react-router import. To skip skeletons entirely, preload on intent: createRouter(reg, { …, router: { defaultPreload: 'intent' } }).

Advanced — layout routes

Set layout: true to create a pathless route: it contributes no URL segment, but still wraps its children. Use it for auth guards (beforeLoad) and shared UI shells (component). Layout routes take no aliases.

// routes/auth.route.tsx — pathless guard, invisible in the URL
export default createRoute({
  id: 'auth',
  layout: true,
  beforeLoad: ({ context, location }) => {
    const session = getSession()
    if (!session) {
      // context.router is locale-aware — redirect by route id (same shape as navigate), no path building.
      throw context.router.redirect({ id: 'login', query: { redirect: location.href } })
    }
    return { session } // flows into every child's context
  },
})

context.router is a locale-bound router (the same surface as useRouter().router) injected into every route's context, so a guard resolves the active locale's translated /login for you and can also throw context.router.notFound().

// routes/app.route.tsx — pathless layout, shared chrome
export default createRoute({
  id: 'app',
  parent: 'auth', // sits inside the guard
  layout: true,
  component: AppShell, // renders sidebar/nav around <Outlet />
})
// routes/account.route.tsx — real page under auth + layout
export default createRoute({
  id: 'account',
  parent: 'app',
  title: ({ context }) => context.t('account.title'),       // required on every URL route (SEO)
  description: ({ context }) => context.t('account.desc'),
  loader: ({ context }) => fetchAccount(context.session, context.locale.country_code),
  component: Account,
})
  .alias(Locale.new({ languageOrLocale: 'en-BE' }), 'account')
  .alias(Locale.new({ languageOrLocale: 'fr-BE' }), 'compte')

Resulting tree — auth and app never appear in the URL, but run on every request beneath them:

/en-BE
  └─ auth   [pathless]  session guard        → context.session
       └─ app   [pathless]  AppShell (chrome)
            └─ account       /account         → context.locale + context.session

| Locale | URL | | ------- | ---------------- | | en-BE | /en-BE/account | | fr-BE | /fr-BE/compte |

Config-driven availability

createRouter's config decides what actually mounts, so one build can serve different route/locale sets per deployment, market, or feature flag:

createRouter(routeRegistry, {
  locales: [/* … */],           // required
  defaultLocale,                // required
  unprefixedDefault: true,      // also mount the default locale without a /{locale} prefix
  exclude: ['blog'],            // drop a route and all its descendants
  excludeStatus: 'forbidden',   // excluded paths 403 (→ statusPages[403]) instead of 404; default 'notFound'
  origin: 'https://ex.com',     // for the canonical + hreflang URLs the router emits (SSR); else window.origin
  statusPages: {                // required — the router's generic 404/403/5xx handling, by HTTP status
    404: 'notFound',            // best practice, but optional — falls back to `default` if omitted
    403: 'signin',              // ditto
    503: 'maintenance',         // any other status: a dedicated page for a beforeLoad/loader failure
    default: 'serverError',     // required — the catch-all for everything else
  },
  redirects: { '/nl/*': '/nl-BE/*' }, // legacy-URL 301s (old bookmarks) — see below
  router: { /* … */ },          // extra options forwarded to TanStack's createRouter
})

Not found, forbidden & error (404 / 403 / 5xx)

Handle all three in one map, keyed by HTTP status, as redirects to real routes: statusPages. Only default is required — it's the guaranteed catch-all, so the router always has somewhere to send a failure. 404 and 403 are best practice (a dedicated not-found / forbidden page), not requirements: omit either and it falls back to default too.

  • 404 — a URL matching no route redirects to statusPages[404] in the active locale (/nl-BE/typo → /nl-BE/404). The same target also catches an explicit throw notFound() from a matched route (e.g. a loader that couldn't find its resource) — every kind of "not found" ends up on the same real, translated, linkable 404 page. Define it as a normal route.
  • 403 — a guard throws context.router.forbidden(), a locale-aware redirect to statusPages[403]. The target lives in config, so the guard never repeats it; pass query to carry a return URL.
  • Any other status (503, 429, …) — a dedicated page for a beforeLoad/loader failure whose thrown value carries that status (an HttpError from @luwiostack/http, or anything else shaped like one: { status: 503, … }).
  • 5xx generically — a route's beforeLoad or loader (its data lifecycle, which runs before the component ever renders) throws something that isn't a deliberate redirect() (how forbidden()'s 403 works too) or notFound(): a bug, a failed request, anything unexpected. The router catches it generically — the route never needs its own try/catch, the same way an unmatched URL generically 404s — logs it (console.error, so it still reaches an error tracker) and redirects to the matching statusPages entry (or default, when the status has no dedicated one, or there's no status at all). That resolved status also becomes the redirect's own HTTP status code (falling back to 500 when there is none).

Every status page can read {@link useRouteFailure} for what actually sent it there:

import { useRouteFailure } from '@luwiostack/router'

function Maintenance() {
  const failure = useRouteFailure() // { status, error? } — undefined if opened directly (a bookmark)
  return (
    <>
      <h1>{failure?.status ?? 503} — The website is in maintenance</h1>
      {failure?.error && <p>Details: {failure.error.message}</p>}
    </>
  )
}

error is only present for a beforeLoad/loader failure — a 404/403 carries status only, since there's no exception behind either.

This is scoped to the data lifecycle only. An error thrown by the route's own component during render is a UI bug, not a load failure, and is never touched by statusPages — it's left entirely to the implementor: that route's own errorComponent, rootErrorComponent, or TanStack's own default. The two failure modes are handled completely differently on purpose: a load failure means the page has nothing to show, so redirecting to a real error page makes sense; a render bug is the app's own code breaking, which redirecting away would just hide from you.

// the 404 route — aliased in every locale it should catch
createRoute({
  id: 'notFound',
  parent: 'shell',
  component: NotFound,
  title: ({ context }) => context.t('notfound.title'), // required on every URL route (SEO)
  description: ({ context }) => context.t('notfound.desc'),
})
  .alias(Locale.new({ languageOrLocale: 'en-BE' }), '404')
  .alias(Locale.new({ languageOrLocale: 'nl-BE' }), '404')

// a guard — 403 → sign-in, carrying the return URL
beforeLoad: ({ context, location }) => {
  if (!getSession()) {
    throw context.router.forbidden({ query: { redirect: (location as { href: string }).href } })
  }
}

// the 500 route — just like the 404 route, a normal aliased page
createRoute({
  id: 'serverError',
  parent: 'shell',
  component: ServerError,
  title: ({ context }) => context.t('error.title'), // required on every URL route (SEO)
  description: ({ context }) => context.t('error.desc'),
})
  .alias(Locale.new({ languageOrLocale: 'en-BE' }), '500')
  .alias(Locale.new({ languageOrLocale: 'nl-BE' }), '500')

There's deliberately no "render inline instead" escape hatch for 404 — statusPages[404] (falling back to default) always redirects, the same way forbidden() always does for 403. rootErrorComponent is unrelated to statusPages: it's TanStack's ordinary top-level boundary for a RENDER error, a separate failure mode statusPages never touches — see above.

Excluded routes: 404 vs 403

By default an excluded route is dropped outright, so its URL is indistinguishable from a typo — it 404s. Set excludeStatus: 'forbidden' to instead keep the excluded route's path mounted as a guard that redirects to statusPages[403] with a 403. Use it when a route is disabled by config or a feature flag but should read as "exists, but not available right now" rather than "never existed" — the path (and everything under it) 403s instead of 404ing.

createRouter(routeRegistry, {
  locales, defaultLocale,
  exclude: ['beta-feature'],
  excludeStatus: 'forbidden',                  // /en-BE/beta-feature (+ any sub-path) → 403
  statusPages: { 403: 'signin', default: 'serverError' }, // the 403 page must be registered
})

The two axes stay distinct on purpose:

  • An excluded route lives inside a mounted locale sub-tree, so there's a path to guard → excludeStatus: 'forbidden' makes it 403 (only in the locales it's aliased in; a locale it was never aliased in still 404s, since that URL never existed there).
  • A missing locale has no /{code} sub-tree at all, so /{code}/anything is always a 404 regardless of excludeStatus — there is nothing to forbid. /nl-BE/page when nl-BE isn't in locales 404s even though page is aliased.

excludeStatus: 'forbidden' requires its resolved 403 page (statusPages[403], or default if 403 has no dedicated entry) to be a registered route, else the build throws. router.has(id) still reports an excluded route as not mounted — a 403 guard is not something you link to.

Legacy redirects

When URLs change (renamed locale codes, a moved page), old bookmarks and indexed links shouldn't 404. redirects is a map of permanent (301) redirects from old paths to new, resolved before the not-found catch-all:

  • Wildcard — a pattern ending in /* captures the rest of the path and appends it to the target's /*: '/nl/*': '/nl-BE/*' sends /nl/over-ons → /nl-BE/over-ons. Query and hash are preserved, and the bare prefix redirects too (/nl → /nl-BE).
  • Exact — a pattern with no * redirects that one path: '/pricing': '/plans'.
createRouter(routeRegistry, {
  locales, defaultLocale,
  redirects: {
    '/nl/*': '/nl-BE/*',   // wildcard, keeps the tail + query + hash
    '/pricing': '/plans',  // exact
  },
})
// /nl/over-ons?x=1#a → /nl-BE/over-ons?x=1#a   (301)

A pattern must not collide with a real mounted path — old locale codes differ from the new ones, so they don't. It's plain path-to-path rewriting; the router knows nothing about the old scheme beyond this map.

Every route is mounted by default — exclude is the only route filter: it drops the listed ids together with all of their descendants. So one build can serve different route sets per deployment, market, or feature flag just by changing exclude.

A route mounts in exactly the locales it's aliased in. A URL-bearing route must have at least one alias — one defined with no .alias() at all is rejected at build (only layout routes may omit it). Beyond that, coverage is up to you: alias a route in every locale and it's everywhere; alias it in a subset and it deliberately exists only there. There's no "strict" flag — partial is simply how aliases work, and a locale a route isn't aliased in just doesn't mount it.

Is a route available?

Because exclude and partial aliasing can both leave a route unavailable, ask the router before you link to it — router.has(id) is true only when the id is mounted (survived exclude), and router.has(id, locale) also requires it be available in that locale:

const { router } = useRouter()

router.has('blog')                 // false — excluded from this build
router.has('about')                // true
router.has('about', 'nl-BE')       // true only if aliased for nl-BE
router.availableLocales('about')   // the locales it resolves in ([] if excluded)

Prefixing the default locale

By default every locale — including the default — is mounted under its /{locale} prefix, and / redirects to the default locale's tree. Set unprefixedDefault: true to also serve the default locale without a prefix, so both resolve:

nl-BE is the default:
  /home        ✓   (unprefixed — the canonical URL for the default locale)
  /nl-BE/home  ✓   (prefixed — still resolves)
  /fr-BE/home  ✓   (non-default locales are always prefixed)

On an unprefixed route the active locale is the default locale — useRouter().router.locale, useRouteLocale(), and context.locale all report it. And URL generation strips the prefix for the default locale: router.href({ id: 'home' }) returns /home, not /nl-BE/home — so navigate and every generated link stay unprefixed while you're in the default locale.

API

| Export | Description | | ----------------------------------- | -------------------------------------------------------------------------------------- | | createRoute(config) | Define a route; returns a chainable RouteBuilder. config.id is required. Export it. See Route options. | | RouteBuilder | .alias(locale, slug), .slugFor(locale), .hasAlias(locale). | | registerModules(modules, reg?) | Register exported routes from a glob record or module array. Defaults to routeRegistry.| | routeRegistry | The shared RouteRegistry singleton. | | RouteRegistry | Registry class — .all(), .get(), .clear(), low-level .add(). For isolated sets. | | createRouter(registry, config) | Expand a registry into a TanStack Router. | | useRouter() | { router } — href/path/absolute/navigate/redirect/notFound/forbidden, plus locale, routeId, locales, has, availableLocales. | | router.redirect / .notFound / .forbidden | Locale-aware, throwable results for beforeLoad / loader — redirect by route id, notFound, or forbidden (→ statusPages[403]). Also on context.router. | | useRouteFailure() | { status, error? } \| undefined — the failure that redirected to the current statusPages page. See Not found, forbidden & error. | | RouterProvider / Outlet | Re-exported from the bundled TanStack Router. Mount the tree / render children. | | HeadContent / Scripts | Re-exported from the bundled TanStack Router. Render a route's head() tags / body scripts. | | Await / useAwaited / defer | Re-exported from the bundled TanStack Router. Stream deferred loader data (skeletons). | | luwioRouter(options?) | The Vite plugin, from @luwiostack/router/vite. | | RouteConfig / RouterConfig | Config types. createRoute<TParams, TSearch> types the handler ctx. | | StatusPages | The statusPages config type — Record<number, string> & { default: string }. | | RouteRegister | Augment to map id → { params?; search? } for typed href / navigate (see below). |

Type safety

Because the tree is assembled at runtime, TanStack can't infer literal to strings from a static route tree. This package recovers the type safety that matters most in two opt-in layers:

1. Typed handler context (no setup). createRoute is generic over a route's params / search, so beforeLoad and loader get typed callbacks — and context.locale is always typed:

createRoute<{ postId: string }, { page: number }>({
  id: 'blog.post',
  title, description,                        // required on every URL route (SEO)
  loader: ({ params, search, context }) => {
    fetchPost(params.postId, search.page)   // params.postId: string, search.page: number
    return context.locale.country_code       // context.locale: ILocale — always typed
  },
})

2. Typed ids + params for href / navigate (augment once). Augment RouteRegister to map each id to its params / search. href, path, absolute and navigate then check the id, require the right params, type query, and reject unknown ids — while unaugmented apps keep the loose string id behavior:

declare module '@luwiostack/router' {
  interface RouteRegister {
    about: {}
    'blog.post': { params: { postId: string }; search: { page?: number } }
  }
}

router.href({ id: 'blog.post', params: { postId: '42' } }) // ✓
router.href({ id: 'blog.post' })                           // ✗ params required
router.navigate({ id: 'aboot' })                           // ✗ unknown route id

This package deliberately renders no link component: it only generates URLs (href / path / absolute) and navigates imperatively (navigate). Render a plain anchor with the generated href and route in-app on a plain left click — the onClick is yours to write; the router doesn't own it. Guard for modified clicks so middle / ⌘ / Ctrl / Shift / Alt still open a new tab, copy the link, etc.:

import { useRouter } from '@luwiostack/router'

const { router } = useRouter()

<a
  href={router.href({ id: 'about' })}
  onClick={(e) => {
    if (e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
      e.preventDefault()
      router.navigate({ id: 'about' }) // in-app nav; modified clicks fall through to the href
    }
  }}
>
  About
</a>