Pinecone Router

Logo

The exendable client-side router for Alpine.js

View the Project on GitHub pinecone-router/router

[![npm](https://img.shields.io/npm/dm/pinecone-router?label=npm&logo=npm&labelColor=%23d7f4ee&color=%230b2822&style=flat&logoColor=%230b2822)](https://npmjs.com/package/pinecone-router) ![jsDelivr hits (npm)](https://img.shields.io/jsdelivr/npm/hm/pinecone-router?style=flat&logo=jsdelivr&logoColor=%230b2822&label=jsdelivr&labelColor=d7f4ee&color=%230b2822) [![npm bundle size](https://img.shields.io/bundlephobia/minzip/pinecone-router@7.0.3?labelColor=%23d7f4ee&style=flat&color=%230b2822&&logo=bun&logoColor=%230b2822)](https://bundlephobia.com/result?p=pinecone-router@7.0.3)
[![Changelog](https://img.shields.io/badge/changelog-0b2822?style=flat)](./CHANGELOG.md) [![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/pinecone-router/router?labelColor=%23d7f4ee&color=%230b2822&label=version&style=flat&sort=semver&logo=semver&logoColor=%230b2822)](https://github.com/pinecone-router/router/tree/7.0.3) [![Sponsor](https://img.shields.io/badge/sponsor-0b2822?logo=githubsponsors&style=flat)](https://ko-fi.com/rehhouari)

Pinecone Router

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>

Features:

Demo: Pinecone example, (source code).

Installation

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.

CDN

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.3/dist/router.min.js"></script>

NPM

npm install pinecone-router
import PineconeRouter from 'pinecone-router'
import Alpine from 'alpinejs'
Alpine.plugin(PineconeRouter)
Alpine.start()

Browser Module

import PineconeRouter from 'https://cdn.jsdelivr.net/npm/pinecone-router@7.0.3/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()

Usage

Demo & Usage Example

x-route

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

Route matching

Segments types

[!IMPORTANT] Trailing slashes are normalized (both /about and /about/ work the same)

Matching is case-insensitive

Accessing params

You can access the params’ values with:

x-template

x-template allows you to display content everytime the route changes.

Inline templates

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.

Modifiers

<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.

External templates

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.

Modifiers

<!-- 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.

Embeded Scripts

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>

x-handler

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.

Handler arguments

Each handler function receives two arguments:

  1. context - The HandlerContext object containing current route information.
  2. controller - An AbortController which allows you to:

Examples

<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)
			}
		},
	}
}

Modifiers

[!NOTE] You can also define global handlers programmatically through Settings.

Prevent execution of subsequent handlers

To prevent the next handlers from executing from inside another hanlder, you can:

Handler Type Reference

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 magic helper

$router is a wrapper for the PineconeRouter object.

PineconeRouter object

You can access the PineconeRouter object in a few ways:

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

Context object

Contains information about the current route. This is available at all times:

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

Settings:

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.

Settings object

export interface Settings {
	/**
	 * enable hash routing
	 * @default false: boolean
	 */
	hash: boolean

	/**
	 * The base path of the site, for example /blog.
	 * @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

Route object

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:

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

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.

Others

notfound route

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.

Named routes

You can add an optional name to the route which can be helpful in certain situations:

<template x-route:name="/test"></template>
PineconeRouter.add('/test', { name: 'name' })
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.

Base Path

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:

[!NOTE] When using hash routing, basePath will only be added to templates urls. Which makes sense because hash routing don’t care about the pathname only the hash.

Bypass click handling

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>

Disable click handling globally

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>

Events / Loading

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.

Add & Remove Routes Programmatically

you can add routes & remove them anytime programmatically using Javascript.

Adding a route

window.PineconeRouter.add(path, 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.

Adding a template

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:

Removing a route

PineconeRouter.remove(path)

Navigating from Javascript:

To navigate to another page from javascript you can use:

PineconeRouter.navigate(path)

Compatibility

Version Alpine.js Version
^v2.x v3
v1.x v2

Contributing:

Please refer to CONTRIBUTING.md

Credits

regexparam for new route matching logic

@shaun/alpinejs-router for the new x-if inspired template logic

Click handling intially method from page.js.

Acknowledgment

@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!

License

Copyright (c) 2021-2025 Rafik El Hadi Houari and contributors

Licensed under the MIT license, see LICENSE.md for details.

Creative Commons License
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