A modern router for Svelte 5 applications, built from the ground up using the new runes API ($props, $state, $effect).
This module is specifically optimized for Single Page Applications (SPA) with dual-mode routing and comprehensive permission management.
Main features:
#/path) and history API (/path) routingdefineRoutes() — single source of truth with IDE autocomplete on route names and paramsrouteParams(), query(), and filters() with intellisenseonNotFound callback for analytics and monitoring/book/:id?) and moreThis module is released under MIT license.
example/src/routes/test/setCurrentUser() + reactivity-by-default — hasPermission() updates live in {#if} blocks without subscription wiring; no more get(store) footgunrevalidateCurrentRoute() + onRevalidationFailure — re-check the currently mounted route on out-of-band user changes (websocket permission updates, token refresh) without remounting on successrelativeLocation on every Router event payload — prefix-stripped view for nested routers; location stays full URLpush('/path', {}, queryObj) was dropping the query; onNotFound never fired when '*' catch-all was configured; navigationContext() was leaking the internal _routeName key (so !ctx was never true)shouldDisplayLoadingOnRouteLoad routes never call hideLoading() — surfaces forgotten-callback bugs after 10s instead of blank pages foreverGlobalErrorHandler (breaking — rc02 unreleased) — notification UI belongs in your stack via the onError callbackdefineRoutes() — type-safe route definitions with IDE autocomplete on route names and params, plus generated nav.X.push(params) and paths.X(params) helpersai/ (basic-setup, navigation, permissions, …) optimized for Claude / Cursor / CopilotrouteContext(), routeTitle(), routeBreadcrumbs()routeContext() mangled name, wrap() not merging title/breadcrumbs into routeContext, missing type declarations for GlobalErrorHandler / ErrorDisplay / setHierarchicalRoutesEnabled / setIncludeReferrerFull details in CHANGELOG.md.
npm install @keenmate/svelte-spa-router
⚠️ Important: This package requires Node.js 22 or higher for production builds. Node.js 20 has compatibility issues with Svelte 5 that may cause runtime errors like "link is not defined" in production builds. Make sure your build environment (CI/CD, Docker, etc.) uses Node 22+.
Here are the most frequently used imports and where to get them:
// Main Router component
import Router from '@keenmate/svelte-spa-router'
// Navigation functions - available from main module OR /utils
import { push, replace, pop, goBack } from '@keenmate/svelte-spa-router'
// Alternative:
import { push, replace, pop, goBack } from '@keenmate/svelte-spa-router/utils'
// Link action for <a> tags
import { link } from '@keenmate/svelte-spa-router'
// Get current route data (call as functions, not stores!)
import { location, querystring, routeParams, navigationContext } from '@keenmate/svelte-spa-router'
// Usage in components:
const currentPath = $derived(location()) // e.g., "/user/123"
const query = $derived(querystring()) // e.g., "?tab=profile"
const params = $derived(routeParams()) // e.g., { id: "123" }
const context = $derived(navigationContext()) // Navigation context data
// Alternative: Access from /utils
import { location, querystring, routeParams } from '@keenmate/svelte-spa-router'
⚠️ Important: In route components, prefer receiving routeParams as props instead of importing:
<script>
// Recommended in route components
let { routeParams = {} } = $props()
</script>
<p>User ID: {routeParams.id}</p>
// Type-safe route definitions (recommended!)
import { defineRoutes } from '@keenmate/svelte-spa-router/routes'
// Wrap routes with loading/conditions
import { wrap } from '@keenmate/svelte-spa-router/wrap'
// Active link highlighting
import active from '@keenmate/svelte-spa-router/active'
// Named routes system
import { registerRoutes, buildUrl } from '@keenmate/svelte-spa-router/routes'
// Permission-based routing
import {
configurePermissions,
createProtectedRoute,
hasPermission
} from '@keenmate/svelte-spa-router/helpers/permissions'
// Navigation guards
import {
registerBeforeLeave,
unregisterBeforeLeave,
NavigationCancelledError
} from '@keenmate/svelte-spa-router/helpers/navigation-guard'
// Hierarchical route structure
import { createHierarchy } from '@keenmate/svelte-spa-router/helpers/hierarchy'
// Error handling
import {
configureGlobalErrorHandler
} from '@keenmate/svelte-spa-router/helpers/error-handler'
import { GlobalErrorHandler } from '@keenmate/svelte-spa-router/helpers/GlobalErrorHandler'
// URL utilities
import { joinPaths } from '@keenmate/svelte-spa-router/helpers/url-helpers'
// Query string helpers
import {
parseQuerystring,
stringifyQuerystring,
updateQuerystring
} from '@keenmate/svelte-spa-router/helpers/querystring'
'@keenmate/svelte-spa-router' // Main module (Router, push, location, etc.)
'@keenmate/svelte-spa-router/utils' // Alternative path for utils
'@keenmate/svelte-spa-router/wrap' // Route wrapping
'@keenmate/svelte-spa-router/active' // Active link action
'@keenmate/svelte-spa-router/routes' // Named routes system
'@keenmate/svelte-spa-router/constants' // Constants and enums
'@keenmate/svelte-spa-router/logger' // Debug logging
'@keenmate/svelte-spa-router/helpers/permissions' // Permission system
'@keenmate/svelte-spa-router/helpers/navigation-guard' // Navigation guards
'@keenmate/svelte-spa-router/helpers/hierarchy' // Hierarchical routes
'@keenmate/svelte-spa-router/helpers/error-handler' // Error handling
'@keenmate/svelte-spa-router/helpers/GlobalErrorHandler' // Error component
'@keenmate/svelte-spa-router/helpers/url-helpers' // URL utilities
'@keenmate/svelte-spa-router/helpers/querystring' // Query string helpers
'@keenmate/svelte-spa-router/helpers/route-metadata' // Breadcrumbs/metadata
'@keenmate/svelte-spa-router/helpers/filters' // Filter parsing
❌ Common Mistake:
// ❌ WRONG - /stores path doesn't exist (this was the old v3/v4 API)
import { routeParams } from '@keenmate/svelte-spa-router/stores'
// ✅ CORRECT - Import from main module or /utils
import { routeParams } from '@keenmate/svelte-spa-router'
Note: This is a Svelte 5 router using runes (
$state,$derived), not Svelte stores. There is no/storesexport path.
The router includes a built-in debug logging system to help troubleshoot routing issues during development.
// main.js
import { enableLogging } from '@keenmate/svelte-spa-router/logger'
// Enable debug logs in development only
if (import.meta.env.DEV) {
enableLogging() // Enable all categories
}
// Or enable specific categories only
import { disableLogging, setCategoryLevel } from '@keenmate/svelte-spa-router/logger'
disableLogging() // Disable all first
setCategoryLevel('ROUTER:SCROLL', 'debug') // Enable only scroll logs
setCategoryLevel('ROUTER:NAVIGATION', 'info') // Enable navigation at info level
The router provides 12 hierarchical logging categories for granular control:
| Category | Description |
|---|---|
ROUTER |
Core routing pipeline, route matching |
ROUTER:NAVIGATION |
push, pop, replace, goBack |
ROUTER:SCROLL |
Scroll restoration |
ROUTER:GUARDS |
Navigation guards |
ROUTER:CONDITIONS |
Route condition checks |
ROUTER:HIERARCHY |
Hierarchical route inheritance |
ROUTER:PERMISSIONS |
Permission checking |
ROUTER:ROUTES |
Named routes and URL building |
ROUTER:ZONES |
Multi-zone routing |
ROUTER:METADATA |
Breadcrumbs and route metadata |
ROUTER:ERROR_HANDLER |
Global error handling |
ROUTER:FILTERS |
Filter parsing |
Example output:
[13:42:48.123] [DEBUG] [ROUTER] Running pipeline for: /document/123
[13:42:48.234] [DEBUG] [ROUTER] Route loaded successfully: /document/:id
[13:42:48.345] [DEBUG] [ROUTER:NAVIGATION] Called - navigationContext: { source: 'menu' }
[13:42:48.456] [DEBUG] [ROUTER:SCROLL] Scroll effect triggered - restoreScrollState: true
import { setLogLevel, setCategoryLevel } from '@keenmate/svelte-spa-router/logger'
// Set global log level (affects all categories)
setLogLevel('warn') // Only show warnings and errors
// Enable specific categories at different levels
disableLogging() // Start with all disabled
setCategoryLevel('ROUTER:SCROLL', 'debug') // Debug scroll issues
setCategoryLevel('ROUTER:PERMISSIONS', 'info') // Monitor permission checks
Log Levels: trace, debug, info, warn, error, silent
To focus on specific router logs in your browser console:
ROUTER to see all router logsROUTER:SCROLL to see only scroll-related logs[ERROR] to see only errorsDebug logs are disabled by default to keep production consoles clean.
This router leverages Svelte 5's runes and provides:
In Svelte 5 version, location stores are accessed as functions instead of Svelte stores:
Example:
<script>
import { routeParams} from '@keenmate/svelte-spa-router'
</script>
<p>Current location: {location()}</p>
<p>Querystring: {querystring()}</p>
<p>Params: {JSON.stringify(routeParams())}</p>
on: directivesExample:
<Router {routes}
onRouteLoading={handleLoading}
onRouteLoaded={handleLoaded}
onConditionsFailed={handleFailed}
/>
The router now uses:
$state for reactive state management$props for component props$effect for side effects (location tracking, scroll restoration)$derived for computed valuesThis provides better performance and follows Svelte 5 best practices.
The router uses a pipeline architecture for clean separation of concerns:
flowchart TD
Start([User Navigation]) --> Effect[🔄 Reactive Effect<br/>Reads location state]
Effect --> Snapshot[📸 Capture Current Route<br/>for Referrer Tracking]
Snapshot --> Pipeline[⚙️ Async Pipeline<br/>No reactive tracking]
Pipeline --> Stage1[1️⃣ Find Matching Route<br/>Pattern matching with regexparam]
Stage1 --> Stage2[2️⃣ Load Component<br/>Async import & race protection]
Stage2 --> Stage3[3️⃣ Execute Guards<br/>Permissions & conditions]
Stage3 --> Stage4[4️⃣ Inject Referrer<br/>Track previous route]
Stage4 --> Stage5[5️⃣ Update Metadata<br/>Breadcrumbs & route data]
Stage5 --> Commit[💾 Commit to Reactive State<br/>Single write operation]
Commit --> Render[🎨 Svelte Renders<br/>Component with props]
Stage3 -->|Guard Failed| Unauthorized[❌ Unauthorized<br/>Redirect or 401]
Stage1 -->|No Match| NotFound[🔍 404 Not Found<br/>Catch-all route]
style Pipeline fill:#e3f2fd
style Commit fill:#c8e6c9
style Effect fill:#fff9c4
style Render fill:#f3e5f5
style Unauthorized fill:#ffccbc
style NotFound fill:#ffccbc
untrack() calls needed: Pipeline runs outside reactive contextcommitToReactiveState)svelte-spa-router-5 supports two routing modes:
Uses hash-based routing with URLs like http://example.com/#/path.
Pros:
file:// protocolCons:
# in themUsage: No configuration needed - this is the default!
<!-- App.svelte -->
<Router {routes}/>
Uses the History API with clean URLs like http://example.com/path.
Pros:
#target attribute on linksCons:
index.html for all routesfile:// protocolUsage: Configure before mounting your app
// main.js
import { mount } from 'svelte'
import { setHashRoutingEnabled, setBasePath } from '@keenmate/svelte-spa-router'
import App from './App.svelte'
// Enable history mode
setHashRoutingEnabled(false)
setBasePath(import.meta.env.BASE_URL || '/')
// Mount app
mount(App, { target: document.body })
Server Configuration:
For production, configure your server to serve index.html for all routes:
# Nginx
location / {
try_files $uri $uri/ /index.html;
}
# Apache .htaccess
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
// Express.js
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'))
})
Base Path Configuration:
If your app is served from a subdirectory (e.g., http://example.com/app/):
setBasePath('/app')
Make sure to also set it in your build tool:
// vite.config.js
export default {
base: '/app/'
}
Examples:
example/ for hash mode (default)example-history/ for history mode with clean URLsEach route is a normal Svelte component. The route definition is a JavaScript dictionary (object) where the key is the path and the value is the component.
import Home from './routes/Home.svelte'
import Author from './routes/Author.svelte'
import Book from './routes/Book.svelte'
import NotFound from './routes/NotFound.svelte'
const routes = {
// Exact path
'/': Home,
// Using named parameters, with last being optional
'/author/:first/:last?': Author,
// Wildcard parameter
'/book/*': Book,
// Catch-all (must be last)
'*': NotFound,
}
Use defineRoutes() for a single source of truth that gives you IDE autocomplete on route names and parameters, preventing typos at compile time:
// src/routes.js (or routes.ts for TypeScript)
import { defineRoutes } from '@keenmate/svelte-spa-router/routes'
import Home from './routes/Home.svelte'
const { routes, nav, paths } = defineRoutes({
home: {
path: '/',
component: Home
},
about: {
path: '/about',
component: () => import('./routes/About.svelte')
},
user: {
path: '/user/:id',
component: () => import('./routes/User.svelte'),
conditions: [checkAuth],
breadcrumbs: [{ label: 'Users' }, { id: 'user', label: 'User' }]
},
settings: {
path: '/settings',
component: () => import('./routes/Settings.svelte'),
permissions: { any: ['settings.read'] }
}
})
export { routes, nav, paths }
Use in App.svelte:
<script>
import Router from '@keenmate/svelte-spa-router'
import { link } from '@keenmate/svelte-spa-router'
import { routes, nav, paths } from './routes'
</script>
<!-- Pass routes to Router -->
<Router {routes} />
<!-- Links with autocomplete on route names + params -->
<a href={paths.user({ id: 123 })} use:link>User 123</a>
<a href={paths.about()} use:link>About</a>
<!-- Programmatic navigation -->
<button onclick={() => nav.user.push({ id: 42 })}>Go to User 42</button>
<button onclick={() => nav.settings.replace()}>Settings</button>
<!-- For use:link action -->
<a use:link={nav.user.link({ id: 99 })}>User 99</a>
What defineRoutes() returns:
| Property | Description |
|---|---|
routes |
Standard routes object for <Router {routes} /> |
nav.X.push(params?, query?, ctx?) |
Navigate to route X (calls push() internally) |
nav.X.replace(params?, query?, ctx?) |
Replace with route X (calls replace() internally) |
nav.X.link(params?, query?) |
Returns object for use:link action |
nav.X.path |
Raw path pattern (e.g. '/user/:id') |
paths.X(params?, query?) |
Build URL string for href attributes |
TypeScript support:
In TypeScript, defineRoutes() extracts :param names from path patterns at the type level:
const { nav, paths } = defineRoutes({
user: { path: '/user/:id', component: UserPage }
})
nav.user.push({ id: 123 }) // ✅ TypeScript knows 'id' is required
nav.user.push({ userId: 123 }) // ❌ Type error — 'userId' doesn't exist
nav.user.push() // ✅ OK — params are optional at runtime
paths.user({ id: 123 }) // ✅ Returns '/user/123'
Supported route options:
Each route in defineRoutes() accepts path, component, and all existing createRoute() / wrap() options: loadingComponent, loadingParams, conditions, props, routeContext, title, breadcrumbs, shouldDisplayLoadingOnRouteLoad, permissions, authorizationCallback, and inheritance flags (inheritBreadcrumbs, inheritPermissions, etc.).
Note:
defineRoutes()automatically callsregisterRoutes()internally — no separate registration step is needed. Named routes work immediately withpush(),replace(), andbuildUrl().
In your main component (usually App.svelte):
<script>
import Router from '@keenmate/svelte-spa-router'
import routes from './routes'
</script>
<Router {routes}/>
Using links with the use:link action:
<script>
import {link} from '@keenmate/svelte-spa-router'
</script>
<a href="/book/123" use:link>View Book</a>
Programmatically with multiple formats:
import {push, pop, replace} from '@keenmate/svelte-spa-router'
// String format (simple paths)
push('/book/42')
// Multi-parameter signature (NEW!)
push('userProfile', { userId: 123 }, { tab: 'settings' })
// Route name starting with / = exact path, otherwise = named route lookup
push('/about', {}, { source: 'nav' })
// Array format (with named routes)
push(['userProfile', { userId: 123 }])
// Array with query parameters
push(['userProfile', { userId: 123 }, { tab: 'settings' }])
// Array with navigation context (4 elements)
push(['userProfile', { userId: 123 }, { tab: 'settings' }, { source: 'menu' }])
// Object format (most explicit)
push({
route: 'userProfile',
params: { userId: 123 },
query: { tab: 'settings', page: '2' }
})
// Object with navigation context
push({
route: 'userProfile',
params: { userId: 123 },
query: { tab: 'settings' },
navigationContext: { source: 'toolbar', userId: 789 }
})
// Go back (browser back button)
pop()
// Go back to referrer (with scroll restoration) - requires referrer tracking
goBack()
// Replace current page (supports all formats above)
replace('/book/3')
replace(['bookDetail', { bookId: 456 }])
replace('bookDetail', { bookId: 456 }, { preview: 'true' })
Note: To use named routes with push() and replace(), you need to register your routes first:
import { registerRoutes } from '@keenmate/svelte-spa-router/routes'
registerRoutes({
home: '/',
userProfile: '/user/:userId',
bookDetail: '/book/:bookId'
})
Pass data during navigation without showing it in the URL (similar to WinForms):
import { push, navigationContext } from '@keenmate/svelte-spa-router'
// Navigate with hidden context data
await push('/order-confirmation', {
orderId: 12345,
customer: 'Alice',
totalAmount: 99.99
})
// In the target route component, access the context
const navContext = $derived(navigationContext())
// { orderId: 12345, customer: 'Alice', totalAmount: 99.99 }
Navigation context:
navigationContext() in the target routeAutomatically track and navigate back to the previous route with full context preservation.
Configure in your main.js before mounting the app:
// main.js
import { setIncludeReferrer } from '@keenmate/svelte-spa-router'
setIncludeReferrer('always') // Track referrer for all routes
Configuration Options:
'never' (default) - Disable referrer tracking'notfound' - Track referrer only for 404/catch-all routes'always' - Track referrer for all navigationThe goBack() helper provides the best way to navigate back with automatic scroll restoration:
<script>
import { goBack, navigationContext } from '@keenmate/svelte-spa-router'
const navContext = $derived(navigationContext())
const referrer = $derived(navContext?.referrer)
</script>
{#if referrer}
<button onclick={goBack}>← Back to {referrer.location}</button>
{/if}
How it works:
window.history.back())pop() if no referrer exists⚠️ Important: Use
goBack()instead of manualpush(referrer.location)to get automatic scroll restoration and proper back navigation behavior.
The referrer object includes complete route context:
{
location: '/documents/123', // Previous route path
querystring: 'tab=settings', // Query string
params: { id: '123' }, // Route parameters
routeName: 'documentDetail', // Named route (if using named routes)
scrollX: 0, // Scroll position when leaving
scrollY: 245 // Scroll position when leaving
}
Referrers are automatically preserved in browser history:
history.stategoBack()The referrer is cleared when users manually type a URL or refresh the page.
For cases requiring custom logic before navigation:
<script>
import { push, navigationContext } from '@keenmate/svelte-spa-router'
const navContext = $derived(navigationContext())
const referrer = $derived(navContext?.referrer)
function customGoBack() {
if (!referrer?.location) {
// No referrer - fallback to home
push('/')
return
}
// Custom logic before navigation
if (await confirmUnsavedChanges()) {
const url = referrer.querystring
? `${referrer.location}?${referrer.querystring}`
: referrer.location
push(url)
// Note: Manual push does NOT restore scroll position
}
}
</script>
Migration Note: If you're currently using manual
push(referrer.location)pattern, switch togoBack()for automatic scroll restoration and proper history navigation.
replace()Configure how missing route parameters are handled:
// main.js
import { setParamReplacementPlaceholder } from '@keenmate/svelte-spa-router'
// Set placeholder for missing parameters (default: 'N-A')
setParamReplacementPlaceholder('N-A')
Behavior:
// Route pattern: /users/:userId/:section
// If userId is provided but section is missing:
push('userProfile', { userId: 123 })
// Result: /users/123/N-A
// Missing parameters trigger onNotFound callback for error tracking
Why strict replacement?
onNotFound callback tracks issues for debuggingIn your route components:
<script>
let { routeParams = {} } = $props()
</script>
<p>Book ID: {routeParams.id}</p>
<script>
import { routeParams} from '@keenmate/svelte-spa-router'
// Access current location and querystring
const currentPath = $derived(location())
const query = $derived(querystring())
const routeParams = $derived(routeParams())
</script>
<p>Current page: {currentPath}</p>
<p>Query: {query}</p>
<p>Params: {JSON.stringify(routeParams)}</p>
All helper functions support TypeScript generics for full intellisense:
// Define your types
interface UserParams {
userId: string
tab?: string
}
interface UserQuery {
search?: string
page?: number
tags?: string[]
}
// Use with type parameters for full intellisense
const p = $derived(routeParams<UserParams>())
const q = $derived(query<UserQuery>())
if (p) {
const userId = p.userId // ✅ TypeScript knows this exists
const tab = p.tab || 'profile' // ✅ TypeScript knows this is optional
}
const search = $derived(q.search || '')
const page = $derived(q.page ? Number(q.page) : 1)
const tags = $derived(q.tags || []) // ✅ TypeScript knows this is string[]
The most convenient way to create routes with async loading, metadata, and conditions:
import { createRoute } from '@keenmate/svelte-spa-router/wrap'
import Home from './routes/Home.svelte'
import NotFound from './routes/NotFound.svelte'
const routes = {
'/': Home,
// No wrap() needed! createRoute() handles it for you
'/author/:first/:last?': createRoute({
component: () => import('./routes/Author.svelte'),
title: 'Author Profile',
breadcrumbs: [
{ label: 'Home', path: '/' },
{ label: 'Authors' }
]
}),
// With loading component
'/book/*': createRoute({
component: () => import('./routes/Book.svelte'),
title: 'Book Details',
loadingComponent: LoadingPlaceholder,
loadingParams: { message: 'Loading book...' }
}),
'*': NotFound,
}
For more control, use wrap() directly or with createRouteDefinition():
import { wrap, createRouteDefinition } from '@keenmate/svelte-spa-router/wrap'
import Home from './routes/Home.svelte'
import NotFound from './routes/NotFound.svelte'
const routes = {
'/': Home,
// Direct wrap() syntax
'/author/:first/:last?': wrap({
asyncComponent: () => import('./routes/Author.svelte')
}),
// Using createRouteDefinition() for consistency
'/book/*': wrap(createRouteDefinition({
component: () => import('./routes/Book.svelte'),
loadingComponent: LoadingPlaceholder,
loadingParams: { message: 'Loading book...' }
})),
'*': NotFound,
}
Metadata Access:
Title and breadcrumbs are stored in userData and accessible in route events:
<script>
let { routeParams = {}, userData = {} } = $props()
// Access metadata
const title = userData.title
const breadcrumbs = userData.breadcrumbs || []
</script>
<h1>{title || 'Default Title'}</h1>
{#if breadcrumbs.length > 0}
<nav>
{#each breadcrumbs as crumb}
{#if crumb.path}
<a href={crumb.path} use:link>{crumb.label}</a>
{:else}
<span>{crumb.label}</span>
{/if}
{/each}
</nav>
{/if}
The router provides flexible loading control with support for three distinct patterns, allowing you to choose the approach that best fits your application architecture.
Use loadingComponent with shouldDisplayLoadingOnRouteLoad: true for multi-zone layouts where the Router manages the loading state:
⚠️ You must call
hideLoading()from the route component. WhenshouldDisplayLoadingOnRouteLoad: trueis set, the router mounts the route component immediately but keeps it hidden underloadingComponentand waits for an explicithideLoading()signal before revealing it. If the component never callshideLoading()(forgotten, thrown before reaching it, conditional code path that didn't run), the loading screen stays up forever and the real component never appears — the route is effectively bricked until the user navigates away. In development, aconsole.warnfires after 10 seconds to surface this; production has no automatic recovery.
import { createRoute } from '@keenmate/svelte-spa-router/wrap'
import { hideLoading } from '@keenmate/svelte-spa-router/helpers/route-metadata'
import Loading from './components/Loading.svelte'
const routes = {
'/document/:id': createRoute({
component: () => import('./routes/DocumentDetail.svelte'),
loadingComponent: Loading,
shouldDisplayLoadingOnRouteLoad: true, // Keep loading visible until component signals ready
title: 'Document Detail',
breadcrumbs: [
{ label: 'Home', path: '/' },
{ label: 'Documents', path: '/metadata-demo' },
{ id: 'documentDetail', label: 'Loading...', path: '/document/:id' }
]
})
}
In your component, signal when data is loaded:
<script>
import { onMount } from 'svelte'
import { hideLoading, updateTitle, updateBreadcrumb } from '@keenmate/svelte-spa-router/helpers/route-metadata'
let { routeParams } = $props()
let document = $state(null)
onMount(async () => {
// Fetch data
document = await fetchDocument(routeParams.id)
// Update metadata with loaded data
updateTitle(document.name)
updateBreadcrumb('documentDetail', {
label: document.name,
path: `/document/${routeParams.id}`
})
// Signal that loading is complete
hideLoading()
})
</script>
<h1>{document?.name || 'Loading...'}</h1>
Perfect for:
Components handle their own loading state with no special configuration:
const routes = {
'/product/:id': createRoute({
component: () => import('./routes/ProductDetail.svelte'),
title: 'Product Detail'
})
}
<script>
let { routeParams } = $props()
let product = $state(null)
let loading = $state(true)
onMount(async () => {
product = await fetchProduct(routeParams.id)
loading = false
})
</script>
{#if loading}
<div class="loading">Loading product...</div>
{:else}
<h1>{product.name}</h1>
<p>{product.description}</p>
{/if}
Perfect for:
Define a global loading overlay in your App.svelte that reacts to route loading state:
<!-- App.svelte -->
<script>
import { routeIsLoading } from '@keenmate/svelte-spa-router/helpers/route-metadata'
import Router from '@keenmate/svelte-spa-router'
const isLoading = $derived(routeIsLoading())
</script>
<div class="app">
{#if isLoading}
<div class="global-loading-overlay">
<div class="spinner"></div>
<p>Loading...</p>
</div>
{/if}
<Router {routes} />
</div>
<style>
.global-loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
</style>
Then manually control loading in your components:
<script>
import { showLoading, hideLoading } from '@keenmate/svelte-spa-router/helpers/route-metadata'
let { routeParams } = $props()
let data = $state(null)
async function loadData() {
showLoading() // Show global overlay
try {
data = await fetchData(routeParams.id)
} finally {
hideLoading() // Hide global overlay
}
}
onMount(loadData)
</script>
Perfect for:
You can combine Pattern 1 and Pattern 3 for comprehensive loading feedback:
// Route uses both loadingComponent and triggers global overlay
'/document/:id': createRoute({
component: () => import('./routes/DocumentDetail.svelte'),
loadingComponent: Loading, // Zone-specific loading
shouldDisplayLoadingOnRouteLoad: true, // Also triggers global overlay
title: 'Document Detail'
})
This shows both:
Loading component in the content area (zone-specific)Update page metadata after data loads:
import {
updateTitle, // Update just the title
updateBreadcrumb, // Update specific breadcrumb by ID
updateRouteMetadata // Update full metadata object
} from '@keenmate/svelte-spa-router/helpers/route-metadata'
// Update title only
updateTitle('Invoice.pdf')
// Update specific breadcrumb by ID (partial update)
updateBreadcrumb('documentDetail', {
label: 'Invoice.pdf',
path: '/document/123'
})
// Update full metadata
updateRouteMetadata({
title: 'Invoice.pdf',
breadcrumbs: [
{ label: 'Home', path: '/' },
{ label: 'Documents', path: '/documents' },
{ label: 'Invoice.pdf', path: '/document/123' }
]
})
Access current route metadata reactively:
<script>
import { routeTitle, routeBreadcrumbs, routeContext } from '@keenmate/svelte-spa-router/helpers/route-metadata'
const title = $derived(routeTitle())
const breadcrumbs = $derived(routeBreadcrumbs())
const context = $derived(routeContext())
</script>
<h1>{title || 'Default Title'}</h1>
{#if breadcrumbs.length > 0}
<nav>
{#each breadcrumbs as crumb}
{#if crumb.path}
<a href={crumb.path} use:link>{crumb.label}</a>
{:else}
<span>{crumb.label}</span>
{/if}
{/each}
</nav>
{/if}
Available helpers:
import {
// Loading state control
showLoading, // Manually show loading state
hideLoading, // Hide loading state (signal component is ready)
routeIsLoading, // Check if currently loading (reactive)
// Metadata updates
updateTitle, // Update just the title
updateBreadcrumb, // Update specific breadcrumb by ID
updateRouteMetadata, // Update full metadata object
// Reactive metadata access
routeTitle, // Get current title
routeBreadcrumbs, // Get current breadcrumbs
routeContext // Get full route context object
} from '@keenmate/svelte-spa-router/helpers/route-metadata'
See /loading-demo and /document/:id routes in the example-history app for complete interactive demos.
Basic condition example:
const routes = {
'/admin': wrap({
asyncComponent: () => import('./routes/Admin.svelte'),
conditions: [
// Can be sync or async
async (detail) => {
const user = await checkAuth()
return user.isAdmin
}
]
})
}
What happens when a condition returns false:
The route's component is not mounted — the slot becomes empty. There is no
built-in fallback UI. The router fires the onConditionsFailed event (see
Event handling) and that's it. The consumer decides what
happens next, typically by either redirecting from inside the condition itself
(await push('/login'); return false) or by handling the event globally:
<Router
{routes}
onConditionsFailed={(e) => push('/login')}
/>
If you want batteries-included Unauthorized-component rendering, use the permission system instead — see Conditions vs Permissions for when to reach for which.
svelte-spa-router-5 includes a flexible permission system for role-based access control:
1. Configure the permission system (in main.js before mounting):
import {
configurePermissions,
setCurrentUser
} from '@keenmate/svelte-spa-router/helpers/permissions'
configurePermissions({
checkPermissions: (user, requirements) => {
if (!user) return false
if (!requirements) return true
// Check if user has any of the required permissions
if (requirements.any) {
return requirements.any.some(perm => user.permissions.includes(perm))
}
// Check if user has all required permissions
if (requirements.all) {
return requirements.all.every(perm => user.permissions.includes(perm))
}
return true
},
onUnauthorized: (detail) => {
push('/unauthorized')
}
})
// Push the current user into the permission system. The library keeps an
// internal $state-backed user, so every hasPermission() call site in a
// reactive context (templates, $derived, $effect) re-evaluates automatically
// when you call setCurrentUser() again.
setCurrentUser(null) // logged-out at startup
// Later, on login:
// setCurrentUser({ id: 42, permissions: ['admin.read'] })
// From a websocket permission update:
// setCurrentUser({ ...getCurrentUser(), permissions: newPerms })
// On logout:
// setCurrentUser(null)
Reactivity:
hasPermission()re-evaluates automatically when you callsetCurrentUser()— the function reads from an internal$staterune, so Svelte's tracker registers the dependency in any reactive context (template{#if},$derived,$effect). No subscription wiring needed on your side.If you maintain your own reactive user store and prefer to read from it directly, pass
getCurrentUsertoconfigurePermissions:configurePermissions({ getCurrentUser: () => myUserState.user, ... })Watch out for non-tracked reads (
get(store),localStorage.getItem, etc.) — those won't propagate updates, and your{#if hasPermission(...)}blocks will appear "broken" (only updating on navigation). The defaultsetCurrentUser-based path avoids this footgun entirely.
Re-validating the currently mounted route
hasPermission() reactivity covers UI element visibility — the user's menu
and buttons update live when permissions change. It does not cover the
case where the user is sitting on a protected page when their permissions
are revoked. The router checks route conditions only during navigation, so a
user already on /admin who loses admin permission stays on /admin until
they navigate away.
To handle this case, call revalidateCurrentRoute() after the permission
change:
import { revalidateCurrentRoute } from '@keenmate/svelte-spa-router'
import { setCurrentUser, getCurrentUser } from '@keenmate/svelte-spa-router/helpers/permissions'
socket.on('permissions:updated', (newPerms) => {
setCurrentUser({ ...getCurrentUser(), permissions: newPerms })
revalidateCurrentRoute()
})
This re-runs the matched route's guards and conditions against the current location. On success, nothing visible happens — the component keeps its state (no flicker, no scroll reset, no in-flight form data lost). On failure, the same unauthorized handling that runs for fresh navigation fires here too.
If you want to customize the failure path — e.g. show a confirmation dialog
before redirecting, soft-warn the user, log to an audit trail — provide an
onRevalidationFailure handler:
configurePermissions({
// ... checkPermissions, etc.
onRevalidationFailure: async (detail) => {
const confirmed = await showConfirmDialog(
'Your permissions have changed. Return to the home page?'
)
if (confirmed) {
push('/')
}
// If the user dismisses the dialog, they stay on the current page.
}
})
When onRevalidationFailure is configured, it fires instead of the
standard unauthorized handling for revalidation failures. The
onConditionsFailed Router event still fires for consistency with normal
navigation. Pass onRevalidationFailure: null to clear and fall back to
standard handling.
Calls to revalidateCurrentRoute() within a ~50ms window are coalesced into
a single re-validation pass — safe to call on every websocket message.
2. Protect routes with permissions:
The most convenient way - no wrap() needed!
import { createProtectedRoute } from '@keenmate/svelte-spa-router/helpers/permissions'
import { push } from '@keenmate/svelte-spa-router'
const routes = {
'/': Home,
// No wrap() needed! createProtectedRoute() returns ready-to-use wrapped component
'/admin': createProtectedRoute({
component: () => import('./Admin.svelte'),
permissions: { any: ['admin.read', 'admin.write'] },
loadingComponent: Loading,
title: 'Admin Panel',
breadcrumbs: [
{ label: 'Home', path: '/' },
{ label: 'Admin' }
]
}),
// User needs ALL of these permissions
'/settings': createProtectedRoute({
component: () => import('./Settings.svelte'),
permissions: { all: ['settings.read', 'settings.write'] },
title: 'Settings'
}),
// Combine role-based and resource-based authorization (NEW!)
'/document/:id': createProtectedRoute({
component: () => import('./DocumentDetail.svelte'),
// Role-based: User must have 'read' permission
permissions: { any: ['read'] },
// Resource-based: User must have access to THIS specific document
authorizationCallback: async (detail) => {
const documentId = detail.routeParams.id
const hasAccess = await checkDocumentAccess(documentId)
if (!hasAccess) {
// push(route, routeParams, queryString, navigationContext)
await push('/unauthorized', {}, {}, {
resource: 'document',
id: documentId
})
return false
}
return true
},
loadingComponent: Loading,
shouldDisplayLoadingOnRouteLoad: true,
title: 'Document Detail'
}),
'/unauthorized': Unauthorized,
'*': NotFound
}
For more control or when combining with other wrap options:
import { wrap } from '@keenmate/svelte-spa-router/wrap'
import { createProtectedRouteDefinition } from '@keenmate/svelte-spa-router/helpers/permissions'
const routes = {
'/admin': wrap(createProtectedRouteDefinition({
component: () => import('./Admin.svelte'),
permissions: { any: ['admin.read', 'admin.write'] },
loadingComponent: Loading
}))
}
3. Show/hide UI elements based on permissions:
<script>
import { hasPermission } from '@keenmate/svelte-spa-router/helpers/permissions'
import { link } from '@keenmate/svelte-spa-router'
</script>
<nav>
<a href="/" use:link>Home</a>
{#if hasPermission({ any: ['admin.read'] })}
<a href="/admin" use:link>Admin Panel</a>
{/if}
{#if hasPermission({ all: ['settings.read', 'settings.write'] })}
<a href="/settings" use:link>Settings</a>
{/if}
</nav>
Permission requirements:
any: [...] - User needs at least ONE of these permissions (OR logic)all: [...] - User needs ALL of these permissions (AND logic)Authorization execution order:
When using both permissions and authorizationCallback:
This order ensures fast checks happen first, preventing unnecessary API calls when user doesn't have basic permissions.
Resource-based authorization details:
The authorizationCallback receives a detail object with:
{
route: string,
location: string,
params: Record<string, string>,
query: Record<string, any>,
routeContext: any,
navigationContext: any
}
Perfect for:
See example-permissions/ for a complete working example with mock authentication.
Both gate access to a route, but they have different defaults and different failure paths. Reach for the one that matches your situation:
wrap({ conditions: [...] }) |
createProtectedRoute({ permissions: ... }) |
|
|---|---|---|
| What it is | Low-level primitive: any sync/async predicate(s) you want | Opinionated wrapper around conditions, built on the configured permission system |
| Setup needed | None — just write the function | Call configurePermissions({ checkPermissions, getCurrentUser, onUnauthorized }) once at app start |
| Where the logic lives | Inline in the condition function | Inside checkPermissions (your function), which is reused across every protected route |
| On failure: UI | Empty slot. The matched component does not mount; nothing renders in its place unless the consumer redirects | The configured Unauthorized component mounts (or onUnauthorized callback runs, if set), with unauthorizedBehavior: 'component' | 'navigate' controlling which |
| On failure: event | onConditionsFailed fires with { route, location, querystring, params } |
Same event fires (permissions are conditions under the hood); the unauthorized handling runs in addition |
| When to choose it | Ad-hoc check that doesn't fit a generic permission model — feature flags, subscription state, ownership of a single resource, custom redirects | Role/permission-based access control where the same checkPermissions logic governs many routes and you want a consistent unauthorized UX |
Common combo: use createProtectedRoute for the role check (gets you the Unauthorized UI) and pass extra conditions for one-off checks specific to that route. The router runs them in order — permissions first (fast), then your custom conditions.
<script>
import {link} from '@keenmate/svelte-spa-router'
import active from '@keenmate/svelte-spa-router/active'
</script>
<style>
:global(a.active) {
color: red;
font-weight: bold;
}
</style>
<a href="/books" use:link use:active>Books</a>
svelte-spa-router-5 includes powerful helpers for working with querystrings and filters in a reactive, type-safe way.
Configure once in main.js:
import { configureQuerystring } from '@keenmate/svelte-spa-router/helpers/querystring'
configureQuerystring({
arrayFormat: 'auto' // 'auto', 'repeat', or 'comma'
})
<script>
import { query } from '@keenmate/svelte-spa-router/helpers/querystring'
import { updateQuerystring } from '@keenmate/svelte-spa-router/helpers/querystring-helpers'
// Define your query type for intellisense
interface SearchQuery {
search?: string
page?: number
category?: string
tags?: string[]
}
// Access query parameters reactively
const q = $derived(query<SearchQuery>())
const search = $derived(q.search || '')
const page = $derived(q.page ? Number(q.page) : 1)
const category = $derived(q.category || 'all')
const tags = $derived(q.tags || [])
// Update querystring (merges with existing params)
async function handleSearch(value: string) {
await updateQuerystring({ search: value || undefined, page: 1 })
}
async function changePage(newPage: number) {
await updateQuerystring({ page: newPage })
}
// Remove parameter completely
await updateQuerystring({ category: undefined })
// Keep parameter with empty value
await updateQuerystring({ search: null })
</script>
<input
type="text"
value={search}
oninput={(e) => handleSearch(e.target.value)}
/>
The router automatically detects array formats:
// Auto-detect (default) - handles both formats
// ?tags=foo&tags=bar → { tags: ['foo', 'bar'] }
// ?tags=foo,bar,baz → { tags: ['foo', 'bar', 'baz'] }
// Explicit repeat format
configureQuerystring({ arrayFormat: 'repeat' })
// ?tags=foo&tags=bar
// Explicit comma format
configureQuerystring({ arrayFormat: 'comma' })
// ?tags=foo,bar,baz
import { parseQuerystring, stringifyQuerystring } from '@keenmate/svelte-spa-router/helpers/querystring-helpers'
// Parse
const parsed = parseQuerystring('search=foo&tags=a,b,c', { arrayFormat: 'auto' })
// { search: 'foo', tags: ['a', 'b', 'c'] }
// Stringify
const qs = stringifyQuerystring({ search: 'foo', tags: ['a', 'b'] }, { arrayFormat: 'repeat' })
// 'search=foo&tags=a&tags=b'
The filter system supports both flat and structured filter modes for different API requirements.
Each filter is a separate query parameter:
// main.js
import { configureFilters } from '@keenmate/svelte-spa-router/helpers/filters'
configureFilters({ mode: 'flat' })
<script>
import { filters, updateFilters } from '@keenmate/svelte-spa-router/helpers/filters'
// Define filter type for intellisense
interface ProductFilters {
search?: string
category?: string
status?: 'active' | 'discontinued'
minPrice?: number
maxPrice?: number
}
// Access filters reactively
const f = $derived(filters<ProductFilters>())
const search = $derived(f.search || '')
const category = $derived(f.category || 'all')
const status = $derived(f.status || 'active')
// Update filters (partial updates by default)
async function handleSearchChange(value: string) {
await updateFilters<ProductFilters>({ search: value || undefined })
}
async function clearAllFilters() {
await updateFilters<ProductFilters>({
search: undefined,
category: undefined,
status: undefined
}, { merge: false }) // Replace instead of merge
}
</script>
<!-- Result URL: ?search=java&category=books&status=active -->
Single filter parameter with custom syntax:
// main.js
import { configureFilters } from '@keenmate/svelte-spa-router/helpers/filters'
configureFilters({
mode: 'structured',
paramName: '$filter',
parse: (filterString) => {
// Parse "displayName eq 'john' AND status eq 'active'"
const parts = filterString.split(' AND ')
const result = {}
parts.forEach(part => {
const [field, , value] = part.split(' ')
result[field] = value.replace(/'/g, '')
})
return result
},
stringify: (filters) => {
// Convert object to OData filter string
return Object.entries(filters)
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => `${k} eq '${v}'`)
.join(' AND ')
}
})
<script>
import { filters, updateFilters } from '@keenmate/svelte-spa-router/helpers/filters'
// Same API regardless of mode!
const f = $derived(filters())
const search = $derived(f.search || '')
await updateFilters({ search: 'java', status: 'active' })
// Result URL: ?$filter=search eq 'java' AND status eq 'active'
</script>
// undefined: Always removes the parameter
await updateFilters({ search: undefined })
// Result: parameter removed from URL
// null: Keeps parameter with empty value
await updateFilters({ search: null })
// Result: ?search=
// For querystring helpers, behavior is configurable:
await updateQuerystring({ search: null }, { dropNull: true }) // Removes it (default)
await updateQuerystring({ search: null }, { dropNull: false }) // Keeps as ?search=null
Prevent navigation when there's unsaved work or other conditions that need user confirmation.
Three patterns, all calling the same underlying registerBeforeLeave primitive — the wrapper and helper are conveniences on top. Pick by ergonomics, not capability.
| Mode | Reach for it when… | Trade-off |
|---|---|---|
| PageWrapper (declarative) | You want a page-level guard tied to the component's lifecycle. Drop the wrapper around your page, write the beforeLeave function, done. |
Adds one extra component in the markup. Less control over when the guard is active during the page's lifetime. |
| Direct Registration (imperative) | You need fine control — swap the guard mid-session, toggle it based on a condition, share one guard across multiple components. Call registerBeforeLeave / unregisterBeforeLeave inside onMount/onDestroy (or a $effect). |
You own the lifecycle — easy to forget the cleanup and leak guards across navigations. |
createDirtyCheckGuard (shortcut) |
Your guard is the classic "form has unsaved changes — confirm before leaving" pattern. The helper bakes in the dirty-check + confirm() dialog; just plug in the dirty predicate. |
Only fits the dirty-check shape. You still register it the same way as Direct mode — the helper just saves the confirm boilerplate. |
Tip: the live demo at
/navigation-guard-demo(in the example app) lets you switch between all three modes with the same form, so you can compare them side-by-side.
Create a reusable wrapper component:
<!-- PageWrapper.svelte -->
<script>
import { registerBeforeLeave, unregisterBeforeLeave } from '@keenmate/svelte-spa-router/helpers/navigation-guard'
import { onMount, onDestroy } from 'svelte'
let { beforeLeave = undefined, children } = $props()
onMount(() => {
if (beforeLeave) {
registerBeforeLeave(beforeLeave)
}
})
onDestroy(() => {
if (beforeLeave) {
unregisterBeforeLeave(beforeLeave)
}
})
</script>
{@render children?.()}
Use in your page components:
<script>
import PageWrapper from './PageWrapper.svelte'
import { NavigationCancelledError } from '@keenmate/svelte-spa-router/helpers/navigation-guard'
let formData = $state({ name: '', email: '' })
let formIsDirty = $state(false)
async function beforeLeave(ctx) {
if (formIsDirty && !confirm(`Leave "${ctx.from}" with unsaved changes?`)) {
throw new NavigationCancelledError()
}
}
</script>
<PageWrapper {beforeLeave}>
<form>
<input bind:value={formData.name} oninput={() => formIsDirty = true} />
<input bind:value={formData.email} oninput={() => formIsDirty = true} />
</form>
</PageWrapper>
Register guards directly without a wrapper:
<script>
import { registerBeforeLeave, unregisterBeforeLeave, NavigationCancelledError } from '@keenmate/svelte-spa-router/helpers/navigation-guard'
import { onMount, onDestroy } from 'svelte'
let formIsDirty = $state(false)
async function beforeLeave(ctx) {
if (formIsDirty && !confirm("Unsaved changes. Leave anyway?")) {
throw new NavigationCancelledError()
}
}
onMount(() => registerBeforeLeave(beforeLeave))
onDestroy(() => unregisterBeforeLeave(beforeLeave))
</script>
<form>
<input oninput={() => formIsDirty = true} />
</form>
Use the createDirtyCheckGuard helper for common scenarios:
<script>
import { registerBeforeLeave, unregisterBeforeLeave, createDirtyCheckGuard } from '@keenmate/svelte-spa-router/helpers/navigation-guard'
import { onMount, onDestroy } from 'svelte'
let formIsDirty = $state(false)
const beforeLeave = createDirtyCheckGuard(
() => formIsDirty,
"You have unsaved changes. Leave anyway?"
)
// Add isDirty for browser beforeunload warning
beforeLeave.isDirty = () => formIsDirty
onMount(() => registerBeforeLeave(beforeLeave))
onDestroy(() => unregisterBeforeLeave(beforeLeave))
</script>
The beforeLeave handler receives a context object with navigation details:
interface NavigationContext {
from: string // Current route
to: string // Destination route
params?: Record<string, string>
querystring?: string
}
Guards also work with browser back/forward/close when using isDirty property:
const beforeLeave = createDirtyCheckGuard(() => formIsDirty)
beforeLeave.isDirty = () => formIsDirty // Enables browser beforeunload warning
See /navigation-guard-demo in the example-history app for a complete interactive demo.
<Router {routes} restoreScrollState={true} />
<!-- Parent router -->
<script>
import Router from '@keenmate/svelte-spa-router'
const routes = {
'/hello': Hello,
'/hello/*': Hello,
}
</script>
<!-- In Hello.svelte (child router) -->
<script>
import Router from '@keenmate/svelte-spa-router'
const prefix = '/hello'
const routes = {
'/:name': NameView
}
</script>
<h2>Hello!</h2>
<Router {routes} {prefix} />
<Router
{routes}
onRouteLoading={(e) => console.log('Loading:', e.detail)}
onRouteLoaded={(e) => console.log('Loaded:', e.detail)}
onConditionsFailed={(e) => console.log('Failed:', e.detail)}
onNotFound={(e) => console.log('Not found:', e.detail)}
/>
Event payloads (e.detail):
| Event | Payload shape | Fires when |
|---|---|---|
onRouteLoading |
{ route, location, relativeLocation, querystring, params } |
Before guards/conditions run for a matched route |
onRouteLoaded |
{ route, location, relativeLocation, querystring, params, component?, name?, routeContext?, zones? } |
After the matched component (and any async children) successfully mounts. zones is set for multi-zone routes; component/name/routeContext for single-component routes |
onConditionsFailed |
{ route, location, relativeLocation, querystring, params } |
A condition in wrap({ conditions }) returned false. The slot is now empty — handle the redirect here or inside the condition itself |
onNotFound |
{ location, relativeLocation, querystring } |
No route matched, or the '*' catch-all matched (it fires for both, so consumers can always log 404s) |
location vs relativeLocation:
location is the full app URL as the browser sees it (e.g. /test/embed/missing). Use this for logging, analytics, or re-navigating with push() (which always takes app-wide paths).relativeLocation is the URL after the Router's prefix has been stripped (e.g. /missing for a <Router prefix="/test/embed" />). Use this when you're reasoning about what this Router instance saw — it matches how routes inside the Router were defined.prefix, the two fields are identical.Define routes in a hierarchical tree structure as an alternative to flat definitions. Child paths are automatically concatenated to parent paths, and routes inherit metadata from parents.
Enable hierarchical mode first (disabled by default — routes are flat with no inheritance):
// main.js - before mounting app
import { setHierarchicalRoutesEnabled } from '@keenmate/svelte-spa-router'
setHierarchicalRoutesEnabled(true) // default: false
Define routes using tree structure:
import { createHierarchy } from '@keenmate/svelte-spa-router/helpers/hierarchy'
const routes = createHierarchy({
'/admin': {
name: 'admin',
component: AdminLayout,
breadcrumbs: [
{ label: 'Home', path: '/' },
{ label: 'Admin' }
],
permissions: { any: ['admin'] },
children: {
'users': {
name: 'adminUsers',
component: AdminUsers,
breadcrumbs: [{ label: 'Users' }],
// Inherits 'admin' permission from parent
// Effective path: /admin/users
// Effective breadcrumbs: [Home, Admin, Users]
children: {
':id': {
name: 'adminUserDetail',
component: AdminUserDetail,
breadcrumbs: [{ label: 'User Detail' }]
// Inherits 'admin' permission from ancestors
// Effective path: /admin/users/:id
// Effective breadcrumbs: [Home, Admin, Users, User Detail]
}
}
},
'settings': {
component: AdminSettings,
breadcrumbs: [{ label: 'Settings' }],
permissions: { any: ['settings:manage'] }
// Requires BOTH 'admin' AND 'settings:manage'
}
}
}
})
// Navigate using names
await push('adminUserDetail', { id: 123 })
// Results in: /admin/users/123
Key features:
push(name, params)Combine with flat routes:
const hierarchicalRoutes = createHierarchy({ /* ... */ })
const flatRoutes = { '/': Home, '/about': About }
const routes = {
...hierarchicalRoutes,
...flatRoutes
}
// Core router
import Router from '@keenmate/svelte-spa-router'
// Navigation utilities
import { push, replace, pop, goBack, location, querystring, routeParams, navigationContext } from '@keenmate/svelte-spa-router'
// Named routes (for use with push/replace/link)
import { registerRoutes, buildUrl, defineRoutes } from '@keenmate/svelte-spa-router/routes'
// Route creation (recommended - no wrap() needed!)
import { createRoute, createRouteDefinition } from '@keenmate/svelte-spa-router/wrap'
// Route wrapping (advanced - for manual wrapping)
import { wrap } from '@keenmate/svelte-spa-router/wrap'
// Tree/nested route structure (alternative to flat routes)
import { createHierarchy } from '@keenmate/svelte-spa-router/helpers/hierarchy'
// Active link highlighting
import active from '@keenmate/svelte-spa-router/active'
// Configuration
import { setHashRoutingEnabled, setBasePath, setParamReplacementPlaceholder, setHierarchicalRoutesEnabled, setIncludeReferrer } from '@keenmate/svelte-spa-router'
// Querystring helpers (shared reactive state)
import { configureQuerystring, query } from '@keenmate/svelte-spa-router/helpers/querystring'
// Querystring helpers (individual functions)
import {
parseQuerystring,
stringifyQuerystring,
updateQuerystring
} from '@keenmate/svelte-spa-router/helpers/querystring-helpers'
// Filter helpers
import {
configureFilters,
filters,
updateFilters
} from '@keenmate/svelte-spa-router/helpers/filters'
// Permission system (recommended - no wrap() needed!)
import {
configurePermissions,
createProtectedRoute,
hasPermission
} from '@keenmate/svelte-spa-router/helpers/permissions'
// Permission system (advanced - for manual wrapping)
import {
createPermissionCondition,
createProtectedRouteDefinition
} from '@keenmate/svelte-spa-router/helpers/permissions'
// Navigation guards
import {
registerBeforeLeave,
unregisterBeforeLeave,
NavigationCancelledError,
createDirtyCheckGuard
} from '@keenmate/svelte-spa-router/helpers/navigation-guard'
// Route metadata & loading control
import {
showLoading,
hideLoading,
routeIsLoading,
updateTitle,
updateBreadcrumb,
updateRouteMetadata,
routeTitle,
routeBreadcrumbs,
routeContext
} from '@keenmate/svelte-spa-router/helpers/route-metadata'
<script>
import { routeParams } from '@keenmate/svelte-spa-router'
import { query } from '@keenmate/svelte-spa-router/helpers/querystring'
import { filters } from '@keenmate/svelte-spa-router/helpers/filters'
import active from '@keenmate/svelte-spa-router/active'
// Define types for intellisense
interface RouteParams {
id: string
}
interface QueryParams {
tab?: string
search?: string
}
// Get route data reactively
const routeParams = $derived(routeParams<RouteParams>())
const queryParams = $derived(query<QueryParams>())
const currentFilters = $derived(filters())
// Use in your component
const id = $derived(routeParams?.id)
const tab = $derived(queryParams.tab || 'overview')
const search = $derived(queryParams.search || '')
</script>
<!-- Navigation with active highlighting -->
<nav>
<a href="/" use:link use:active>Home</a>
<a href="/about" use:link use:active>About</a>
</nav>
<!-- Display current route -->
<p>Current: {location()}</p>
This repository includes three complete example applications:
example/ - Hash mode routing with basic featuresexample-history/ - History mode with clean URLs, querystring demos, and filter demosexample-permissions/ - Permission-based routing with role managementRun examples:
cd example-history
npm install
npm run dev
Production-ready error handling for your Svelte app.
// main.js
import { configureGlobalErrorHandler } from '@keenmate/svelte-spa-router/helpers/error-handler'
import GlobalErrorHandler from '@keenmate/svelte-spa-router/helpers/GlobalErrorHandler'
configureGlobalErrorHandler({
onError: (error, errorInfo, context) => {
// Log to Sentry, LogRocket, etc.
Sentry.captureException(error, { extra: errorInfo })
// Show a toast/snackbar via your own UI library
toast.error(`Something went wrong: ${error.message}`)
},
strategy: 'navigateSafe', // Navigate to home on error
safeRoute: '/',
maxRestarts: 3,
restartWindow: 60000, // 1 minute
isDevelopment: import.meta.env.DEV
})
<!-- App.svelte -->
<script>
import GlobalErrorHandler from '@keenmate/svelte-spa-router/helpers/GlobalErrorHandler'
</script>
<GlobalErrorHandler>
<Router {routes} />
</GlobalErrorHandler>
navigateSafe (default) - Navigate to safe route (e.g., home page)restart - Reload the page with loop preventionshowError - Display error component and let user decidecustom - Execute custom recovery logic via onRecover callbackconfigureGlobalErrorHandler({
strategy: 'custom',
onRecover: (error, errorInfo, context, helpers) => {
const { restart, navigate, showError, canRestart } = helpers
if (error.name === 'ChunkLoadError') {
restart() // New deployment - safe to restart
} else if (error.message.includes('auth')) {
navigate('/login')
} else {
navigate('/')
}
}
})
ErrorDisplay or your own component)onError callback for wiring up your toast/snackbar library, Sentry, analytics, etc.Track 404s for analytics and monitoring:
<Router
{routes}
onNotFound={(e) => {
console.log('404:', e.detail.location)
// Send to Sentry
Sentry.captureMessage('404 Not Found', {
extra: {
path: e.detail.location,
querystring: e.detail.querystring
}
})
// Send to Google Analytics
gtag('event', 'page_not_found', {
page_path: e.detail.location
})
}}
/>
The onNotFound callback fires when:
'*') matches (user sees your 404 page)For full documentation, see:
MIT License - see LICENSE for details.