alpinejs-router: HTML-first Client-side Routing for Alpine.js
@shaun/alpinejs-router is a lightweight client-side router for Alpine.js 3.x — dynamic params, external templates, hash or HTML5 history modes, zero runtime dependencies.
If you already ship an Alpine site and need in-browser routing, you should not have to pull in a full SPA framework. Current release: 1.3.4 (June 2026). Peer dependency: alpinejs@^3. pinecone-router, the most widely used router in the Alpine ecosystem, credits this project in its README as a reference implementation — the exact wording is below.
Who it is for (and who it is not)
Use it when:
- You have a multi-view Alpine.js UI on a static host or a small backend
- You want declarative routes in HTML (
x-route/x-link), not a JS route table - Bundle size and “no build step” matter more than nested layouts and data loaders
Skip it when:
- You need SSR, file-based routing, or a full app framework (use a real SPA stack)
- You already committed to another Alpine router and are happy with it
- Your “routes” are just a few hash tabs — plain Alpine state may be enough
Install
npm install @shaun/alpinejs-router
import Alpine from 'alpinejs'
import router from '@shaun/alpinejs-router'
Alpine.plugin(router)
Alpine.start()
Works with yarn and bun. CDN build for zero-build setups (pin a version in production):
<script src="https://unpkg.com/@shaun/[email protected]/dist/cdn.min.js" defer></script>
<script src="https://unpkg.com/[email protected]/dist/cdn.min.js" defer></script>
Load the router script before Alpine so the plugin can register.
Quick start
Routes live in HTML. Links use x-link:
<body x-data>
<nav>
<a x-link href="/">Home</a>
<a x-link href="/hello/alpine">Hello</a>
</nav>
<template x-route="/">
<main>Home</main>
</template>
<template x-route="/hello/:name">
<main>Hello <span x-text="$router.params.name"></span></main>
</template>
<template x-route.notfound>
<main>Not found</main>
</template>
</body>
No build step, no framework — just Alpine.
Highlights
- Dynamic params —
/users/:id→$router.params.id; optional regex like/:orderId(\d+)when two routes share a path shape. - External templates —
template="/somewhere.html"with preload and inline fallbacks if fetch fails. - Programmatic navigation —
$router.push('/path')and$router.replace('/path'). - Active link states —
x-link.activityaddsactive/exact-active(configurable). - Two history modes —
hashon any static host;web(HTML5) when the server has a catch-all fallback. - Lightweight — single Alpine plugin, no runtime dependencies, no build-tool requirement.
How the matcher actually works
The README documents the API. This is the part underneath it — worth knowing because it decides which route wins when two of them could match the same URL.
A route without : is never a regex. URLPattern.build() returns the path string unchanged, and it lands in a Set. Matching a static route is one Set.has() — which is why the static case below is several times faster than everything else, and why adding a thousand static routes costs nothing per navigation.
Dynamic routes are indexed twice before any regex runs. First by segment count, then by first segment:
// /teams/:teamId/projects → depth 3, keyed under "teams"
// /:lang/about → depth 2, no static first segment → wildcard bucket
So /teams/42/projects only ever tests patterns that are three segments long and start with teams, plus the depth-3 patterns whose first segment is itself a param. Everything else in the table is skipped without being touched.
Specificity decides ties, not declaration order. This is the part most likely to surprise you if you come from an order-sensitive router. Each segment is scored — static 3, param with custom regex 2, bare param 1 — and the bucket is kept sorted by that score, comparing segment by segment:
/users/settings → [3, 3] ← always tried first
/users/:id(\d+) → [3, 2]
/users/:id → [3, 1]
/:section/:id → [1, 1] ← always tried last
You can declare those in any order and get the same result. The practical consequence: you do not need to hand-order your routes, and moving a <template x-route> around in your HTML cannot silently change which one matches.
Params come from named capture groups. :teamId compiles to (?<teamId>[^/]+), and :orderId(\d+) to (?<orderId>\d+) — so $router.params is just the regex's groups object. That is also why a custom regex is the right tool when two routes share a shape: /orders/:id(\d+) and /orders/new cannot collide.
is() and notfound() reuse the same machinery. is() memoizes compiled patterns in a cache keyed by route string, so repeated $router.is('/users/:id') calls in a reactive expression stay cheap. notfound() walks the identical index and only reports true when every candidate misses — hence its cost tracks the miss case, not the route count.
Performance
Matcher microbenchmark (npm run bench:router) on Node.js v26, 4000 routes (1000 per type). This measures route matching only — not DOM paint or navigation side effects:
| Case | ops/sec | us/op |
|---|---|---|
| match static | 6,253,583 | 0.160 |
| match dynamic first | 1,271,974 | 0.786 |
| match dynamic last | 1,287,858 | 0.776 |
| match miss | 4,129,862 | 0.242 |
| cached is() | 3,610,343 | 0.277 |
| notfound miss | 4,148,457 | 0.241 |
The numbers follow directly from the structure above. Static is a Set hit. “Dynamic first” and “dynamic last” land within a few percent of each other — the double index means position in the table does not matter, which is the whole point. Misses stay fast because a path with no matching depth bucket exits before any regex runs.
alpinejs-router vs pinecone-router
pinecone-router is one of the most popular routers in the Alpine ecosystem. Both solve client-side routing; the trade-offs differ:
| @shaun/alpinejs-router | pinecone-router | |
|---|---|---|
| Style | Declarative HTML routes (x-route) |
More feature-oriented (middleware, named routes, etc.) |
| Dependencies | Zero runtime deps | Larger feature surface |
| Route precedence | Specificity score, order-independent | Order-sensitive matching |
| Best fit | Small Alpine sites that want HTML-first routing | Apps that want middleware, handlers, and a bigger toolkit |
| Relationship | Reference implementation cited by pinecone | Credits this project in its README |
Neither is universally “better.” Pick HTML-first minimalism here; pick pinecone when you need its extra surface area.
From pinecone’s README:
Code from @shaun/alpinejs-router is licensed under the MIT License. Copyright (c) 2022 Shaun Li
@shaun/alpinejs-router for being a reference of how things can be done differently.
HTML5 mode on a static host
With web mode, nested URLs need a catch-all so refresh does not 404. nginx example:
location / {
try_files $uri $uri/ /index.html;
}
Use hash mode when you cannot change server config.
FAQ
Does it work with Alpine.js 2?
No. Peer dependency is Alpine.js 3 (alpinejs@^3).
Does the order of my x-route templates matter?
No. Routes are sorted by specificity — static segments beat regex params, which beat bare params — so /users/settings wins over /users/:id no matter which you declare first.
Hash or HTML5 history?
Hash works everywhere. HTML5 needs a server fallback (or a host that rewrites to index.html).
Can I load templates from other files?
Yes — template="/path.html" on the route, with optional preload and fallback markup.
Is this a replacement for a full SPA framework?
No. It is routing for Alpine. No SSR, no data loaders, no nested layout system.
Related projects
The Svelte router shares this two-level index but resolves ties the opposite way — declaration order is authoritative there, and the comparison of the two is the more interesting half of both posts. Further back there is my first npm package from 2011, and on the Go side, simpleconf. All packages: Projects.
Project links
- npm: @shaun/alpinejs-router (v1.3.4)
- GitHub: shaunlee/alpinejs-router
- License: MIT
If Alpine.js is enough for your UI and you only need client-side routing, install it and start with the HTML example above. Bug reports, feature requests, and PRs are welcome.