$ pnpm install --dev navgo
import Navgo from 'navgo'
// Define routes up front (strings or RegExp)
const routes = [
  ['/', {}],
  ['/users/:username', {}],
  ['/books/*', {}],
  [/articles\/(?<year>[0-9]{4})/, {}],
  [/privacy|privacy-policy/, {}],
  [
    '/admin',
    {
      // constrain params with built-ins or your own
      param_validators: {
        /* id: Navgo.validators.int({ min: 1 }) */
      },
      // load data before URL changes; result goes to after_navigate(...)
      // loader returns a plan object: { key: { request, parse?, cache? } }
      loader() {
        return {
          admin: { request: '/api/admin', parse: 'json' },
        }
      },
      // per-route guard; cancel synchronously to block nav
      before_route_leave(nav) {
        if ((nav.type === 'link' || nav.type === 'nav') && !confirm('Enter admin?')) {
          nav.cancel()
        }
      },
    },
  ],
]
// Create router with options + callbacks
const router = new Navgo(routes, {
  base: '/',
  before_navigate(nav) {
    // app-level hook before loader/URL update; may cancel
    console.log('before_navigate', nav.type, '→', nav.to?.url.pathname)
  },
  after_navigate(nav, on_revalidate) {
    // called after routing completes; nav.to.data holds loader result
    if (nav.to?.data?.__error?.status === 404) {
      console.log('404 for', nav.to.url.pathname)
      return
    }
    console.log('after_navigate', nav.to?.url.pathname, nav.to?.data)
    // optional: subscribe to SWR revalidate for this navigation
    on_revalidate?.(() => {
      console.log('revalidated', nav.to?.url.pathname, nav.to?.data)
    })
  },
  // let your framework flush DOM before scroll
  // e.g. in Svelte: `import { tick } from 'svelte'`
  tick: tick,
  url_changed(cur) {
    // fires on shallow/hash/popstate-shallow/404 and full navigations
    // `cur` is the router snapshot: { url: URL, route, params }
    console.log('url_changed', cur.url.href)
  },
})
// Long-lived router: history + <a> bindings
// Also immediately processes the current location
router.init()Returns: Router
Type: Array<[pattern: string | RegExp, data: any]>
Each route is a tuple whose first item is the pattern and whose second item is hooks (see “Route Hooks”). Pass {} when no hooks are needed. Navgo returns this tuple back to you unchanged via onRoute.
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.
 
- 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()synchronously to prevent navigation.
 
- App-level hook called once per navigation attempt after the per-route guard and before loader/URL update. May call 
- after_navigate:- (nav: Navigation) => void- App-level hook called after routing completes (URL updated, data loaded). nav.to.dataholds any loader data.
 
- 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 
- url_changed:- (snapshot: any) => void- Fires on every URL change -- shallow push_state/replace_state, hash changes,popstateshallow entries, 404s, and full navigations. (deprecated; subscribe to.routeinstead.)
- Receives the router's current snapshot: an object like { url: URL, route: RouteTuple|null, params: Params }.
- The snapshot type is intentionally anyand may evolve without a breaking change.
 
- Fires on every URL change -- shallow 
- preload_delay:- number(default- 20)- Delay in ms before hover preloading triggers.
 
- preload_on_hover:- boolean(default- true)- When false, disables hover/touch preloading.
 
- When 
- attach_to_window:- boolean(default- true)- When true,init()attaches the instance towindow.navgofor convenience.
 
- When 
Important: Navgo only processes routes that match your base path.
- router.route--- Writable<{ url: URL; route: RouteTuple|null; params: Params }>- 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).
 
Example:
Current path: {$route.path}
<div class="request-indicator" class:active={$is_navigating}></div>
<script>
const router = new Navgo(...)
const {route, is_navigating} = router
</script>- param_validators?: Record<string, (value: string|null|undefined) => boolean>- Validate params (e.g., id: Navgo.validators.int({ min: 1 })). Anyfalseresult skips the route.
 
- Validate params (e.g., 
- loader?(ctx): LoaderPlan | Promise<LoaderPlan>- Declarative plan that Navgo executes and caches. Returns an object where keys become nav.to.data[key].
- Shape: { [key]: { request: string|URL|Request, parse?: 'json'|'text'|'blob'|'arrayBuffer'|((res)=>Promise), cache?: { strategy?: 'swr'|'cache-first'|'network-first'|'no-store', ttl?: number, soft_ttl?: number, tags?: string[], version?: string|number } } }
- ctxprovides:- { params, url, signal, fetch, invalidate }.
- Defaults:
- parse:- 'json'
- cache.strategy:- 'swr'
- cache.ttl:- 300000ms (5 minutes)
- cache.soft_ttl: unset (optional)
- cache.tags:- []
- Request is coerced to GET(body ignored); ETag/Last-Modified used when present
 
 
- Declarative plan that Navgo executes and caches. Returns an object where keys become 
- 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 
The Navigation object contains:
{
  type: 'link' | 'nav' | 'popstate' | 'leave',
  from: { url, params, route } | null,
  to:   { url, params, route } | null,
  will_unload: boolean,
  cancelled: boolean,
  event?: Event,
  cancel(): void
}- Router calls before_navigateon the current route (leave).
- Call nav.cancel()synchronously to cancel.- For link/nav, 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 = [
  [
    '/admin',
    {
      param_validators: {
        /* ... */
      },
      loader() {
        return {
          stats: { request: '/api/admin/stats', parse: 'json', cache: { strategy: 'swr', ttl: 300000 } },
        }
      },
      before_route_leave(nav) {
        if (nav.type === 'link' || nav.type === 'nav') {
          if (!confirm('Enter admin area?')) nav.cancel()
        }
      },
    },
  ],
  ['/', {}],
]
const router = new Navgo(routes, { base: '/app' })
router.init()Returns: String or false
Formats and returns a pathname relative to the base path.
If the uri does not begin with the 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: 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
The desired path to navigate. If it begins with / and does not match the configured base, it will be prefixed automatically.
Type: Object
- replace: Boolean(defaultfalse)
- When true, useshistory.replaceState; otherwisehistory.pushState.
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 links that have any target attribute or if the link was 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 triggers- preload(href).
- touchstartand- mousedown(tap) -- tapping or pressing on an in-app link also triggers- preload(href).
Preloading applies only to in-app anchors that match the configured base. You can tweak this behavior with the preload_delay and preload_on_hover options.
Notes:
- preload(uri)is a no-op when- uriformats to the current route's path (already loaded).
On beforeunload, the current scroll position is saved to sessionStorage and restored on the next load of the same URL (e.g., refresh or tab restore).
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 path are deduped.
Note: Resolves to undefined when the matched route has no loader.
Returns: void
Perform a shallow history push: updates the URL/state without triggering route processing.
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 matches- base.
- goto-- programmatic navigation via- router.goto(...).
- popstate-- browser back/forward.
- leave-- page is unloading (refresh, external navigation, tab close) via- beforeunload.
The router passes the type to your route-level before_route_leave(nav) hook.
- A route is a [pattern, data?]tuple.
- patterncan be a string (compiled with- regexparam) or a- RegExp.
- 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_validatorsis present, eachparams[k]is validated; anyfalseresult skips that route.
- 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
            → no → run loader(ctx)   // returns { key: spec }
            → cache/parse each spec into data
            → history.push/replaceState(new URL)
            → after_navigate(nav)
            → 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. 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.
Navgo manages scroll manually (sets history.scrollRestoration = 'manual') and applies SvelteKit-like behavior:
- Saves the current scroll position for the active history index.
- On link/nav(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)-- normalizes a path relative to- base. Returns- falsewhen- uriis outside of- base.
- match(uri)-- returns a Promise of- { route, params } | nullusing string/RegExp patterns and validators. Awaits an async- validate(params)if provided.
- goto(uri, { replace? })-- fires route-level- before_route_leave('goto'), calls global- before_navigate, saves scroll, runs loader, pushes/replaces, and completes via- after_navigate.
- init()-- wires global listeners (- popstate,- pushstate,- replacestate, click) and optional hover/tap preloading; immediately processes the current location.
- destroy()-- removes listeners added by- init().
- preload(uri)-- pre-executes a route's- loaderfor a path and caches the result; concurrent calls are deduped.
- push_state(url?, state?)-- shallow push that updates the URL and- history.statewithout route processing.
- replace_state(url?, state?)-- shallow replace that updates the URL and- history.statewithout route processing.
- Navgo.validators.int({ min?, max? })--- trueiff the value is an integer within optional bounds.
- Navgo.validators.one_of(iterable)--- trueiff the value is in the provided set.
Attach validators via a route tuple's data.param_validators to constrain matches.
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