A batteries-included SPA router for Svelte.
Code-based routes (params + regex) • Nested layouts • Route guards and hooks • Param validation • Loader caching (SWR + tags) • Data preloading • Initial branch bootstrap • Search schema sync (validate/coerce + URL/store) • Shallow routing • Scroll restoration • Reactive stores • Performant • Well tested • ~1k loc (fraction of other routers) • Minimal API
navgo.mp4
This example demonstrates:
- code-defined routes with a dynamic segment (
/products/:id) after_navigate(nav, on_revalidate)wiring for route data + SWR revalidate updates + 404 state- app shell rendering via
nav.to.route?.[1]?.default - reading router stores from
window.navgo(route,is_navigating) - route-level
param_rulesvalidation/coercion (idstring -> number, min1) - basic LoadPlan loader (
{ product: '/api/products/:id' }) - automatic SWR caching for LoadPlan requests (default strategy)
- passing
$route.params+ loader data into the route component
import Navgo from 'navgo'
import { hydrate } from 'svelte'
import App from './App.svelte'
import * as Product from './routes/Product.svelte'
const routes = [['/products/:id', Product]]
const props = $state({
Component: null,
route_data: null,
is_404: false,
})
function after_navigate(nav, on_revalidate) {
props.is_404 = nav.status === 404
props.route_data = nav.to?.data ?? null
props.Component = nav.to?.route?.[1]?.default ?? null
on_revalidate?.(() => {
props.route_data = nav.to?.data ?? null
})
}
new Navgo(routes, { after_navigate }).init().then(() => {
hydrate(App, { target: document.body, props })
}){#key $route.url.pathname}
{#if Component}
<Component {...$route.params} data={route_data} />
{/if}
{/key}
<div class="request-indicator" class:active={$is_navigating}></div>
<script>
const { Component, route_data } = $props()
const { route, is_navigating } = window.navgo
</script><script module>
import { v } from 'navgo'
export const param_rules = {
id: v.pipe(v.string(), v.toNumber(), v.minValue(1)),
}
export function loader({ params }) {
return {
product: `/api/products/${params.id}`,
}
}
</script>
<script>
const { id, data } = $props()
</script>
<h1>Product {id}</h1>
<pre>{JSON.stringify(data?.product, null, 2)}</pre>Returns: Router
Type: Array<RouteTuple | RouteGroup>
Navgo accepts flat routes (tuples) and/or nested route groups (objects) for layouts + shared loaders.
RouteTuple
Each route tuple is [pattern, data?, extra?] whose first item is the pattern and whose second item is hooks (see “Route Hooks”). The optional third item is extra hooks and is merged with the second item (third wins; param_rules are merged by key).
RouteGroup
Each route group is an object:
{
id?: string,
layout?: any,
loader?: (ctx) => LoadPlan | Promise<unknown>,
before_route_leave?: (nav) => void,
routes: Array<RouteTuple|RouteGroup>
}idenables direct keyed access vianav.to.layouts[id]/$route.layouts[id]. IDs must be unique across route groups.layoutis forwarded intonav.to.matches(the router does not render anything).loaderruns for every matched child route in the group.before_route_leaveruns when leaving a matched route within the group.
Supported pattern types:
- static (
/users) - named parameters (
/users/:id) - nested parameters (
/users/:id/books/:title) - optional parameters (
/users/:id?/books/:title?) - wildcards (
/users/*) - RegExp patterns (with optional named groups)
Notes:
- Pattern strings are matched relative to the
basepath. - RegExp patterns are used as-is. Named capture groups (e.g.
(?<year>\d{4})) becomeparamskeys; unnamed groups are ignored.
base:string(default'/')- App base pathname. With or without leading/trailing slashes is accepted.
rewrite:{ input?: (ctx) => string | URL | { url?: string | URL; context?: unknown } | void; output?: (ctx) => string | URL | { url?: string | URL; context?: unknown } | void }- Optional bidirectional URL rewrite hooks.
inputmaps a public URL into Navgo's canonical internal path before matching, whileoutputmaps an internal target back into a public URL for history, links, and preloading. Useful for locale prefixes like/en/...without duplicating routes.
- Optional bidirectional URL rewrite hooks.
before_navigate:(nav: Navigation) => void- App-level hook called once per navigation attempt after the per-route guard and before loader/URL update. May call
nav.cancel()to prevent navigation ornav.replace()to commit withhistory.replaceState.
- App-level hook called once per navigation attempt after the per-route guard and before loader/URL update. May call
history_state:(nav: Navigation, current_state: unknown) => unknown- Adds application metadata to full-navigation history entries. Navgo merges the result, then writes its reserved
__navgometadata last.
- Adds application metadata to full-navigation history entries. Navgo merges the result, then writes its reserved
after_navigate:(nav: Navigation, on_revalidate?: (cb: () => void) => void) => void | Promise<void>- App-level hook called after routing completes (URL updated, data loaded).
nav.to.dataholds any loader data, andnav.statusis the HTTP-like status for the completed route. - If the active route uses SWR and a stale entry is revalidated in the background, register a callback via
on_revalidate(cb)to refresh UI. nav.ssris resolved from the matched leaf route'sssrexport before this hook runs.
- App-level hook called after routing completes (URL updated, data loaded).
tick:() => void | Promise<void>- Awaited after
after_navigateand before scroll handling; useful for frameworks to flush DOM so anchor/top scrolling lands correctly.
- Awaited after
manage_scroll:boolean(defaulttrue)- When
false, Navgo performs no scroll tracking/restoration, hash scrolling, session persistence, orhistory.scrollRestorationchanges. Use this when mounted workspaces own scroll state.
- When
scroll_to_top:boolean(defaulttrue)- When
false, skips the default top scroll for non-hash navigations while retaining the rest of Navgo's scroll management.
- When
initial_scroll_restore:'router' | 'browser'(default'router')'router': restore saved refresh scroll after the initial route render. Use this for SPA shells.'browser': preserve native initial scroll restoration. Use this for SSR/hydration documents.
aria_current:boolean(defaultfalse)- When
true, setsaria-current="page"on active in-app links.
- When
preload_delay:number(default20)- Delay in ms before hover preloading triggers.
preload_on_hover:boolean(defaulttrue)- When
false, disables hover/touch preloading.
- When
should_handle_link:({ anchor, url, event, phase }) => boolean | void- Synchronous policy shared by click navigation and intent preloading. Return
falseto leave a same-origin link entirely to the browser.phaseis'navigate'or'preload'.
- Synchronous policy shared by click navigation and intent preloading. Return
bootstrap:unknown[]- Optional initial matched branch data for the current document. Navgo consumes it once on the first same-document navigation instead of running loaders.
- The caller is responsible for ensuring the array matches the current URL and matched branch order.
attach_to_window:boolean(defaulttrue)- When
true,init()attaches the instance towindow.navgofor convenience.
- When
load_plan_defaults:{ parse?: Parser; cache?: { strategy?: CacheStrategy; ttl?: number; tags?: string[] } }- Defaults applied to LoadPlan entries when
parse/cacheare omitted. - Default:
{ parse: 'json', cache: { strategy: 'swr', ttl: 86_400_000 } }
- Defaults applied to LoadPlan entries when
search:SearchOptions- Default behavior for keeping URL search params in sync with
router.search_params. - Can be overridden per-route via
search_options.
- Default behavior for keeping URL search params in sync with
Important: Navgo only processes routes that match your base path.
router.route--Writable<{ url: URL; internal_url: URL; path: string; context?: unknown; route: RouteTuple|null; params: Params; matches: Match[]; layouts: Record<string, Match>; search_params: SearchParams }>- Readonly property that holds the current snapshot.
- Subscribe to react to changes; Navgo updates it on every URL change.
router.is_navigating--Writable<boolean>truewhile a navigation is in flight (between start and completion/cancel).
router.search_params--Writable<SearchParams> & { toString(): string }- Writable store of validated search params for the current route.
- If the current route defines a
search_schema, this store is kept in sync with the URL. - Writing to it updates the URL search string (optionally debounced).
- The store object has a custom
toString()that returns the canonical query string (without the leading?) for the current route, usingURLSearchParamsencoding.
Example:
Current path: {$route.path}
Current public URL: {$route.url.pathname}
<div class="request-indicator" class:active={$is_navigating}></div>
<script>
const router = new Navgo(...)
const {route, is_navigating} = router
</script>Navgo can keep a route-scoped search params store in sync with the URL.
- Define a Valibot
search_schemaon a route tuple and/or a route group. - Navgo validates + applies defaults, and exposes the result:
- as
router.search_params(Svelte store) - as
search_paramsonrouter.route(snapshot:$route.search_params) - as
ctx.search_paramsinside loaders
- as
- Update
router.search_paramsto update the URL search string.
Only keys declared in the schema are managed. Other query params are preserved.
Coercion is default-driven: if a schema default is a number or boolean, Navgo will coerce URL values
from strings before validation. Plain strings are not JSON-parsed, so ?q=true stays 'true' when q
defaults to a string.
Navgo re-exports Valibot as v, so you can import it from navgo (useful with pnpm, which doesn't allow importing undeclared transitive deps).
// routes/Products.svelte
import { v } from 'navgo'
export const search_schema = v.object({
q: v.optional(v.fallback(v.string(), ''), ''),
page: v.optional(v.fallback(v.number(), 1), 1),
// arrays are supported
tag: v.optional(v.fallback(v.array(v.string()), []), []),
cat: v.optional(v.fallback(v.array(v.string()), []), []),
})
export const search_options = {
debounce: 300,
push_history: true,
show_defaults: false,
sort: true,
// arrays default to 'repeat' (?tag=a&tag=b). When using a map, `default` is the fallback for keys you don't list.
array_style: { default: 'repeat', cat: 'csv' },
}If multiple matched layout groups define a search_schema, the most specific (closest) one wins. If the leaf route defines a search_schema, it wins over any layout schema (no merging).
<script>
const { search_params } = router
</script>
<input
value={$search_params.q ?? ''}
oninput={(e) => ($search_params = { ...$search_params, q: e.target.value, page: 1 })}
/>Notes:
- Writes are shallow (URL changes via
replace_state/push_state), so loaders are not re-run automatically. - If you want a full navigation, call
router.goto(...)with a new URL. - You can serialize the current managed query with
router.search_params.toString().
Load plans let you define one or more fetches that Navgo can cache via the CacheStorage API.
// sync => treated as a LoadPlan
function loader({ params }) {
return {
product: `https://dummyjson.com/products/${params.id}`,
reviews: {
request: `https://example.com/reviews/${params.id}`,
cache: { strategy: 'cache-first', ttl: 60_000, tags: ['reviews'] },
},
}
}
// async => treated as plain data
async function loader(ctx) {
return { session: await ctx.fetch('/api/session').then(r => r.json()) }
}ctx.fetch(...) is just fetch(...) with the current navigation's abort signal already attached.
Use plain fetch if you do not need navigation-scoped cancellation.
Global defaults for LoadPlans can be set in options:
const router = new Navgo(routes, {
load_plan_defaults: {
parse: 'json',
cache: { strategy: 'swr', ttl: 60_000 },
},
})See examples.md for more setups.
Executed LoadPlans expose per-entry cache sources on data.__meta.source and a timestamp at data.__meta.at.
- param_rules?:
Record<string, ParamRule>- Each rule is either a Valibot schema or
{ schema, coercer }. - Schema runs on raw params; when it succeeds, the schema output replaces the param value.
- Coercers run after schema and may transform params before
validate(...)/loader.
- Each rule is either a Valibot schema or
- loader?(ctx: LoaderContext):
LoadPlan | Promise<unknown>- If you return a non-Promise object, it is treated as a
LoadPlanand executed (each entry can be cached). - If you return a Promise, it is awaited and the resolved value becomes
nav.to.data. - To return a plain object as data, make the loader
async.
- If you return a non-Promise object, it is treated as a
- ssr?:
{ serve_shell?: boolean; refresh_every?: number }- Optional SSR metadata for the leaf route. Navgo exposes it on completed navigations as
nav.ssr.
- Optional SSR metadata for the leaf route. Navgo exposes it on completed navigations as
- validate?(params):
boolean | Promise<boolean>- Predicate called during matching. If it returns or resolves to
false, the route is skipped.
- Predicate called during matching. If it returns or resolves to
- before_route_leave?(nav):
(nav: Navigation) => void- Guard called once per navigation attempt on the current route (leave). Call
nav.cancel()synchronously to prevent navigation. Forpopstate, cancellation auto-reverts the history jump.
- Guard called once per navigation attempt on the current route (leave). Call
- search_schema?:
any- Valibot object schema whose output becomes
router.search_paramsandctx.search_params. - Can also be placed on route groups (layouts) to share search params across children.
- Valibot object schema whose output becomes
- search_options?:
SearchOptions- Overrides
options.searchfor this route (e.g. debounce and history behavior).
- Overrides
The Navigation object contains:
{
type: 'link' | 'goto' | 'popstate' | 'leave',
from: { url, params, route, matches, layouts } | null,
to: { url, params, route, matches, layouts, data } | null,
will_unload: boolean,
cancelled: boolean,
event?: Event,
cancel(): void
}nav.to.matches is ordered outer → inner and remains the canonical structure:
for (const m of nav.to?.matches || []) {
if (m.type === 'layout') console.log('layout', m.layout, m.data)
if (m.type === 'route') console.log('route', m.route?.[0], m.data)
}If a matched route group declares an id, Navgo also exposes a keyed lookup that points at the same match object:
const session = nav.to?.layouts?.app?.data
const admin_layout = $route.layouts?.admin- Router calls
before_route_leaveon the current route (leave). - Call
nav.cancel()synchronously to cancel.- For
link/goto, it stops before URL change. - For
popstate, cancellation causes an automatichistory.go(...)to revert to the previous index. - For
leave, cancellation triggers the native “Leave site?” dialog (behavior is browser-controlled).
- For
Example:
const routes = [
[
'/account/:account_id',
{
param_rules: {
account_id: v.pipe(v.string(), v.toNumber(), v.minValue(1)),
},
loader: ({ params }) => fetch(`/api/account/${params.account_id}`).then(r => r.json()),
before_route_leave(nav) {
if (nav.type === 'link' || nav.type === 'goto') {
if (!confirm('Leave account settings?')) nav.cancel()
}
},
},
],
['/', {}],
]
const router = new Navgo(routes, { base: '/app' })
router.init()Returns: String or false
Parses a public URL relative to the configured base path and optional rewrite.input, then returns the canonical internal pathname.
If the uri does not belong to the app's base, then false will be returned instead.
Otherwise, the return value will always lead with a slash (/).
Note: This is called automatically within the
init()method.
Type: String
The path to format.
Note: Much like
base, paths with or without leading and trailing slashes are handled identically.
Returns: String or false
Builds a public in-app URL from an internal target. This is the forward counterpart to format() and is especially useful when combined with rewrite.output (for example, locale prefixes).
Type: Object
- absolute:
Boolean(defaultfalse) - literal:
Boolean(defaultfalse) - context:
Any
When literal is true, the uri is treated as an already-public URL and is only validated/normalized. Otherwise Navgo treats ambiguous inputs like /about and same-origin absolute URL values as canonical internal targets and applies base + rewrite.output when building the final public URL.
Returns: Promise<void>
Runs any matching route loader before updating the URL and then updates history. Route processing triggers after_navigate. Use replace: true to replace the current history entry.
Type: String | URL
The desired path to navigate. When literal is false, ambiguous paths like /about and same-origin absolute URL values are treated as canonical internal targets and are passed through base + rewrite.output.
Type: Object
- replace:
Boolean(defaultfalse) - literal:
Boolean(defaultfalse) - context:
Any - When
true,replaceuseshistory.replaceState; otherwisehistory.pushState. - When
literalistrue, theuriis interpreted as the already-public browser URL. Otherwise ambiguous inputs like/aboutand same-origin absoluteURLvalues are treated as canonical internal targets and pass throughrewrite.output.
Attaches global listeners to synchronize your router with URL changes, which allows Navgo to respond consistently to your browser's BACK and FORWARD buttons.
Events:
- Responds to:
popstateonly. No synthetic events are emitted.
Navgo will also bind to any click event(s) on anchor tags (<a href="" />) so long as the link has a valid href that matches the base path. Navgo will not intercept download links, links targeting another browsing context (target other than _self), or links clicked with a special modifier (ALT, SHIFT, CMD, or CTRL).
While listening, link clicks are intercepted and translated into goto() navigations. You can also call goto() programmatically.
In addition, init() wires preloading listeners (enabled by default) so route data can be fetched early:
mousemove(hover) -- after a short delay, hovering an in-app link triggerspreload(href).touchstartandmousedown(tap) -- tapping or pressing on an in-app link also triggerspreload(href).
Preloading applies only to in-app anchors that match the configured base. should_handle_link is evaluated for both preload and navigation, so opt-outs cannot warm data that the router would not consume. You can tune intent timing with preload_delay and disable it with preload_on_hover.
If you pass options.bootstrap, the first same-document navigation can hydrate from that ordered branch data instead of running loaders. This is useful for SSR HTML that already contains the exact data used to render the page.
Notes:
preload(uri)is a no-op whenuriformats to the current route's path (already loaded).
Set manage_scroll: false to leave this entire concern to the application. Otherwise, on beforeunload, the current scroll position is saved to sessionStorage by history entry. On the next load of that same history entry (for example refresh or tab restore), Navgo restores it after the initial route render when initial_scroll_restore is 'router'. For SSR/hydration documents, set initial_scroll_restore: 'browser' so the browser's native restoration owns initial scroll.
Navgo caches/restores scroll positions for the window and any scrollable element that has a stable identifier:
- Give your element either an
idordata-scroll-id="...". - Navgo listens to
scrollglobally (capture) and records positions per history entry. - On
popstate, it restores matching elements before paint.
Example:
<div id="pane" class="overflow-auto">...</div>Or with a custom id:
<div data-scroll-id="pane">...</div>Returns: Promise<unknown | void>
Preload a route's loader data for a given uri without navigating. Concurrent calls for the same public URL are deduped. Accepts the same literal / context options as goto().
Note: Resolves to undefined when the matched route has no loader.
Returns: void
Abort the active navigation without changing the current route or URL.
Returns: void
Perform a shallow history push: updates the URL/state without triggering route processing. It supersedes any active full navigation.
Returns: void
Perform a shallow history replace: updates the URL/state without triggering route processing.
Detach all listeners initialized by init().
This section explains, in detail, how navigation is processed: matching, hooks, data loading, shallow routing, history behavior, and scroll restoration. The design takes cues from SvelteKit's client router (see: kit/documentation/docs/30-advanced/10-advanced-routing.md and kit/documentation/docs/30-advanced/67-shallow-routing.md).
link-- user clicked an in-app<a>that matchesbase.goto-- programmatic navigation viarouter.goto(...).popstate-- browser back/forward.leave-- page is unloading (refresh, external navigation, tab close) viabeforeunload.
The router passes the type to your before_route_leave(nav) hooks (route tuples and route groups).
- A matchable route is a
[pattern, data?]tuple. Route groups are wrappers that add layouts/shared loaders. patterncan be a string (compiled withregexparam) or aRegExp.- Named params from string patterns populate
paramswithstringvalues; optional params that do not appear arenull. - Wildcards use the
'*'key. - RegExp named groups also populate
params; omitted groups can beundefined. - If
data.param_rulesis present, eachparams[k]schema runs first (schema output replaces the param), then coercers run to transform params. - If
data.validate(params)returns or resolves tofalse, the route is also skipped.
For link and goto navigations that match a route:
[click <a>] or [router.goto()]
→ before_route_leave({ type }) // per-route guard
→ before_navigate(nav) // app-level start
→ cancelled? yes → stop
→ replace requested? commit with replaceState
→ run loaders (layouts → route) // each may be value, Promise, or Promise[]
→ cache data by formatted path
→ history.push/replaceState(new URL)
→ after_navigate(nav, on_revalidate)
→ tick()? // optional app-provided await before scroll
→ scroll restore/hash/top
- If a loader throws/rejects, navigation continues and
after_navigate(..., with nav.to.data = { __error })is delivered so UI can render an error state. - For
popstate, the route'sloaderruns before completion so content matches the target entry; this improves scroll restoration. Errors are delivered viaafter_navigatewithnav.to.data = { __error }.
Use push_state(url, state?) or replace_state(url, state?) to update the URL/state without re-running routing logic.
push_state/replace_state (shallow)
→ updates history.state and URL
→ router does not process routing on shallow operations
This lets you reflect UI state in the URL while deferring route transitions until a future navigation.
To enable popstate cancellation, Navgo stores a monotonic idx in history.state.__navgo.idx. Applications may keep their own namespaced state beside it with history_state; __navgo remains reserved. On popstate, a cancelled navigation computes the delta between the target and current idx and calls history.go(-delta) to return to the prior entry.
With manage_scroll: true, Navgo manages scroll manually (sets history.scrollRestoration = 'manual') and applies SvelteKit-like behavior. With manage_scroll: false, none of the behavior in this section runs.
- Saves the current scroll position for the active history index.
- On initial boot: restore saved scroll after route render in
'router'mode, or preserve native browser restoration in'browser'mode. - On
link/goto(after route commit):- If the URL has a
#hash, scroll to the matching elementidor[name="..."]. - Otherwise, scroll to the top
(0, 0).
- If the URL has a
- On
popstate: restore the saved position for the target history index; if not found but there is a#hash, scroll to the anchor instead. - Shallow
push_state/replace_statenever adjust scroll (routing is skipped).
scroll flow
├─ on any nav: save current scroll for current idx
├─ link/goto: after navigate → hash? anchor : scroll(0,0)
└─ popstate: after navigate → restore saved idx position (fallback: anchor)
format(uri)-- parses a public URL relative tobaseandrewrite.input, returning the canonical internal path. Returnsfalsewhenuriis outside ofbase.match(uri)-- returns a Promise of{ route, params, matches, layouts } | nullusing string/RegExp patterns andparam_rules(Valibot schemas). Awaits an asyncvalidate(params)if provided.href(uri, options?)-- builds a public in-app URL from a canonical internal target (or validates a literal public URL whenliteral: true).goto(uri, { replace?, literal?, context? })-- fires route-levelbefore_route_leave('goto'), calls globalbefore_navigate, runs the loader, pushes/replaces, and completes viaafter_navigate. A guard may callnav.replace()to select replacement transactionally.init()-- wires global listeners (popstate,pushstate,replacestate, click) and optional hover/tap preloading; immediately processes the current location. Whenoptions.bootstrapis present, the first same-document navigation reuses that branch data instead of running loaders.destroy()-- removes listeners added byinit().preload(uri, { literal?, context? })-- pre-executes a route'sloaderfor a path and caches the result; concurrent calls are deduped by public URL.cancel_navigation()-- aborts the active navigation without changing the current route or URL.push_state(url?, state?)-- shallow push that updates the URL andhistory.statewithout route processing.replace_state(url?, state?)-- shallow replace that updates the URL andhistory.statewithout route processing.
Use Valibot schemas in param_rules to validate/transform route params before validate(...)/loader.
This router integrates ideas and small portions of code from these fantastic projects:
- SvelteKit -- https://github.com/sveltejs/kit
- navaid -- https://github.com/lukeed/navaid
- TanStack Router -- https://github.com/TanStack/router