A small, easy to use, and feature-packed router for Alpine.js.
<div x-data="app" >
<template x-route="/" x-template>
<h1>Welcome!</h1>
<p>What's your name?</p>
<input @enter="$router.navigate('/'+$el.value)"></input>
</template>
<template x-route="/:name" x-handler="handle" x-template>
<h1>Hello <span x-text="$params.name"></span>!</h1>
<button @click="$history.back()">Go Back</button>
</template>
<template x-route="notfound" x-template="/404.html"></template>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('app', () => ({
handler(context, controller) {
if (context.params.name == 'easter') {
this.$router.navigate('/easter-egg')
}
},
}))
})
</script>
- 😄 Easy and familiar syntax well integrated with Alpine.js.
- ⚙️ Handler functions allow you to run functions on for each route.
- 🔰 Inline and external templates to display content.
- ✨ 3 Magic helpers to easily access router data.
-
Full Typescript support.
- 🔗 Automatic click handling and loading events.
- #️⃣ Hash routing support.
Demo: Pinecone example, (source code).
This projects follow the Semantic Versioning guidelines.
Important
Check the CHANGELOG before major updates.
Note
If you're upgrading from v6, also see the more compact Upgrade Guide.
Include the following <script>
tag in the <head>
of your document,
before Alpine.js:
<script src="https://cdn.jsdelivr.net/npm/pinecone-router@7.0.2/dist/router.min.js"></script>
npm install pinecone-router
import PineconeRouter from 'pinecone-router'
import Alpine from 'alpinejs'
Alpine.plugin(PineconeRouter)
Alpine.start()
import PineconeRouter from 'https://cdn.jsdelivr.net/npm/pinecone-router@7.0.2/dist/router.esm.js'
import Alpine from 'https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/module.esm.js'
Alpine.plugin(PineconeRouter)
Alpine.start()
Declare routes by creating a template tag with the x-route
directive.
<div x-data="...">
<template x-route="/"></template>
<template x-route="/hello/:name"></template>
<template x-route="notfound"></template>
</div>
Note
Alternatively you can use Javascript to add routes
Note
Read more: notfound
route, Named routes
- Literal (
/literal
): Matches/literal
but not/something-else
- Named (
/:name
): Matches/john
or/123
but not/john/doe
or/
- Optional (
/:name?
): Matches/profile
or/profile/john
but not/profile/john/settings
- Rest (
/users/:rest+
): Matches/users/john
or/users/john/settings
but not/users
(matches one or more) - Wildcard (
/:path*
): Matches/files
,/files/docs
,/files/docs/report.pdf
(matches zero or more) - Suffix (
/movies/:title.mp4
): Matches/movies/avatar.mp4
but not/movies/avatar.mov
or/movies/avatar
- Suffix Pattern (
/movies/:title.(mp4|mov)
): Matches/movies/avatar.mp4
or/movies/avatar.mov
but not/movies/avatar.avi
or/movies/avatar
Important
Trailing slashes are normalized (both /about
and /about/
work the same)
Matching is case-insensitive
You can access the params' values with:
$params
magic helper:$params.paramName
from Alpine.js components.context.params.paramName
from inside handlers.PineconeRouter.context.params
from elsewhere in JS.
x-template
allows you to display content everytime the route changes.
By adding an empty x-template
attribute to a route template element,
Pinecone Router will render its children when the route is matched.
<template x-route="/" x-template>
<div>Hello World!</div>
<p>Works with multiple children as well</p>
</template>
In this example it will inserts the child elements into the document the same
way x-if
does: after the template
tag.
.target
:.target.app
will render the inline template inside the element with theapp
ID:
<template x-route="/" x-template.target.app>
<div>Hello World!</div>
</template>
<div id="app"></div>
Tip
Default Target ID can be set globally in Settings.
x-template
also allows you to specify one or more external template files
to be fetched from a URL.
<template x-route="/" x-template="/home.html"></template>
<template
x-route="/header"
x-template="['/header.html', '/home.html']"
></template>
In this example it will fetch the html files and inserts them in the document
the same way x-if
does: after the appropriate template
tags.
.preload
: Fetches the templates after the first page load atlow
priority, without waiting for the route to be matched..target
: Takes an ID paramater for example.target.app
will render the template inside the element with theapp
ID.interpolate
: Enable named route params in template urls.
<!-- you can preload templates -->
<template x-route="notfound" x-template.preload="/404.html"></template>
<!-- you can specify an element to render into -->
<template
x-route="/profile/:id"
x-template.target.app="/profile.html"
></template>
<!-- this will fetch templates according to the current route params -->
<!-- on /dyamic/foo it it will fetch /api/dynamic/foo.html, and so on -->
<!-- this can be helpful when using an API that generates HTML -->
<template
x-route="/dynamic/:name"
x-template.interpolate="/api/dynamic/:name.html"
>
</template>
<div id="app">
<!-- profile.html content will be displayed here -->
</div>
Note
Templates's content are cached by PineconeRouter in a variable when loaded, and are automatically cleared on browser page reload.
Note
When fetching a template fails, it adispatches a
pinecone:fetch-error
event to document
.
Tip
Modifiers can be used simulateneously: x-template.preload.target.app
For obvious reasons, .preload
cannot be used with .interpolate
.
Tip
Preload can be used globally in Settings.
Tip
Default Target ID can be set globally in Settings.
Templates can have their own script elements, which will run when the route is matched.
/template.html
:
<div x-data="hello" x-effect="effect">
<h1>Homepage</h1>
<p x-text="message"></p>
</div>
<script>
Alpine.data('hello', () => ({
message: 'Hello world',
init() {
console.log('hello from init()')
},
effect() {
// this will run whenever the param `name` changes
if (this.$params.name == 'world') {
console.log('hello world')
}
},
}))
</script>
Important
Templates does not re-render when the path/params changes on the same route.
init()
will run only once until the user visits another route then comes
back.
Tip
To run a function when params change, use x-effect
or $watch
:
<div x-data="hello" x-effect="getData"></div>
<strong x-show="!loading" x-text="name"></strong>
<script>
Alpine.data('name', () => ({
loading: true,
name: Alpine.$persist(''),
async getData() {
try {
this.loading = true
const response = await fetch(`/views/${this.$params.slug}.json`)
const data = await response.json()
this.name = data.name
} catch (error) {
console.error('Fetch error:', error)
} finally {
this.loading = false
}
},
}))
</script>
This powerful directive can be used alone or alongisde x-template
, it allow
you to excute one or more methods when a route is matched.
x-handler
takes a function, or an array of functions, that will be called in order.- Handlers are awaited.
- Handlers run before x-template allowing you to redirect before showing
them, or use handlers without
x-template
to display content from JS. - Handlers run before the route is added to the Navigation History, so any redirection is not added to the history which prevents loops.
Each handler function receives two arguments:
context
- The HandlerContext object containing current route information.controller
- AnAbortController
which allows you to:
- check
controller.signal
to cancel your handler when a user navigates elsewhere. For example, the clicked a link while handler isfetch
ing data. - use
controller.abort()
to cancel subsequent handlers.
<div x-data="router()" x-handler.global="[globalHandler]">
<!-- You can pass in a function name -->
<template x-route="/" x-handler="home"></template>
<!-- Or an anonymous/arrow function -->
<template
x-route="/home"
x-handler="[(ctx) => ctx.redirect('/'), thisWontRun]"
></template>
<!-- Or even an array of multiple function names/anonymous functions! -->
<template x-route="/hello/:name" x-handler="[checkName, hello]"></template>
<!-- Handlers will be awaited, and their returned value is passed
to the next handler -->
<template
x-route="/home"
x-handler="[awaitedHandler, processData]"
></template>
<!-- 404 handler -->
<template x-route="notfound" x-handler="notfound"></template>
</div>
<div id="app"></div>
The JS:
function router() {
return {
home(context) {
document.querySelector('#app').innerHTML = `<h1>Home</h1>`
},
checkName(context) {
if (context.params.name.toLowerCase() == 'rafik') {
alert('we have the same name!')
}
},
hello(context) {
document.querySelector('#app').innerHTML =
`<h1>Hello, ${context.params.name}</h1>`
},
notfound(context) {
document.querySelector('#app').innerHTML = `<h1>Not Found</h1>`
},
thisWontRun(context) {
// This function wont run because the previous handler redirected
console.log('skipped!')
},
globalHandler(context) {
// this will be run for every router
console.log('global handler: ', context.route)
},
// async functions will be automatically awaited by Pinecone Router
async awaitedHandler(ctx, controller) {
try {
// use abort signal to cancel when the user navigates away.
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts',
{ signal: controller.signal }
)
return await response.json() // pass the response to the next handler
} catch (err) {
// safely ignore aborts, but handle fetch errors
if (err.name != 'AbortError') {
console.error(`Download error: ${err.message}`)
// abort on error for example, which wont render the route's template
// nor run subsequent handlers
controller.abort()
}
}
},
processData(ctx) {
// get previous handlers returned data
if (ctx.data) {
console.table(ctx.data)
}
},
}
}
.global
: define global handlers that will be run for every route, it is bound to the data of the element it is defined on so it's best to add to the router component element (<div x-data="router" x-handler.global="[]">
), or any element with a access to the handlers you're using (doesn't have to be on the same element as x-data)- These global handlers always run before route specific handlers.
Note
You can also define global handlers programmatically through Settings.
To prevent the next handlers from executing from inside another hanlder, you can:
this.$router.navigate()
to redirect to another path, since all navigation requests cancel queued handlers.controller.abort()
to cancel subsequent handlers without redirecting.- This is useful if you want to show an error from a handler without redirecting, ie. using JS.
These are the types you can import if using Alpine.js with Typescript
/**
* Handler type takes the In and Out parameters.
*
* @param In is the value of the previous handler, which will be inside
* `HandlerContext.data`.
* @param Out is the return value of the handler.
*/
export type Handler<In, Out> = (
context: HandlerContext<In>,
controller: AbortController
) => Out | Promise<Out>
export interface HandlerContext<T = unknown> extends Context {
readonly data: T
readonly route: Route
}
$router
is a wrapper for the PineconeRouter object.
You can access the PineconeRouter
object in a few ways:
$router
magic helper inside Alpine.js componentswindow.PineconeRouter
inside JS modules.PineconeRouter
inside global JS.Alpine.$router
.
Reference:
export interface PineconeRouter {
readonly name: string
readonly version: string
routes: RoutesMap
context: Context
settings: (value?: Partial<Settings>) => Settings
history: NavigationHistory
loading: boolean
/**
* Add a new route
*
* @param {string} path the path to match
* @param {RouteOptions} options the options for the route
*/
add: (path: string, options: RouteOptions) => void
/**
* Remove a route
*
* @param {string} path the route to remove
*/
remove: (path: string) => void
/**
* Navigate to the specified path
*
* @param {string} path the path with no hash even if using hash routing
* @param {boolean} fromPopState INTERNAL Is set to true when called from
* onpopstate event
* @param {boolean} firstLoad INTERNAL Is set to true on browser page load.
* @param {number} index INTERNAL the index of the navigation history
* that was navigated to.
* @returns {Promise<void>}
*/
navigate: (
path: string,
fromPopState?: boolean,
firstLoad?: boolean,
index?: number
) => Promise<void>
}
The routes object is a map that has a string key which is the route path, and a value which is a Route object.
export type RoutesMap = Map<string, Route> & {
get(key: 'notfound'): Route
}
Note
Read more: Settings, NavigationHistory, Route, Context, RouteOptions
Contains information about the current route. This is available at all times:
- Using the magic helper:
$router.context
in Alpine components - From Javascript using
window.PineconeRouter.context
- Every
handler
method takes thecontext
object as the first argument which you should use instead of the above.
Reference:
/**
* This is the global Context object
* Which can be accessed from `PineconeRouter.context`
*/
export interface Context {
readonly path: string
readonly route?: Route
readonly params: Record<string, string | undefined>
}
Read more: Route object
PineconeRouter can be configured using
PineconeRouter.settings
.
In Alpine:
<div x-data="router" x-init="$router.settings({targetID: 'app'})"></div>
In JS:
<script>
document.addEventListener('alpine:init', () => {
window.PineconeRouter.settings({
basePath: '/app',
targetID: 'app',
})
})
</script>
PineconeRouter.settings()
returns the current settings.
export interface Settings {
/**
* enable hash routing
* @default false: boolean
*/
hash: boolean
/**
* The base path of the site, for example /blog.
* No effect when using hash routing.
* @default `/`
*/
basePath: string
/**
* Set an optional ID for where the templates will render by default.
* This can be overridden by the .target modifier.
* @default undefined
*/
targetID?: string
/**
* Set to false if you don't want to intercept link clicks by default.
* @default true
*/
handleClicks: boolean
/**
* Handlers that will run on every route.
* @default []
*/
globalHandlers: Handler<unknown, unknown>[]
/**
* Set to true to preload all templates.
* @default false
* */
preload: boolean
}
Read more: Base Path
export interface Route {
/**
* Set to true automatically when creating a route programmatically.
* @internal
*/
readonly programmaticTemplates: boolean
/**
* Set to true when the route is added programmatically and defined as having
* params in the template urls
* @internal
*/
readonly interpolate: boolean
/**
* The regex pattern used to match the route.
* @internal
*/
readonly pattern: RegExp
/**
* The raw route path
*/
readonly path: string
/**
* The target ID for the route's templates
*/
readonly targetID?: string
/**
* The name of the route
*/
readonly name: string
match(path: string): undefined | { [key: string]: string }
handlers: Handler<unknown, unknown>[]
templates: string[]
}
export interface RouteOptions {
handlers?: Route['handlers']
interpolate?: boolean
templates?: string[]
targetID?: string
preload?: boolean
name?: string
}
Besides updating the browser history, Pinecone Router also has its own
independent navigation history object, keeping
track of path visits, and allowing you to do back()
and forward()
operations without relying on the browser API.
The way it works is by recording all paths visited, excluding:
- Duplicates; meaning if you're on '/home' and you click a link that goes to '/home', it wont affect the history.
- Redirects: if you're on
/home
and you visit/profile/old
which has a handler that redirects you to/profile/new
, it will not add/profile/old
to the history, only /profile/new.
If you click a link after using back()
, meaning the history.index
is not history.entries.length-1
, it will remove all elements
from entries
starting
from the history.index
to the end, then appends the current path.
To access the NavigationHistory object you can use
- The
$history
magic helper. - PineconeRouter.history.
export interface NavigationHistory {
/**
* The current history index
*/
index: number
/**
* The list of history entries
*/
entries: string[]
/**
* Check if the router can navigate backward
* @returns {boolean} true if the router can go back
*/
canGoBack: () => boolean
/**
* Go back to the previous route in the navigation history
*/
back: () => void
/**
* Check if the router can navigate forward
*
* @returns {boolean} true if the router can go forward
*/
canGoForward: () => boolean
/**
* Go to the next route in the navigation history
*/
forward: () => void
/**
* Navigate to a specific position in the navigation history
*
* @param index The index of the navigation position to navigate to
* @returns void
*/
to: (index: number) => void
}
Tip
Use PineconeRouter.canGoBack()
or
PineconeRouter.canGoForward()
to check if the
operation is possible, for example to disable the appropriate buttons.
By default when PineconeRouter initializes, a default notfound
route
is created with the handler:
;(ctx) => console.error(new ReferenceError(ROUTE_NOT_FOUND(ctx.path)))
You can create a new template
element using x-route="notfound"
withx-template
and or x-handler
to add templates and replace the defaul
handler.
You can also update the notfound
route programmatically,
using PineconeRouter.add
, to which notfound
is
the only expection that wont throw an error due to an exisitng route.
You can add an optional name to the route which can be helpful in certain situations:
- With x-route:
<template x-route:name="/test"></template>
- With JS:
PineconeRouter.add('/test', { name: 'name' })
- Access inside handlers:
function handler(context, controller) {
console.log('route name:', context.route.name) // route name: name
}
Note
If there was no route name suplied, it will fallback to the route's path.
Names don't have to be unique.
After setting a Settings.basePath
, it will automatically added to
x-route
& x-template
paths, PineconeRouter.add()
, and to very navigation
request, be it link clicks or navigate()
calls.
This means if you set the basePath
to /parent
, you can now just write:
x-route="/about"
rather thanx-route="/parent/about"
.x-template="/views/home.html"
rather thanx-template="/parent/views/home.html"
.$router.navigate('/about')
rather than$router.navigate('/parent/about')
By default Pinecone Router intercept all clicks on anchor elements with valid attribues.
Adding a native
/ data-native
attribute to a link will prevent Pinecone
Router from handling it:
<a href="/foo" native>This will be handled by the browser</a>
You can set Settings.handleClicks
to false to disable
automatically handling links by the router, unless an x-link
attribute is
set on the anchor element.
When disabeld:
<a href="/path">This will reload the page</a>
<a href="/path" x-link>This won't reload the page</a>
name | recipient | when it is dispatched |
---|---|---|
pinecone:start | document | loading starts |
pinecone:end | document | loading ends |
pinecone:fetch-error | document | fetching of external templates fail |
Usage from Alpine.js:
<div @pinecone:start.document=""></div>
Tip
You can easily use nProgress with
x-template
:
document.addEventListener('pinecone:start', () => NProgress.start())
document.addEventListener('pinecone:end', () => NProgress.done())
document.addEventListener('pinecone:fetch-error', (err) => console.error(err))
Tip
You can also use $router.loading to check the loading state reactively.
you can add routes & remove them anytime programmatically using Javascript.
window.PineconeRouter.add(path, options)
- path: string, the route's path.
- options: RouteOptions, array of route options:
See RouteOptions
Note that by adding handlers this way you wont have access to the this
of the
alpine.js component if the handler is part of one.
You must add a local targetID in options or set a global one in Settings:
<script>
document.addEventListener('alpine:init', () => {
window.PineconeRouter.settings({ targetID: 'app' })
window.PineconeRouter.add('/route', {
templates: ['/header.html', '/body.html'],
})
window.PineconeRouter.add('notfound', {
templates: ['/404.html'],
})
})
</script>
Important
The template added through this method won't be cleared automatically until you access another route with a template that has the same target, so make sure all your routes have the same target if you use this method.
Note
A targetID is required, whether globally through settings or on a
per rotue basis when creating a route using
add('/path', {templates: [...], targetID: 'app'})
> Removing a route:
Navigating from Javascript:
To navigate to another page from javascript you can use:
Version | Alpine.js Version |
---|---|
^v2.x | v3 |
v1.x | v2 |
Please refer to CONTRIBUTING.md
regexparam for new route matching logic
@shaun/alpinejs-router for the new x-if inspired template logic
Click handling intially method from page.js.
@KevinBatdorf for many ideas and early feedback!
Let’s code a client side router for your frameworkless SPA teaching client-side routing basic concepts.
@shaun/alpinejs-router for being a reference of how things can be done differently.
Last but not least, everyone opening issues,discussions, and pull requests with bug reports and feature requests!
Copyright (c) 2021-2025 Rafik El Hadi Houari and contributors
Licensed under the MIT license, see LICENSE.md for details.
Pinecone Router Logo by Rafik El Hadi Houari is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.
Code from Page.js is licensed under the MIT License. Copyright (c) 2012 TJ Holowaychuk tj@vision-media.ca
Code from @shaun/alpinejs-router is licensed under the MIT License. Copyright (c) 2022 Shaun Li
Code from regexparam is licensed is licensed under the MIT License. Copyright (c) Luke Edwards