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
) routingparams()
, query()
, and filters()
with intellisense/book/:id?
) and moreThis module is released under MIT license.
npm install @keenmate/svelte-spa-router
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 {location, querystring, params} from '@keenmate/svelte-spa-router'
</script>
<p>Current location: {location()}</p>
<p>Querystring: {querystring()}</p>
<p>Params: {JSON.stringify(params())}</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.
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:
import {push, pop, replace} from '@keenmate/svelte-spa-router'
// Navigate to a new page
push('/book/42')
// Go back
pop()
// Replace current page
replace('/book/3')
In your route components:
<script>
let { params = {} } = $props()
</script>
<p>Book ID: {params.id}</p>
<script>
import {location, querystring, params} from '@keenmate/svelte-spa-router'
// Access current location and querystring
const currentPath = $derived(location())
const query = $derived(querystring())
const routeParams = $derived(params())
</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(params<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[]
import {wrap} from '@keenmate/svelte-spa-router/wrap'
import Home from './routes/Home.svelte'
import NotFound from './routes/NotFound.svelte'
const routes = {
'/': Home,
// Dynamically imported component
'/author/:first/:last?': wrap({
asyncComponent: () => import('./routes/Author.svelte')
}),
// With loading component
'/book/*': wrap({
asyncComponent: () => import('./routes/Book.svelte'),
loadingComponent: LoadingPlaceholder,
loadingParams: {message: 'Loading book...'}
}),
'*': NotFound,
}
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:
import { wrap } from '@keenmate/svelte-spa-router/wrap'
import { createProtectedRoute } from '@keenmate/svelte-spa-router/helpers/permissions'
const routes = {
'/': Home,
// User needs at least one of these permissions
'/admin': wrap(createProtectedRoute({
component: () => import('./Admin.svelte'),
permissions: { any: ['admin.read', 'admin.write'] },
loadingComponent: Loading
})),
// User needs ALL of these permissions
'/settings': wrap(createProtectedRoute({
component: () => import('./Settings.svelte'),
permissions: { all: ['settings.read', 'settings.write'] }
})),
'/unauthorized': Unauthorized,
'*': NotFound
}
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)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)}
/>
// Core router
import Router from '@keenmate/svelte-spa-router'
// Navigation utilities
import { link, push, pop, replace, location, querystring, params } from '@keenmate/svelte-spa-router'
// Route wrapping (async, conditions, loading)
import { wrap } from '@keenmate/svelte-spa-router/wrap'
// Active link highlighting
import active from '@keenmate/svelte-spa-router/active'
// Configuration
import { setHashRoutingEnabled, setBasePath } 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
import {
configurePermissions,
createPermissionCondition,
createProtectedRoute,
hasPermission
} from '@keenmate/svelte-spa-router/helpers/permissions'
// Navigation guards
import {
registerBeforeLeave,
unregisterBeforeLeave,
NavigationCancelledError,
createDirtyCheckGuard
} from '@keenmate/svelte-spa-router/helpers/navigation-guard'
<script>
import { link, location, params } 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(params<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
For full documentation, see:
MIT License - see LICENSE for details.