React Router
Client-side routing end to end: how a SPA fakes navigation without reloads, routes and dynamic params, nested/layout routes with Outlet, programmatic navigation, query strings, route loaders and data APIs, and auth guards — beginner to advanced in one page.
What is client-side routing? In a traditional multi-page app, every link click asks the server for a brand-new HTML document and the browser throws away the current page and paints a fresh one — a full reload. A single-page application (SPA) loads one HTML shell plus a JavaScript bundle once, and from then on simulates navigation entirely in the browser: it intercepts link clicks, updates the address bar with the History API, and swaps which React components are on screen. No round-trip, no white flash, no lost in-memory state. React Router is the library that wires all of this together for React.
Why not just use <a href>? A plain <a> triggers a real browser navigation — a full document load — which defeats the whole point of a SPA. React Router gives you <Link to="/about"> and <NavLink>, which render an <a> for accessibility and SEO but call preventDefault() on click and route on the client instead. NavLink additionally exposes an isActive flag so you can highlight the current tab. Rule of thumb: internal navigation uses <Link>/<NavLink>; external links stay plain <a>.
The core pieces. You wrap your app in a router (<BrowserRouter> for real URLs backed by the History API, or createBrowserRouter in the modern data-router API). Inside, <Routes> holds a set of <Route path=... element=... /> declarations. React Router matches the current URL against these paths top-down, picks the best match, and renders that route's element. Everything below is elaboration on this one matching step.
Dynamic segments and params. A path segment prefixed with a colon is a URL parameter: path="/users/:userId" matches /users/42 and /users/abc. Inside the matched component you read it with const { userId } = useParams(). Params are always strings, so coerce when you need a number. This is how detail pages work — one route definition serves an unbounded set of records, and the param tells the component which record to fetch.
Query strings vs params. Path params identify which resource (/products/99); query strings carry optional modifiers like filters, sorting, and pagination (/products?sort=price&page=2). Read and write the query string with const [searchParams, setSearchParams] = useSearchParams(). Keeping filter state in the URL (rather than component state) makes the view shareable, bookmarkable, and survivable across refreshes — a URL is the cheapest global state you have.
Nested and layout routes — the Outlet mental model. Real apps share chrome: a sidebar, a header, a tab bar that stays put while the inner content changes. You express this by nesting <Route> elements. The parent route renders shared layout plus an <Outlet /> — a placeholder where whichever child route matched gets rendered. Navigating between siblings only re-renders the outlet, not the surrounding layout. The special index route (<Route index element={...} />) is what shows at the parent's exact path when no child segment is present.
Relative paths and links. Inside a nested route, paths and <Link to> values are resolved relative to the parent by default, so a child route with path="edit" under /expenses/:id matches /expenses/:id/edit. A leading slash (to="/login") is absolute. Understanding relative resolution is what lets you move a whole route subtree without rewriting every link inside it.
Programmatic navigation. Sometimes you navigate in response to logic, not a click — after a successful save, on a failed auth check, or via a wizard's Next button. const navigate = useNavigate() gives you an imperative function: navigate('/expenses') pushes a new history entry, navigate('/login', { replace: true }) replaces the current entry (so Back doesn't return to a dead page), and navigate(-1) goes back. Passing { state: {...} } ships data to the destination, readable there via useLocation().state.
404 and catch-all routes. A route with path="*" matches anything no other route caught, so it's your Not Found page. Because matching is best-match rather than strictly first-match in the modern API, the catch-all only fires when nothing more specific applies. Every routed app should ship one so unknown URLs render a friendly page instead of a blank screen.
Data routers, loaders, and actions. The modern createBrowserRouter API introduces loaders and actions that move data-fetching into the routing layer. A loader runs before the route's component renders and its returned data is read with useLoaderData() — this fetches data as soon as the URL is known, eliminating the classic render-then-fetch-then-spinner waterfall. An action handles form submissions to that route. useNavigation() exposes pending state so you can show a global loading bar. This is the same idea Next.js and Remix generalise: co-locate data requirements with the route that needs them.
Route guards / protected routes. There's no dedicated 'guard' primitive — a protected route is just a component that checks auth state and either renders its children or redirects. The declarative form returns <Navigate to="/login" replace />; the loader form throws a redirect('/login') before the page ever renders, which is stronger because the protected component's code never runs. Always use replace on the redirect so the protected URL isn't left in history for the Back button to stumble into.
Lazy loading routes (code splitting). Shipping every route's code in the initial bundle is wasteful. Pair React.lazy(() => import('./Admin')) with a <Suspense fallback={...}> boundary (or the data router's route.lazy) so a route's JavaScript downloads only when the user actually navigates there. This is one of the highest-leverage performance wins in a SPA: route-based code splitting keeps the first load small.
The server fallback gotcha. Because the client owns routing, deep links like /expenses/42 don't correspond to a file on the server. If the user refreshes or shares that URL, the server must be configured to return the SPA's index.html for all unmatched paths (a rewrite rule), letting the client router take over. Forgetting this is the classic 'works on click, 404 on refresh' bug.
The mental model (memorise this). One HTML shell loads once; the router intercepts navigation and swaps components instead of reloading. <Routes>/<Route> match the URL → dynamic :params (via useParams) identify the resource, query strings (via useSearchParams) carry filters → nested routes render shared layout with an <Outlet />, and index fills the parent's exact path → useNavigate does imperative redirects, path="*" catches 404s → loaders fetch before render, guards redirect with <Navigate replace />, and lazy routes split the bundle. The URL is your source of truth.
React Router's route table is your Spring `@RequestMapping` / Vert.x `Router` — path patterns matched against an incoming request, with `:id` playing the role of `@PathVariable` and query strings playing `@RequestParam`. Nested routes with `<Outlet />` are like a layout template with a content region, the way a servlet filter or a Vert.x sub-router wraps handlers with shared behaviour and delegates the inner work. Loaders that run before the component renders are the front-end equivalent of a controller method fetching its data before returning the view — data-then-render, not render-then-fetch. A protected route throwing `redirect('/login')` is exactly an auth interceptor / `HandlerInterceptor.preHandle` returning false and issuing a 302 before your controller code ever executes. The one difference: all of this runs in the browser against the History API instead of on the server against HTTP.
- Client-side routing swaps components and updates the URL via the History API without a full page reload, so JS bundle and in-memory state persist.
- Use Link and NavLink for internal navigation (they render an accessible anchor but route on the client); keep plain a tags for external links.
- Dynamic segments like :id are read with useParams and are always strings; query strings for filters and pagination are read/written with useSearchParams.
- Nested routes render shared layout plus an Outlet placeholder; the index route renders at the parent's exact path when no child segment matches.
- useNavigate gives imperative navigation: navigate(path) pushes, navigate(path, replace:true) replaces, navigate(-1) goes back; pass state to ship data.
- A path='*' catch-all route is your 404 page and every routed app should include one.
- Loaders in the data-router API fetch data before the component renders, avoiding the render-fetch-spinner waterfall; actions handle form submissions.
- A protected route is just a component that redirects with Navigate replace, or a loader that throws redirect('/login') before the page renders.
- Route-based code splitting with React.lazy plus Suspense downloads a route's JS only when visited, keeping the initial bundle small.
- Configure the server to return index.html for all unmatched paths, or deep links 404 on refresh even though they work on click.
Worked Code
import {
BrowserRouter, Routes, Route, Link, NavLink,
Navigate, Outlet, useParams, useNavigate,
} from 'react-router-dom';
import type { ReactNode } from 'react';
function App() {
return (
<BrowserRouter>
<nav>
{/* Link renders an <a> but routes on the client (no reload) */}
<Link to="/">Home</Link>
{/* NavLink adds an isActive flag for highlighting the current tab */}
<NavLink to="/expenses" className={({ isActive }) => isActive ? 'active' : ''}>
Expenses
</NavLink>
</nav>
<Routes>
<Route path="/" element={<Dashboard />} />
{/* Nested/layout route: ExpenseLayout renders shared chrome + <Outlet/> */}
<Route path="/expenses" element={<ExpenseLayout />}>
<Route index element={<ExpenseList />} /> {/* /expenses exactly */}
<Route path=":id" element={<ExpenseDetail />} /> {/* /expenses/42 */}
<Route path=":id/edit" element={<ExpenseForm />} />{/* relative to parent */}
<Route path="new" element={<ExpenseForm />} />
</Route>
{/* Protected route: renders children only when authenticated */}
<Route
path="/admin"
element={
<RequireAuth>
<AdminPanel />
</RequireAuth>
}
/>
{/* Catch-all 404 — matches anything no other route caught */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
// Shared layout: the surrounding chrome stays put; only <Outlet/> re-renders.
function ExpenseLayout() {
return (
<div className="shell">
<Sidebar />
<main><Outlet /></main>
</div>
);
}
// Dynamic params + programmatic navigation.
function ExpenseDetail() {
const { id } = useParams(); // always a string: "/expenses/42" -> "42"
const navigate = useNavigate();
// fetch the expense by id here...
return (
<>
<h1>Expense #{id}</h1>
{/* imperative redirect, e.g. after an action */}
<button onClick={() => navigate('/expenses')}>Back to list</button>
</>
);
}
// A "guard" is just a component: render children or redirect.
function RequireAuth({ children }: { children: ReactNode }) {
const { user } = useAuth();
// replace: true so the Back button never returns to the protected URL
return user ? <>{children}</> : <Navigate to="/login" replace />;
}import { useSearchParams } from 'react-router-dom';
// URL like /products?category=meals&page=2 — the URL is your state store.
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') ?? 'all';
const page = Number(searchParams.get('page') ?? '1'); // params are strings
function changeCategory(next: string) {
// merge into existing params so we don't clobber page, sort, etc.
setSearchParams((prev) => {
prev.set('category', next);
prev.set('page', '1'); // reset pagination on a new filter
return prev;
});
}
return (
<>
<select value={category} onChange={(e) => changeCategory(e.target.value)}>
<option value="all">All</option>
<option value="meals">Meals</option>
<option value="travel">Travel</option>
</select>
<p>Showing {category}, page {page}</p>
{/* State lives in the URL -> shareable, bookmarkable, survives refresh */}
</>
);
}import {
createBrowserRouter, RouterProvider, redirect,
useLoaderData, useNavigation, Form,
} from 'react-router-dom';
const router = createBrowserRouter([
{
path: '/expenses/:id',
// loader runs BEFORE the component renders -> no render-fetch-spinner waterfall
loader: async ({ params, request }) => {
const token = getToken();
if (!token) throw redirect('/login'); // guard: page code never runs
const res = await fetch(`/api/expenses/${params.id}`, {
signal: request.signal, // aborts if the user navigates away
});
if (!res.ok) throw new Response('Not Found', { status: 404 });
return res.json();
},
// action handles <Form method="post"> submissions to this route
action: async ({ request, params }) => {
const form = await request.formData();
await fetch(`/api/expenses/${params.id}`, {
method: 'PUT',
body: JSON.stringify(Object.fromEntries(form)),
});
return redirect(`/expenses/${params.id}`);
},
element: <ExpenseDetail />,
},
]);
function ExpenseDetail() {
const expense = useLoaderData() as { title: string };
const navigation = useNavigation(); // 'idle' | 'loading' | 'submitting'
const busy = navigation.state !== 'idle';
return (
<Form method="post">
<h1>{expense.title}</h1>
<button disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
</Form>
);
}
export default function App() {
return <RouterProvider router={router} />;
}import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// The Admin bundle downloads only when the user navigates to /admin.
const AdminPanel = lazy(() => import('./AdminPanel'));
const Reports = lazy(() => import('./Reports'));
function App() {
return (
<BrowserRouter>
{/* Suspense shows a fallback while the chunk is fetched */}
<Suspense fallback={<div>Loading…</div>}>
<Routes>
<Route path="/admin" element={<AdminPanel />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}▶Try It Live
Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.
Interview-Ready Q&A
With server-side routing, every navigation requests a fresh HTML document and the browser reloads the whole page. With client-side routing (React Router), the router intercepts navigation, updates the URL via the History API, and swaps the rendered component tree in place — no full reload, so app state and the loaded JS bundle persist and there's no white flash. The trade-offs: the initial bundle must ship the routing logic, you need a server rewrite so deep links resolve to the SPA entry point, and you must manage focus/scroll and document titles yourself since the browser no longer does it per page.
- 1Client-side routing changes the URL and swaps components without a full page reload; state and bundle persist.
- 2Use Link/NavLink for internal navigation; keep plain a for external links.
- 3useParams reads dynamic :segments (always strings); useSearchParams reads/writes query strings for filters and pagination.
- 4Parent layout + <Outlet /> gives nested routes; index renders the parent's exact path.
- 5useNavigate for imperative redirects; use replace: true so Back doesn't return to a dead page; navigate(-1) goes back.
- 6path='*' is the 404 catch-all — always include one.
- 7Loaders fetch before render (useLoaderData) and avoid the render-fetch-spinner waterfall; actions handle form posts.
- 8A protected route redirects with <Navigate replace /> or a loader that throws redirect('/login'); guards are UX only, authorise on the server.
- 9React.lazy + Suspense splits route bundles so JS loads only when visited.
- 10Configure a server rewrite to index.html for unmatched paths, or deep links 404 on refresh.