react-pragmatic-router
v19.1.2
Published
A simple react router for lazy people, without all the added complexity of react-router and the likes.
Maintainers
Readme
react-pragmatic-router
Getting started:
npm i react-pragmatic-router
Usage:
<BrowserRouter>
<Route pattern="/" element={() => <SamplePage />} />
<Route pattern="/second" element={({ params }) => <SecondPage />} />
<Route pattern="/second/third" element={() => <ThirdPage />} />
<Route pattern="/data/:someId" element={({ params }) => <ParamsPage someId={params.someId} />} />
<Route pattern="/data/:someId/more/:someOtherId" element={({ params }) => <NestedParamsPage
someId={params.someId}
someOtherId={params.someOtherId}
/>} />
</BrowserRouter>Or:
<Router location={"/"} setLocation={(newLocation: string) => {}}>
<Route pattern="/" element={() => <SamplePage />} />
<Route pattern="/second" element={({ params }) => <SecondPage />} />
<Route pattern="/second/third" element={() => <ThirdPage />} />
<Route pattern="/data/:someId" element={({ params }) => <ParamsPage someId={params.someId} />} />
<Route pattern="/data/:someId/more/:someOtherId" element={({ params }) => <NestedParamsPage
someId={params.someId}
someOtherId={params.someOtherId}
/>} />
</Router>Params:
import { DOMRouter, Route } from 'react-pragmatic-router';
function Page(props: { someParam: string }) {
return <div>
<h1>Param: {props.someParam}</h1>
</div>;
}
function App() {
return <BrowserRouter>
<Route pattern="/page/:someParam" element={({ params }) => <Page someParam={params.someParam} />} />
</BrowserRouter>;
}Links:
import { Link, NavLink } from 'react-pragmatic-router';
function Page(props: { someParam: string }) {
return <div>
<h1>Link</h1>
<Link href="/some-other-page">To some other page</Link>
<NavLink activeClass="active" exact href="/some-other-page">Navlink</NavLink>
</div>;
}Exact route:
Exact is the same as adding ^ before and $ after your route ^/posts$ and /posts with exact is the same
These two routes does the same thing
<BrowserRouter>
<Route pattern="/posts" exact element={() => <PostsPage />} />
<Route pattern="^/posts$" element={() => <PostsPage />} />
</BrowserRouter>;SwitchRoute:
<BrowserRouter>
<SwitchRoute
exact
patterns={{
'/posts/create-post': () => <CreatePostPage />,
'/posts/:postId': ({ params }) => <PostPage id={params.postId} />,
}}
/>
</BrowserRouter>;Programmatic navigation:
import { Link, NavLink, useRouter } from 'react-pragmatic-router';
function Page(props: { someParam: string }) {
const { setLocation } = useRouter();
return <div>
<h1>Programmatic navigation</h1>
<button onClick={() => setLocation(`/some-new-location`)}>Trigger navigation</button>
</div>;
}Search params:
useRouter().location includes the query string, and a query-only navigation (e.g. /users/42 → /users/42?tab=activity) re-renders every component that reads the router context. useSearchParams() is a small convenience that returns a URLSearchParams keyed off the current location:
import { Link, useSearchParams } from 'react-pragmatic-router';
function UserDetail({ params }: { params: { id: string } }) {
const search = useSearchParams();
const tab = search.get('tab') ?? 'profile';
return <div>
<Link href={`/users/${params.id}`}>Profile</Link>
<Link href={`/users/${params.id}?tab=activity`}>Activity</Link>
<p>Current tab: {tab}</p>
</div>;
}Route params from patternMatcher always come from the pathname; the query is never merged into them.
Vite plugin (file-based routing):
Add the plugin to vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { reactPragmaticRouterPlugin } from 'react-pragmatic-router/vite';
export default defineConfig({
plugins: [
react(),
reactPragmaticRouterPlugin({ path: './src/routes' }),
],
});Add a reference in an ambient .d.ts (e.g. src/vite-env.d.ts) so TS knows the virtual module:
/// <reference types="react-pragmatic-router/client" />Then in your app:
import { Suspense } from 'react';
import { BrowserRouter } from 'react-pragmatic-router';
import { Routes, ModalRoutes } from 'virtual:react-pragmatic-router/routes';
export function App() {
return <BrowserRouter>
<Suspense fallback={null}>
<Routes />
</Suspense>
<Suspense fallback={null}>
<ModalRoutes />
</Suspense>
</BrowserRouter>;
}Use two separate <Suspense> boundaries — one per slot — so a loading modal chunk doesn't collapse the background page (and vice versa).
Conventions
| File | Pattern |
|----------------------------------------|----------------------|
| routes/index.tsx | / |
| routes/about.tsx | /about |
| routes/users/index.tsx | /users |
| routes/users/new.tsx | /users/new |
| routes/users/[id].tsx | /users/:id |
| routes/users/[id]/posts.tsx | /users/:id/posts |
| routes/docs/[...slug].tsx | /docs/*slug |
| routes/(marketing)/pricing.tsx | /pricing |
| routes/_layout.tsx | wraps every page |
| routes/users/_layout.tsx | wraps every /users/* page |
| routes/@modal/edit-thing/[id].tsx | /edit-thing/:id (overlay modal) |
- Each route file must
export defaulta component. It receives{ params }as a prop. [id]→ named param.[...slug]→ catch-all, matches the rest of the path (including slashes), sorted after all other routes.- Folders named
(something)are route groups: they don't appear in the URL, but can contain their own_layout.tsxthat applies only to pages inside the group. _layout.tsxat any depth wraps every descendant route. Layouts receive{ children, params }. Nest freely —routes/_layout.tsxwraps everything,routes/users/_layout.tsxadditionally wraps/users/*.- Other files prefixed with
_are ignored (treat them as private). Folders prefixed with_are also skipped entirely, so you can colocate non-route code — e.g.routes/_components/Button.tsxorroutes/users/_hooks/useUser.ts— without it showing up as a URL. - Files with a sub-extension (anything with a
.in the base name, e.g.[id].trpc.tsx,users.server.ts,index.test.tsx) are treated as colocated files and are not routes. Onlyfoo.tsxbecomes a route, notfoo.anything.tsx. - Sorting: static segments beat dynamic ones beat catch-all. So
/users/newwins over/users/:id, and/users/:idwins over/*rest. SwitchRoutewithexact: trueis used under the hood, so only one route renders at a time.- Dev server does a full reload when route files are added, removed, or renamed.
Modals
Files inside any @modal/ folder are modal routes. The @modal segment is dropped from the URL (like a route group), so routes/@modal/edit-thing/[id].tsx becomes /edit-thing/:id. Modals don't inherit page layouts.
To open one as an overlay, pass the modal prop to <Link> / <NavLink>:
<Link href="/edit-thing/42" modal>Edit 42</Link>This stashes the current URL in history.state as backgroundLocation. <Routes /> then renders against the background, so the page you were on stays mounted, and <ModalRoutes /> renders the modal on top. Browser back closes the modal; refresh shows the modal standalone (no background).
Inside a modal, read backgroundLocation and call setLocation to close:
const { backgroundLocation, setLocation } = useRouter();
const close = () => setLocation(backgroundLocation ?? '/');Transitions (motion/react)
The virtual module also exports two hooks so you can drive animations from the matched pattern:
import {
Routes,
ModalRoutes,
useMatchedRoute,
useMatchedModal,
} from 'virtual:react-pragmatic-router/routes';useMatchedRoute()→ the matched page pattern (matched againstbackgroundLocation ?? location), ornull.useMatchedModal()→ the matched modal pattern (matched against the livelocation), ornull.
Use them as <AnimatePresence> keys.
Modals (always a top-level overlay):
function AnimatedModals() {
const matched = useMatchedModal();
return (
<AnimatePresence>
{matched && (
<Suspense fallback={null}>
<motion.div
key={matched}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
>
<ModalRoutes />
</motion.div>
</Suspense>
)}
</AnimatePresence>
);
}Put the <Suspense> inside the <AnimatePresence> conditional, so the <motion.div> only mounts once the modal chunk has loaded. Otherwise the enter animation starts on an empty wrapper and the content pops in mid-tween.
Pages — put the <AnimatePresence> inside each layout, not around <Routes />. The plugin already keys every layout by its file identity and every leaf page by its pattern, so each layout's children slot is a keyed child that AnimatePresence can track:
// routes/_layout.tsx
import { AnimatePresence } from 'motion/react';
export default function RootLayout({ children }) {
return (
<>
<Header />
<main>
<AnimatePresence mode="wait">{children}</AnimatePresence>
</main>
</>
);
}Then have each leaf page wrap its content in a motion.* element with initial/animate/exit. A shared wrapper keeps this tidy:
// routes/_components/Page.tsx
export function Page({ children }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
>
{children}
</motion.div>
);
}Why this works:
- Layout keys are stable per layout file (
layout:0,layout:1, …). AnAnimatePresenceinsideroutes/_layout.tsxsees its child key stay the same when you navigate between/usersand/users/42(both wrap alayout:1element at that slot), so the root layout does not re-animate. - The inner
routes/users/_layout.tsxgets a keyed leaf as its child, so/users→/users/newtriggers an exit/enter inside that layout only. /users/42→/users/43animates too: the leaf is keyed by the resolved pathname (not the pattern), so dynamic-param siblings swap cleanly. Revisiting the same URL keeps the key stable, so it won't re-animate unnecessarily.
See examples/vite-advanced for a complete setup demonstrating layouts, groups, dynamic params and catch-all routes.
Advanced (Animations etc):
import { ReactNode, useMemo } from 'react';
import { useRouter, patternMatcher, ParamsType } from 'react-pragmatic-router';
import { AnimatePresence, motion } from 'framer-motion';
function AnimatedRoute(props: {
pattern: string;
exact?: boolean;
element: ({ params }: { params: ParamsType }) => ReactNode
}) {
const { location } = useRouter();
const matches = patternMatcher(props.pattern, location, props.exact);
const cached = useMemo(() => {
if (!matches) return null;
return props.element({ params: matches?.groups || {} });
}, [!!matches, JSON.stringify(matches?.groups)]);
return <AnimatePresence mode="wait">{!!matches && cached}</AnimatePresence>;
}
function AnimatedPage() {
return <motion.div
initial={{ opacity: 0, y: '-20px' }}
animate={{ opacity: 1, y: '0px' }}
exit={{ opacity: 0, y: '-20px' }}
transition={{ duration: 0.2 }}
style={{
position: 'fixed',
top: '0',
left: '0',
width: '100vw',
height: '100vh',
background: "white"
}}
>
<h1>Animated page!</h1>
</motion.div>;
}
export function App() {
return <BrowserRouter>
<AnimatedRoute pattern="/" element={() => <AnimatedPage />} />
<AnimatedRoute pattern="/some-other-page" element={() => <AnimatedPage />} />
</BrowserRouter>;
}
