@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-nousRunnable example:
apps/showcase— a small localized app that uses this router (localized routes, a layout route, a locale switcher) alongside the other@luwiostackpackages.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-BEDynamic 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/useSearchwith afromroute 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 idA 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>inindex.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 explicitthrow 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 tostatusPages[403]. The target lives in config, so the guard never repeats it; passqueryto carry a return URL. - Any other status (
503,429, …) — a dedicated page for abeforeLoad/loaderfailure whose thrown value carries thatstatus(anHttpErrorfrom@luwiostack/http, or anything else shaped like one:{ status: 503, … }). - 5xx generically — a route's
beforeLoadorloader(its data lifecycle, which runs before the component ever renders) throws something that isn't a deliberateredirect()(howforbidden()'s 403 works too) ornotFound(): 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 matchingstatusPagesentry (ordefault, 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}/anythingis always a 404 regardless ofexcludeStatus— there is nothing to forbid./nl-BE/pagewhennl-BEisn't inlocales404s even thoughpageis 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 idThis 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>