nanoroute
v0.2.0
Published
Zero-dependency React router in ~2KB gzipped — nested routes, typed params, wildcards, and a memory history, no context provider required.
Maintainers
Readme
nanoroute
A tiny, dependency-free router for React 19+. Nested routes, typed params, wildcards, search params, and a memory history for tests — client-side routing with nothing extra bolted on.
Contents
- Why nanoroute
- nanoroute vs. react-router
- Install
- Quick start
- Guide
- API reference
- TypeScript
- FAQ
- Deliberately not included
- Notes
- License
Why nanoroute
- ~2.3KB gzipped, zero runtime dependencies. No transitive packages, no supply-chain surface beyond React itself.
- No provider needed. Location lives in a
useSyncExternalStorestore, not in context — mount<Routes>anywhere and it just works against the browser URL. - No leaks by construction. One shared
popstate/hashchangelistener exists only while components are mounted, and every cache in the library is bounded. - React Compiler friendly. Passes
eslint-plugin-react-hooksv7 with the compiler rules enabled; every manual memo is preserved rather than skipped. - ESM, fully typed, tree-shakeable. Ships its own
.d.ts, noanyin the public API. - A real memory history, not a mock.
MemoryRouterandcreateMemoryHistorymake route-aware components testable without touchingwindow.
nanoroute vs. react-router
react-router is a capable, full-featured framework: data loaders, actions, fetchers,
deferred data, framework mode. If you need those, use it. If you just need to show the
right component for the current URL, nanoroute does that and stops there.
| | nanoroute | react-router |
| --- | --- | --- |
| Gzipped size | ~2.3 KB | ~57.9 KB (main bundle) |
| Runtime dependencies | 0 | 1 (cookie-es) |
| Setup | Mount <Routes> — no provider required | Requires <BrowserRouter> / RouterProvider |
| Scope | Routing primitives: match, navigate, params | Full framework: loaders, actions, data APIs, framework mode |
| Config surface | path, element, nested children | Loaders, actions, error boundaries, handles, and more |
| TypeScript | Fully typed, no any | Fully typed |
Sizes measured August 2026: nanoroute's dist/index.js gzips to 2,398 bytes
(built from this repo); [email protected]'s main bundle gzips to 59,247 bytes per
Bundlephobia. Re-check before citing —
both numbers move as each package ships new releases.
Reach for nanoroute when you want SPA routing without adopting a framework's opinions. Reach for react-router when you want the data layer that comes with it.
Install
npm install nanoroutepnpm add nanorouteyarn add nanorouteRequires React ≥ 19 (contexts are rendered directly as providers, and ref is a plain
prop). No other peer dependencies.
Quick start
import { Link, Outlet, Route, Routes, useParams } from 'nanoroute'
const Shell = () => (
<>
<nav>
<Link to="/">Home</Link>
<Link to="/users/7">User</Link>
</nav>
<Outlet />
</>
)
const User = () => <h1>{useParams<{ id: string }>().id}</h1>
export const App = () => (
<Routes>
<Route path="/" element={<Shell />}>
<Route path="" element={<Home />} /> {/* index route */}
<Route path="users/new" element={<NewUser />} /> {/* wins over :id */}
<Route path="users/:id" element={<User />} />
<Route path="files/*" element={<Files />} /> {/* params['*'] */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
)No <BrowserRouter> wrapper — <Routes> reads the browser URL by default. Mount it
anywhere in the tree.
Full export surface, for a quick scan:
import {
// components
Link, MemoryRouter, Navigate, Outlet, Route, Router, Routes,
// hooks
useLocation, useMatch, useNavigate, useParams, useRouterHistory, useSearchParams,
// history & SSR
browserHistory, createBrowserHistory, createMemoryHistory, createStaticHistory,
navigate, setServerUrl,
// standalone matcher
matchPath,
} from 'nanoroute'Guide
Routing & matching
Patterns are compiled once to a regex and ranked by specificity, so declaration
order never matters: static segments beat :params, which beat *.
<Route path="users/new" element={<NewUser />} /> {/* static: always wins over :id */}
<Route path="users/:id" element={<User />} /> {/* dynamic: params.id */}
<Route path="files/*" element={<Files />} /> {/* wildcard: params['*'] */}A trailing * also matches the bare parent path (files/* matches /files, with
params['*'] equal to ''). Use matchPath(pattern, pathname) to run the same
matcher outside of rendering, e.g. for route-based analytics or redirects.
Nested layouts
<Route> is configuration only — it never renders. <Routes> reads the tree of
<Route> elements, matches the deepest one, and folds every ancestor's element
around it so each layout's <Outlet /> renders its matched child.
<Routes>
<Route path="/" element={<Shell />}>
<Route path="dashboard" element={<DashboardLayout />}>
<Route path="" element={<DashboardHome />} />
<Route path="settings" element={<DashboardSettings />} />
</Route>
</Route>
</Routes><Outlet /> renders null where there's nothing nested — it's safe to include in
every layout unconditionally.
Navigation
import { Link, Navigate, useNavigate } from 'nanoroute'
<Link to="/users/7">User 7</Link>
<Link to="/users/7" replace state={{ from: 'list' }}>User 7</Link><Link> accepts every <a> prop (including ref) plus to, replace, and state.
Modified clicks (Cmd/Ctrl/Shift/Alt), a non-_self target, and cross-origin
URLs all fall through to normal browser navigation instead of being intercepted.
const navigate = useNavigate()
navigate('/users/7')
navigate('/users/7', { replace: true, state: { from: 'list' } })
navigate(-1) // back one entry, like history.go(-1)<Navigate to="/login" /> redirects on mount (replace defaults to true, unlike
<Link>). For navigation outside of React — an error handler, a non-component module —
use the module-level navigate(to, options), which targets the browser history
directly.
Location, params & active links
import { useLocation, useMatch, useParams } from 'nanoroute'
const { pathname, search, hash, state } = useLocation()
const { id } = useParams<{ id: string }>()
const isActive = useMatch('/users/:id') !== null // handy for active-link stylingSearch params
import { useSearchParams } from 'nanoroute'
const [searchParams, setSearchParams] = useSearchParams()
const tab = searchParams.get('tab')
setSearchParams({ tab: 'settings' }) // replaces the query string
setSearchParams((current) => {
current.set('page', '2')
return current
})
setSearchParams({ tab: 'settings' }, { replace: false }) // push instead of replaceThe setter replaces the current history entry by default, so filter and pagination controls don't flood back/forward history.
History & testing
Swap the browser out for an in-memory history — for tests, embedded panes, previews, or anywhere without a real address bar:
import { MemoryRouter } from 'nanoroute'
render(
<MemoryRouter initialEntries={['/users/9']}>
<App />
</MemoryRouter>,
)Bring your own instance to drive and inspect it directly, e.g. from node:test or
Vitest:
import assert from 'node:assert/strict'
import { createMemoryHistory, Router } from 'nanoroute'
const history = createMemoryHistory({ initialEntries: ['/'] })
render(<Router history={history}><App /></Router>)
history.navigate('/users/7')
assert.deepEqual(history.entries, ['/', '/users/7'])
assert.equal(history.index, 1)createMemoryHistory resolves relative targets ('sub', '?q=1', '#top', '../x')
against the current entry, truncates forward entries on push (like a real session), and
caps the stack at maxEntries (default 50) so a long-lived router can't grow it
forever.
Inside a component, useRouterHistory() returns whichever history is active for that
subtree — the browser by default, or whatever <Router> / <MemoryRouter> supplied —
which is what useNavigate and useLocation are built on:
import { useRouterHistory } from 'nanoroute'
import type { MemoryHistory } from 'nanoroute'
const history = useRouterHistory()
history.navigate('/next') // always available, whatever the history
const { entries } = history as MemoryHistory // only valid when you know it's a MemoryRouterServer-side rendering
import { setServerUrl } from 'nanoroute'
setServerUrl(request.url) // once per request, before renderToString / renderToStaticMarkupOn the client, the same snapshot is read from window.location, so hydration matches
without any extra setup on your part.
setServerUrl writes one shared value for the whole process, so it's only safe when
requests can't interleave — true for synchronous rendering, not for streaming SSR
(renderToPipeableStream / renderToReadableStream), where a Suspense boundary can
yield mid-render and let a second request's setServerUrl call stomp on the first.
For that case, isolate the URL per request instead:
import { createStaticHistory, Router } from 'nanoroute'
const history = createStaticHistory(request.url) // scoped to this request, not shared
render(<Router history={history}><App /></Router>)API reference
| Export | Notes |
| --- | --- |
| <Routes> / <Route> | path, element, nested children. <Route> is config only. |
| <Outlet /> | Renders the matched child route. |
| <Link to> | Plus replace, state, and every <a> prop including ref. Modified clicks, target, and cross-origin URLs fall through to the browser. |
| <Navigate to> | Redirects on mount; replace defaults to true. |
| <Router history> | Runs a subtree against any history. |
| <MemoryRouter> | initialEntries, initialIndex, maxEntries. |
| useNavigate() | (to, { replace, state }). to may be a delta: navigate(-1). |
| useLocation() | { pathname, search, hash, state }. |
| useParams<T>() | Params of the matched route. |
| useSearchParams() | [URLSearchParams, setSearchParams]; the setter replaces by default. |
| useMatch(pattern) | Params or null — handy for active links. |
| useRouterHistory() | The history driving this subtree. |
| navigate(to, opts) | Module-level, for use outside React. Targets the browser history. |
| matchPath(pattern, pathname) | Standalone matcher. |
| browserHistory | The singleton driving the browser by default — used when no <Router> wraps the tree. |
| createBrowserHistory() | Creates an independent browser-backed history; rarely needed, browserHistory already exists. |
| createMemoryHistory(options) | initialEntries, initialIndex, maxEntries. Returns a history with entries / index for inspection. |
| createStaticHistory(url) | A history frozen to one URL, isolated per call — safe for concurrent SSR requests, unlike setServerUrl's shared global. |
| setServerUrl(url) | Sets the URL browserHistory reports during synchronous SSR (renderToString / renderToStaticMarkup). |
All prop and return types (LinkProps, RouterHistory, MemoryHistory,
RouteParams, SearchParamsUpdate, …) are exported from nanoroute directly — no
@types package, no duplicated ambient types.
TypeScript
useParams and useMatch are the two spots you'll usually reach for a generic:
const { id } = useParams<{ id: string }>()
const params = useMatch('/files/*') // RouteParams | null
if (params) console.log(params['*']) // the wildcard captureFAQ
What is nanoroute?
nanoroute is a tiny, dependency-free client-side router for React 19+. It matches the
current URL against a tree of <Route> elements and renders the matched component,
with support for nested layouts, dynamic params, wildcards, search params, and a
memory history for tests.
Is nanoroute a good react-router alternative?
Yes, if you want URL-based rendering, nested layouts, and typed params without adopting a data-loading framework. It is not a drop-in replacement — nanoroute has no loaders, actions, or framework mode by design. See nanoroute vs. react-router.
Does nanoroute require a <BrowserRouter> provider?
No. <Routes> reads the browser URL by default with zero setup. Wrap in <Router> or
<MemoryRouter> only when you want to swap in a different history, e.g. in tests.
Does nanoroute support data loaders, actions, or SSR frameworks like Remix or Next.js?
No — that's explicitly out of scope; see
Deliberately not included. nanoroute does support
server-side rendering itself via setServerUrl (synchronous rendering) or
createStaticHistory (concurrent/streaming SSR), and works fine as the client-side
router inside a custom SSR setup.
How do I test components that use nanoroute?
Render them inside <MemoryRouter initialEntries={[...]}>, or build a
createMemoryHistory() instance and pass it to <Router history={...}> to drive and
assert on navigation directly. See History & testing.
What React version does nanoroute require?
React 19 or newer. nanoroute renders context providers directly (<Context value={…}>)
and passes ref as a plain prop, both React 19 features.
Is nanoroute CommonJS-compatible?
No, ESM only. CommonJS consumers need a bundler or await import('nanoroute').
Deliberately not included
Data loaders, lazy routes, scroll restoration, basename, hash history, relative
<Routes> nesting, and NavLink (use useMatch for active styling). Reach for
react-router if you need them.
Notes
- ESM only. Consumers on CommonJS need a bundler or
await import(). useLocation().staterefreshes when the URL changes; areplaceStatethat only swapsstatewill not re-render on its own.
Contributing
npm run lint # eslint src
npm run typecheck # tsc -p tsconfig.json
npm run test # compiles with tsc, then runs node --test
npm run build # tsup -> dist/Issues and pull requests: github.com/SheikhAminul/nanoroute.
License
MIT © Sheikh Aminul Islam
