svelte-router: Svelte 3–5 SPA Routing Without SvelteKit

· 8 min read

@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 statesactiveClass / exactActiveClass (defaults: active / exact-active). /users/123 activates /users; /users2 does not.
  • Programmatic navigationrouter.push('/users', { page: 2 }) and router.replace(...), with query-string merging.
  • Replace links<Link href="..." replace> skips a history entry.
  • Base pathbase: '/demo' with vite build --base=/demo/.
  • Two history modeshash for static hosts; web for clean URLs.
  • Zero runtime dependencies — just Svelte.

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.

svelte-router candidate merge Four candidate sources each expose their next match with its declaration order; the matcher repeatedly takes the lowest order until one matches. Lowest declaration order wins each source offers its next candidate; the matcher merges them by array index static Map get(path) order 0 keyed dynamic segs 2 · users order 3 fallback dyn. :param first order 5 wildcard * /^.*$/ order 9 take the lowest order that still matches a regex only runs when its route is the current front-runner { component, params } Dashed: early exit — when the static hit already outranks every dynamic route, no bucket is consulted and no regex runs at all.

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

  1. Replace the route table with createRouter({ routes }) (path + component).
  2. Swap navigation components for <Link> / use:link.
  3. Read params as component props instead of a store-only API (if that is what you used).
  4. Set history mode (web or hash) and base if you deploy under a subpath.
  5. Check your route order — it is authoritative here. Put /users/new above /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).

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.

Building a Svelte SPA without SvelteKit? Install it, wire createRouter + <View>, and ship. Issues and pull requests are welcome.