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) routingrouteParams(), query(), and filters() with intellisenseonNotFound callback for analytics and monitoring/book/:id?) and moreThis module is released under MIT license.
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+.
The router includes a built-in debug logging system to help troubleshoot routing issues during development.
// main.js
import { setDebugLoggingEnabled } from '@keenmate/svelte-spa-router/utils'
// Enable debug logs in development only
if (import.meta.env.DEV) {
setDebugLoggingEnabled(true)
}
When enabled, the router displays color-coded console logs for:
[Router] in orange) - Route matching, component loading, guard execution, metadata updates[Router:Utils] in green) - push(), pop(), replace(), goBack() callsExample output:
[Router] Running pipeline for: /document/123
[Router] Route loaded successfully: /document/:id
[Router:Utils] Called - navigationContext: { source: 'menu' }
[Router] Scroll effect triggered - restoreScrollState: true
To focus on specific router logs in your browser console, use the filter feature:
Router in the Console filter boxRouter in the Console filter inputRouter in the Filter fieldimport { getDebugLoggingEnabled } from '@keenmate/svelte-spa-router/utils'
if (getDebugLoggingEnabled()) {
console.log('Router debug logging is active')
}
Debug 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/utils'
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,
}
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
pop()
// 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 access information about the previous route:
// main.js - Enable referrer tracking
import { setIncludeReferrer } from '@keenmate/svelte-spa-router/utils'
setIncludeReferrer('always') // Track referrer for all routes
// Options: 'never' (default), 'notfound' (404 only), 'always'
<!-- In any route component -->
<script>
import { push, navigationContext } from '@keenmate/svelte-spa-router/utils'
const navContext = $derived(navigationContext())
const referrer = $derived(navContext?.referrer)
function goBack() {
if (referrer?.location) {
const url = referrer.querystring
? `${referrer.location}?${referrer.querystring}`
: referrer.location
push(url)
}
}
</script>
{#if referrer}
<button onclick={goBack}>← Go Back to {referrer.location}</button>
{/if}
Referrer object contains:
location - Previous route path (e.g., '/documents/123')querystring - Previous query stringparams - Previous route parametersrouteName - Previous route name (if using named routes)Benefits over history.back():
replace() navigationConfigure how missing route parameters are handled:
// main.js
import { setParamReplacementPlaceholder } from '@keenmate/svelte-spa-router/utils'
// 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:
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, routeUserData } from '@keenmate/svelte-spa-router/helpers/route-metadata'
const title = $derived(routeTitle())
const breadcrumbs = $derived(routeBreadcrumbs())
const userData = $derived(routeUserData())
</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
routeUserData // Get full userData 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
}
]
})
}
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 } from '@keenmate/svelte-spa-router/helpers/permissions'
import { get } from 'svelte/store'
import { currentUser } from './stores/auth'
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
},
getCurrentUser: () => get(currentUser),
onUnauthorized: (detail) => {
push('/unauthorized')
}
})
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.
<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.
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)}
/>
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:
// main.js - before mounting app
import { setHierarchicalRoutesEnabled } from '@keenmate/svelte-spa-router/utils'
setHierarchicalRoutesEnabled(true)
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 { routeParams, navigationContext } from '@keenmate/svelte-spa-router'
// Named routes (for use with push/replace/link)
import { registerRoutes, buildUrl } 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 } from '@keenmate/svelte-spa-router/utils'
// 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,
routeUserData
} 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 })
},
strategy: 'navigateSafe', // Navigate to home on error
safeRoute: '/',
maxRestarts: 3,
restartWindow: 60000, // 1 minute
showToast: true,
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('/')
}
}
})
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.