Zero runtime dependencies Β· Concurrent-safe Β· ESM + CJS Β· RSC-ready
Quick start Β· Why Tiger Router? Β· Recipes Β· API Β· Migrate from v2 Β· Changelog
npm install tiger-routerimport { Router, Routes, Route, Link } from 'tiger-router'
export default function App() {
return (
<Router>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
<Routes fallback={<h1>404</h1>}>
<Route path="/" element={<h1>Home</h1>} />
<Route path="/about" element={<h1>About</h1>} />
</Routes>
</Router>
)
}That is the whole mental model: Router holds the state, Routes picks one match, Route describes a page, and Link navigates without a reload.
- ~2.6 kB brotli for a typical app, zero runtime dependencies
- React 18 and 19, built on
useSyncExternalStoreβ safe under concurrent rendering - TypeScript first, with typed params:
useParams<{ id: string }>() - Works in RSC setups (Next.js App Router) β ships the
'use client'directive - 6 components, 6 hooks. A deliberately small API you can learn in minutes
- No config, no route objects, no build step
| Choose Tiger Router when⦠| Choose a full routing framework when⦠|
|---|---|
| You want client-side routing with a very small API | You need route-level loaders, actions or middleware |
| Bundle size and zero dependencies matter | You want file-based route generation |
| You prefer JSX routes and standard browser APIs | You need built-in caching or search-param schemas |
| You are building an SPA, widget, prototype or small product | Routing is the architectural core of a large application |
Tiger Router intentionally does not include data loaders, route guards or custom code-splitting APIs. Compose those with React, or use React Router or TanStack Router when you need a full routing framework.
Write :name in the path, read it with useParams:
import { Route, Routes, useParams } from 'tiger-router'
function User() {
const { id } = useParams<{ id: string }>()
return <h1>User {id}</h1>
}
<Routes>
<Route path="/users/:id" element={<User />} />
</Routes>| Pattern | Matches | Params |
|---|---|---|
/about |
/about, /about/ |
{} |
/users/:id |
/users/42 |
{ id: '42' } |
/posts/:page? |
/posts, /posts/2 |
{ page: undefined } or '2' |
/files/* |
/files/a/b.pdf |
{ '*': 'a/b.pdf' } |
* |
anything | { '*': '...' } |
Params are URL-decoded for you, and static segments match case-insensitively.
<Routes> renders the most specific match, so you can declare routes in any order:
<Routes>
<Route path="*" element={<NotFound />} /> {/* least specific */}
<Route path="/users/:id" element={<User />} />
<Route path="/users/new" element={<NewUser />} /> {/* wins for /users/new */}
</Routes>import { useNavigate } from 'tiger-router'
function LoginForm() {
const navigate = useNavigate()
async function onSubmit() {
await login()
navigate('/dashboard', { replace: true }) // no back-button trap
}
}navigate(-1) goes back, navigate(1) goes forward.
import { useSearchParams } from 'tiger-router'
function Search() {
const [params, setParams] = useSearchParams()
const q = params.get('q') ?? ''
return (
<input
value={q}
onChange={e => setParams({ q: e.target.value }, { replace: true })}
/>
)
}Reload the page and the search box is still filled in β the URL was the state all along.
import { NavLink } from 'tiger-router'
<NavLink to="/about" className={({ isActive }) => (isActive ? 'current' : '')}>
About
</NavLink>NavLink also sets aria-current="page", so screen readers announce the current page. Add end to only light up on an exact match.
End a path with * and the child component gets the rest of the URL:
function App() {
return (
<Routes>
<Route path="/settings/*" element={<Settings />} />
</Routes>
)
}
function Settings() {
return (
<>
<h1>Settings</h1>
{/* These paths are relative to /settings */}
<Routes fallback={<Overview />}>
<Route path="/profile" element={<Profile />} />
<Route path="/billing/:plan" element={<Billing />} />
</Routes>
</>
)
}Inside a nested scope, relative links just work: <Link to="profile"> from /settings goes to /settings/profile. Params from parent routes are inherited by children.
404 page
<Routes fallback={<NotFound />}>{/* ... */}</Routes>Or, if you prefer it as a route: <Route path="*" element={<NotFound />} />.
Redirect
import { Navigate } from 'tiger-router'
<Route path="/old-pricing" element={<Navigate to="/pricing" />} />Protected route
function RequireAuth({ children }: { children: React.ReactNode }) {
const { user } = useAuth()
const location = useLocation()
if (!user) return <Navigate to="/login" state={{ from: location.pathname }} />
return <>{children}</>
}
<Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>} />Read the origin back on the login page with useLocation().state.
Code splitting with lazy + Suspense
const Dashboard = lazy(() => import('./Dashboard'))
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>Static hosting (GitHub Pages, S3) β hash mode
<Router mode="hash">β¦</Router>URLs become /#/about, which needs no server rewrites.
App served from a subfolder
<Router base="/docs">β¦</Router>Now <Link to="/api"> points the browser at /docs/api, while useLocation().pathname stays /api.
Testing
memory mode keeps the URL out of the browser, so tests never leak state:
render(
<Router mode="memory" initialPath="/users/42">
<App />
</Router>
)Next.js App Router / React Server Components
The published files carry the 'use client' directive, so importing Tiger Router from a client component works without extra setup. On the server the router falls back to memory mode instead of touching window.
| Component | Props |
|---|---|
Router |
mode?: 'browser' | 'hash' | 'memory', base?, initialPath?, history?, children |
Routes |
children (<Route> elements), fallback? |
Route |
path, element?, children? |
Link |
to, replace?, state? + every <a> attribute |
NavLink |
Link props, plus end? and function forms of className / style / children |
Navigate |
to, replace? (defaults to true), state? |
<Route> also works on its own, outside <Routes> β then every matching route renders, which is handy for persistent UI like a sidebar.
| Hook | Returns |
|---|---|
useLocation() |
{ pathname, search, hash, state, key } |
useNavigate() |
(to, { replace, state }) => void, or (delta: number) |
useParams<T>() |
Params of this route and its ancestors |
useSearchParams() |
[URLSearchParams, setSearchParams] |
useMatch(pattern) |
The match (with params) or null |
useRouter() |
{ location, history, basename, routeBase } β escape hatch |
createHistory, matchRoute, rankMatches, parsePath, joinPaths, resolvePath, toHref are exported too, in case you want route matching outside React.
- <Router>
- <Route path="/" element={<Home />} />
- <Route path="/users/:id" element={<User />} />
- </Router>
+ <Router>
+ <Routes fallback={<NotFound />}>
+ <Route path="/" element={<Home />} />
+ <Route path="/users/:id" element={<User />} />
+ </Routes>
+ </Router>- Wrap your routes in
<Routes>to get single-match rendering and a 404 mode="history"is nowmode="browser"useRouteMatch(path)is nowuseMatch(path)and returns the match object (ornull) instead of a boolean- Everything else keeps working. See CHANGELOG.md for the full list.
npm install
npm run dev # demo app on http://localhost:5173
npm test # unit + render tests
npm run verify # lint, types, tests, build, package checks, size budgetContributions are welcome β read the contribution guide, report a bug or start a discussion in an issue.