React has no router. It renders components; it has no opinion about URLs. Every React application with more than one page uses a library for this, and in practice that library is React Router.
This post is written against react-router-dom 7, using the declarative
<Routes> API.
npm install react-router-domThe router goes at the top
One <BrowserRouter> wrapping the app, in the entry file:
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
{/* … */}
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);BrowserRouter uses the History API, so URLs look normal:
/menu, /checkout. The alternative, HashRouter, produces
/#/menu and exists for static hosts that cannot be configured — which brings us to the
one deployment detail that catches everyone:
Your server must return index.html for every path. The router is
client-side, so the server has never heard of /checkout. Load the home page and
navigate and it works; refresh on /checkout and you get a 404 until the server is told
to serve the app for unknown paths. On Vite's dev server this is automatic; in production it is a
one-line rewrite rule.
Routes
<Routes> picks the best match among its <Route> children and
renders it:
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/menu" element={<MenuPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/order-confirmation/:orderId" element={<OrderConfirmationPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="*"
element={
<div className="container py-5 text-center">
<h1 className="h4">Page not found</h1>
</div>
}
/>
</Routes>element takes rendered JSX — {<MenuPage />}, not
{MenuPage}. That is what lets you pass props to a routed component.
path="*" is the catch-all. Order does not matter — v6 and later score routes by
specificity rather than taking the first match — but keeping the catch-all last still reads
better.
Everything outside <Routes> stays mounted across navigation, which is exactly
what you want for a navbar, a footer and a cart drawer:
<div className="d-flex flex-column min-vh-100">
<AppNavbar onOpenCart={() => setCartOpen(true)} />
<main className="flex-grow-1">
<ErrorBoundary>
<Suspense fallback={<Spinner />}>
<Routes>{/* … */}</Routes>
</Suspense>
</ErrorBoundary>
</main>
<CartDrawer show={cartOpen} onHide={() => setCartOpen(false)} />
<Footer />
</div>Link, not anchor
A plain <a href> triggers a full page load: the whole application unmounts,
downloads again and restarts, and your cart, your auth state and your scroll position go with it.
<Link> changes the URL and re-renders, which is the entire point.
<Link to="/menu" className="btn btn-primary btn-lg">
Order now
</Link><NavLink> is a Link that knows whether it is the current page, so
the active section can be highlighted without comparing to the URL by hand:
{/*
NavLink (not Link) gets an isActive flag, so the current section can be
highlighted without manually comparing to the URL.
*/}
<Nav.Link as={NavLink} to="/menu" end>
Menu
</Nav.Link>end means "match this path exactly". Without it /menu would count as
active while you are on /menu/pizzas, and / would be active
everywhere.
as={NavLink} is a react-bootstrap idiom: render this Bootstrap component using that
element. It is how you get Bootstrap's styling and Router's behaviour in one tag.
URL parameters
A :name segment captures part of the path:
<Route path="/order-confirmation/:orderId" element={<OrderConfirmationPage />} />import { useParams } from 'react-router-dom';
export function OrderConfirmationPage() {
const { orderId } = useParams<{ orderId: string }>();
/* … fetch the order … */
}Params are always strings, and always possibly undefined as far as TypeScript is
concerned — a route param cannot be proven present at the type level. Convert and guard.
Query strings as state
useSearchParams gives you the query string with a useState-shaped API.
This is worth more than it first appears:
/*
* REACT ROUTER CONCEPT: useSearchParams
*
* The active filter lives in the URL rather than in component state, so /menu?type=PIZZA is
* shareable, bookmarkable, and survives a refresh. Treating the URL as state is usually the
* right call for anything a user might want to link to.
*/
const [searchParams, setSearchParams] = useSearchParams();
const activeFilter = (searchParams.get('type') as Filter) ?? 'ALL';
const handleFilter = useCallback(
(filter: Filter) => {
if (filter === 'ALL') {
setSearchParams({});
} else {
setSearchParams({ type: filter });
}
},
[setSearchParams],
);The filter is not in useState. Because it is in the URL, /menu?type=PIZZA
can be linked to, bookmarked, shared, and survives a refresh — and the browser Back button steps
through filter changes, for free.
Ask this of every piece of UI state: would a user ever want to link to this? If yes, it belongs in the URL. Which tab is open, what is being searched for, which page of results — usually yes. Whether a dropdown is open — no.
Navigating from code
After a form submits, or a cart checkout starts, you navigate in a handler rather than by clicking:
const navigate = useNavigate();
function goToCheckout() {
onHide();
navigate('/checkout');
}async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
try {
await login(email, password);
navigate(redirectTo, { replace: true });
} catch {
// The error message is already surfaced through AuthContext; nothing to do here.
}
}{ replace: true } swaps the current history entry instead of adding one. After a
successful sign-in that matters: without it, pressing Back returns the user to the login page they
have just left.
Layout routes
Nested routes let a parent render a shell and children render inside it. The parent puts an
<Outlet /> where the child should go:
<Route
path="/admin"
element={
<ProtectedRoute requireAdmin>
<AdminLayout />
</ProtectedRoute>
}
>
{/* `index` is the route shown at /admin itself. */}
<Route index element={<AdminReportsPage />} />
<Route path="products" element={<AdminProductsPage />} />
<Route path="toppings" element={<AdminToppingsPage />} />
<Route path="crusts" element={<AdminCrustsPage />} />
<Route path="orders" element={<AdminOrdersPage />} />
<Route path="users" element={<AdminUsersPage />} />
</Route>export default function AdminLayout() {
return (
<Container className="py-4">
<h1 className="h3 fw-bold mb-1">Admin</h1>
<p className="text-muted">Menu management and reporting.</p>
<Nav variant="tabs" className="mb-4 admin-nav">
{TABS.map((tab) => (
<Nav.Item key={tab.to}>
<Nav.Link as={NavLink} to={tab.to} end={tab.end}>
{tab.label}
</Nav.Link>
</Nav.Item>
))}
</Nav>
<Outlet />
</Container>
);
}Three things fall out of that. Child paths are relative — path="products" resolves to
/admin/products. index is what renders at /admin itself.
And switching tabs never re-mounts the shell, so its state and scroll position survive.
Guarded routes
The layout route above is wrapped in <ProtectedRoute requireAdmin>, and that is
the whole access-control story for six admin screens. Guarding the parent means a new tab
cannot be added unprotected by accident:
export function ProtectedRoute({ children, requireAdmin = false }: Props) {
const { isAuthenticated, isAdmin, initialising } = useAuth();
const location = useLocation();
/*
* Wait for the stored token to be validated before deciding anything.
*
* Without this, refreshing the page on /admin renders one frame where `isAuthenticated` is still
* false — and redirects a perfectly valid admin to the login screen.
*/
if (initialising) {
return <Spinner animation="border" variant="danger" role="status" />;
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (requireAdmin && !isAdmin) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}<Navigate> is a redirect expressed as a component — rendering it navigates.
That is much easier to reason about than calling navigate() from an effect, and it does
not flash the protected content first.
The state={{ from: location }} is what makes the round trip work. The login page reads
it back and returns the user where they were headed:
/**
* Where to go after a successful sign-in.
*
* ProtectedRoute stashes the attempted location in navigation state, so a user bounced off
* /admin lands back on /admin rather than on the home page.
*/
const redirectTo =
(location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';And the initialising guard is not decoration. Without it, a signed-in admin who
refreshes on /admin is thrown out, because the stored token has not been validated yet
when the first frame renders.
This is a usability guard, not security. Anyone can edit client-side JavaScript.
The real enforcement is the backend rejecting /api/admin/** without a valid admin token;
the route guard just stops honest users seeing a broken page.
Routes and code splitting
Route boundaries are the natural place to split your bundle, because a route is by definition something most visitors will not visit. That is the next but one lesson.