Nano Stores Router
June 26, 2026 · View on GitHub
A tiny URL router for Nano Stores state manager.
- Small. 725 bytes (minified and brotlied). Zero dependencies.
- Good TypeScript support.
- Framework agnostic. Can be used with React, Preact, Vue, Svelte, Angular, Solid.js, and vanilla JS.
Since Nano Stores promote moving logic to store, the router is a store, not a component in UI framework like React.
// stores/router.ts
import { createRouter } from '@nanostores/router'
export const $router = createRouter({
home: '/',
list: '/posts/:category',
post: '/posts/:category/:post'
})
Store in active mode listens for <a> clicks on document.body and Back button
in browser.
// components/layout.tsx
import { useStore } from '@nanostores/react'
import { $router } from '../stores/router.js'
export const Layout = () => {
const page = useStore($router)
if (!page) {
return <Error404 />
} else if (page.route === 'home') {
return <HomePage />
} else if (page.route === 'list') {
return <ListPage category={page.params.category} filters={page.search} />
} else if (page.route === 'post') {
return <PostPage post={page.params.post} />
}
}
Made at Evil Martians, product consulting for developer tools.
Install
npm install nanostores @nanostores/router
Usage
See Nano Stores docs about using the store and subscribing to store’s changes in UI frameworks.
Routes
Routes is an object of route’s name to route pattern:
createRouter({
route1: '/',
route2: '/path/:var1/and/:var2',
route3: /\/posts\/(?<type>draft|new)\/(?<id>\d+)/
})
For string patterns you can use :name for variable parts. To make the
parameter optional, mark it with the ? modifier:
createRouter({
routeName: '/profile/:id?/:tab?'
})
Routes can have RegExp patterns. They should be an array with function,
which convert () groups to key-value map.
For TypeScript, router parameters will be converted to types automatically. You need to use TypeScript ≥5.x.
createRouter({
routeName: '/path/:var1/and/:var2',
routeName2: [/path2/, () => ({ num: 1, str: '' })]
})
/**
* Params will be inferred as:
* {
* routeName: { var1: string, var2: string },
* routeName2: { num: number, str: string }
* }
*/
Search Query Routing
Router value contains parsed URL search params (like ?sort=name):
createRouter({ home: '/posts/:category' })
location.href = '/posts/general?sort=name'
router.get() //=> {
// path: '/posts/general',
// route: 'list',
// params: { category: 'general' },
// search: { sort: 'name' },
// hash: ''
// }
To disable the automatic parsing of search params in routes you need
to set search option. Router will now treat search query like ?a=1&b=2
as a string. Parameters order will be critical.
createRouter({ home: '/posts?page=general' }, { search: true })
location.href = '/posts/?page=general'
router.get() //=> {
// path: '/posts?page=general',
// route: 'list',
// params: { },
// search: { },
// hash: ''
// }
Hash Routing
Router’s value has current location.hash and router updates its value
on hash changes.
location.href = '/posts/general#dialog'
router.get() //=> {
// path: '/posts/general',
// route: 'list',
// params: { category: 'general' },
// search: {},
// hash: '#dialog'
// }
Clicks Tracking
By default, router and ?search params store will add click event listener
on window to track links clicks.
To disable click tracking for specific link, add target="_self" to link tag:
<a href="/posts" target="_self">Posts</a>
You can disable this behavior by links: false options and create custom
<Link> component.
export const $router = createRouter({ … }, { links: false })
function onClick (e) {
e.preventDefault()
$router.open(new Url(e.target.href).pathname)
}
export const Link = (props) => {
return <a onClick={onClick} {...props}></a>
}
URL Generation
Using getPagePath() avoids hard coding URL in templates. It is better
to use the router as a single place of truth.
import { getPagePath } from '@nanostores/router'
…
<a href={getPagePath($router, 'post', { category: 'guides', post: '10' })}>
If you need to change URL programmatically you can use openPage
or redirectPage:
import { openPage, redirectPage } from '@nanostores/router'
function requireLogin() {
openPage($router, 'login')
}
function onLoginSuccess() {
// Replace login route, so we don’t face it on back navigation
redirectPage($router, 'home')
}
All functions accept search params as last argument:
getPagePath($router, 'list', { category: 'guides' }, { sort: 'name' })
//=> '/posts/guides?sort=name'
Preventing Navigation
Navigation is derived from the store value: the URL changes only after the
store changed. So you can use Nano Stores onSet event to prevent navigation,
for example, to keep the user on a form with unsaved changes.
import { onSet } from 'nanostores'
onSet($router, ({ newValue, abort }) => {
if (hasUnsavedChanges && !confirm('Discard changes?')) {
abort()
}
})
abort() stops $router.open(), openPage(), redirectPage(), and tracked
<a> clicks: neither the store nor the URL will change.
Browser Back/Forward buttons change the URL before onSet runs,
so abort() will keep the store on the old value but will not roll back the
address bar. Add your own history.pushState in the listener if you need that.
Server-Side Rendering
Router can be used in Node environment without window and location.
In this case, it will always return route to / path.
You can manually set any other route:
if (isServer) {
$router.open('/posts/demo/1')
}