svelte-router: Svelte 3–5 SPA Routing Without SvelteKit
@shaun/svelte-router is a lightweight client-side router for Svelte 3, 4, and 5 — no SvelteKit required. Declarative links, dynamic params with regex, wildcards, hash or HTML5 history, zero runtime dependencies.
Current release: 2.0.2 (June 2026). Peer dependency: svelte@^3 || ^4 || ^5. The 2.x line targets Svelte 5 while keeping the same minimal API that worked on 3 and 4.
SvelteKit vs this router
| Need | Use |
|---|---|
| SSR, file-based routes, load functions, form actions | SvelteKit |
| Existing Vite + Svelte SPA, client routing only | @shaun/svelte-router |
| Embed Svelte UI inside a non-Svelte backend | @shaun/svelte-router |
Deploy under a subpath (/demo/) with clean URLs |
@shaun/svelte-router (base option) |
| Full-stack app with adapters and server endpoints | SvelteKit |
This package is not a Kit alternative for full apps. It is the router you reach for when Kit would be oversized.
Install
npm install @shaun/svelte-router
Works with npm, bun, and yarn. Only peer dependency: svelte.
Quick start
<script>
import { createRouter, link, Link, View } from '@shaun/svelte-router'
import Home from './Home.svelte'
import User from './User.svelte'
import NotFound from './NotFound.svelte'
const routes = [
{ path: '/', component: Home },
{ path: '/users/:userId(\\d+)', component: User },
{ path: '*', component: NotFound }
]
const router = createRouter({ routes })
</script>
<Link href="/">Home</Link>
<Link href="/users/123">Someone</Link>
<a use:link href="/users/111">a link with action</a>
<View></View>
Route params are passed as props. Classic Svelte (3/4 and Svelte 5 compatibility mode):
<script>
export let userId
</script>
<div>User ID: {userId}</div>
Svelte 5 runes style works the same way when the component opts into runes — params still arrive as props:
<script>
let { userId } = $props()
</script>
<div>User ID: {userId}</div>
Highlights
- Dynamic params with custom regex —
/users/:userId(\d+)only matches numbers; put more specific routes first. - Wildcard routes —
{ path: '*', component: NotFound }. - Active link states —
activeClass/exactActiveClass(defaults:active/exact-active)./users/123activates/users;/users2does not. - Programmatic navigation —
router.push('/users', { page: 2 })androuter.replace(...), with query-string merging. - Replace links —
<Link href="..." replace>skips a history entry. - Base path —
base: '/demo'withvite build --base=/demo/. - Two history modes —
hashfor static hosts;webfor clean URLs. - Zero runtime dependencies — just Svelte.
Link behavior
The link action and <Link> only intercept same-origin app navigation. External URLs, target="_blank", download, and modified clicks (Cmd/Ctrl-click) keep native browser behavior — so you can mix internal routes and outbound links without special cases.
How the matcher actually works
“Put more specific routes first” appears in every router's docs, usually without saying what enforces it. Here it is literally the array index, and knowing that explains both the ordering rule and the benchmark numbers below.
Routes are compiled once, into three shapes. A path with no : stays a plain string in a Map. A path with params compiles to a RegExp with named capture groups — :userId(\d+) becomes (?<userId>\d+) — so params is just the match's groups object. * compiles to /^.*$/ and is kept aside as a wildcard.
Dynamic routes are indexed twice. First by segment count, then by first static segment:
{ path: '/users/:id', component: User } // segs 2, keyed under "users"
{ path: '/:lang/about', component: About } // segs 2, no static head → fallback list
A two-segment path only ever tests two-segment patterns, and only those whose first segment matches or is itself a param. The rest of the table is never touched.
Every route keeps its array index, and that index is the tie-break. Each candidate source — the static map, the keyed bucket, the fallback bucket, the wildcard — can offer at most one “next” route, and the matcher repeatedly takes whichever has the lowest declaration order, running that route's regex only when it is the front-runner. So this:
const routes = [
{ path: '/users/new', component: NewUser }, // order 0 — wins
{ path: '/users/:id', component: User } // order 1
]
behaves differently from the same two lines swapped. That is deliberate: the route table is a Svelte array you already control, and reading it top-to-bottom tells you exactly what will match.
The static early exit is the reason the benchmark looks the way it does. Before touching any bucket, the matcher compares the static hit's order against the lowest order of any dynamic route in the table. If the static route was declared first, it returns immediately — no segment scan, no bucket lookup, no regex. That is one Map.get plus two integer comparisons, and it is why the static case below runs an order of magnitude faster than the dynamic ones.
Compared with the Alpine router
I maintain a router for Alpine.js with the same two-level index and the opposite answer to the tie-break question:
| @shaun/svelte-router | @shaun/alpinejs-router | |
|---|---|---|
| Tie-break | Declaration order (array index) | Specificity score per segment |
| Reordering routes | Changes which one matches | Changes nothing |
| Route table lives in | A JS array you control | HTML <template> elements |
| Why | An array has an obvious reading order | Template position in a document does not imply intent |
Neither rule is more correct — they follow from where the routes are written. If your routes are a list, order is meaningful and should be honored. If they are scattered through a document, ordering by accident of markup position would be a trap, so specificity decides instead.
HTML5 mode server setup
With web mode, direct visits to nested paths need a catch-all:
nginx:
location / {
try_files $uri $uri/ /index.html;
}
Apache:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
Caddy v2 — try_files must name index.html explicitly, and file_server has to come after it:
example.com {
root * /srv/dist
encode gzip
try_files {path} /index.html
file_server
}
Performance
Matcher microbenchmark on Node.js v26, ~4000 routes. Matching only — not full navigation or component mount:
| Case | ops/sec | us/op |
|---|---|---|
| match static | 28,051,797 | 0.036 |
| match dynamic first | 1,482,711 | 0.674 |
| match dynamic last | 1,161,006 | 0.861 |
| match no dynamic bucket | 12,001,774 | 0.083 |
| match no keyed dynamic | 7,300,384 | 0.137 |
Read alongside the section above: match static is the early exit — a Map.get and two comparisons. No dynamic bucket is a path whose segment count matches nothing, so it exits after the segment scan. Dynamic first and dynamic last differ by about 25% because declaration order genuinely is a linear walk; that is the cost of the ordering guarantee, and at ~0.7 µs across 4000 routes it is not a cost worth optimizing away.
Alternatives
| Router | History default | Params | Precedence | Notes |
|---|---|---|---|---|
| @shaun/svelte-router | HTML5 (web) |
Props, regex-constrained | Declaration order | Zero runtime deps; Svelte 3/4/5 |
| svelte-spa-router | Hash only | Props + store | Declaration order | Hash-first by design; very widely used |
| svelte-routing | HTML5 | Props | Ranked specificity | <Router>/<Route> component nesting |
| tinro | HTML5 | Props | Nested/declarative | Tiny; supports nested routes and redirects |
| routify | HTML5 | File-based | File tree | Filesystem routing; closer to Kit in scope |
Choose @shaun/svelte-router when you want HTML5 history as the default, regex-constrained params, predictable declaration-order matching, and no runtime dependencies. If you already use svelte-spa-router and hash mode is fine, there is no reason to migrate. If you want nested layouts, tinro or routify will fit better than either.
Rough migration shape
- Replace the route table with
createRouter({ routes })(path+component). - Swap navigation components for
<Link>/use:link. - Read params as component props instead of a store-only API (if that is what you used).
- Set history mode (
weborhash) andbaseif you deploy under a subpath. - Check your route order — it is authoritative here. Put
/users/newabove/users/:id.
FAQ
Does 2.x support Svelte 5?
Yes. Peer range includes ^5. Params are still props — use export let or let { id } = $props().
Does route order matter?
Yes, and it is the whole precedence rule: the first matching route in the array wins. Declare /users/new before /users/:id, or constrain the param with /users/:id(\d+).
When should I use SvelteKit instead?
Whenever you need SSR, +page/+layout loaders, or server endpoints. This router is client-only.
Hash or HTML5?
Hash needs no server help. HTML5 needs the catch-all config above (or a host that rewrites to index.html).
Related projects
The Alpine.js router is the same index with the opposite precedence rule — the comparison above is the short version. Further back there is my first npm package from 2011, and on the Go side, simpleconf. All projects: Projects.
Project links
- npm: @shaun/svelte-router (v2.0.2)
- GitHub: shaunlee/svelte-router
- License: MIT
Building a Svelte SPA without SvelteKit? Install it, wire createRouter + <View>, and ship. Issues and pull requests are welcome.