π What You Will Learn
- Why SPAs need a router: client-side navigation vs full page reloads, and what
history.pushStateactually does - Setup done right:
createBrowserRouter+RouterProvider(and when the classicBrowserRouterstyle still makes sense) - Navigation:
Link,NavLinkactive styling, and programmatic redirects withuseNavigate - Dynamic & nested routes:
/users/:idwithuseParams, layout routes withOutlet - URL as state: filters and sorting with
useSearchParams - Real-app patterns: 404 pages,
errorElement, protected routes, and the loader/action data APIs
Prerequisites: comfortable with components, props, state, and hooks β if not, start with the React Cheatsheet and React Hooks posts first.
Why SPAs Need a Router
In a classic multi-page website, every link click asks the server for a brand-new HTML document. The browser throws away the current page β all your React state, every open dropdown, every scroll position β and rebuilds everything from scratch. You see the white flash, the spinner, the re-downloaded JS.
A React single-page application (SPA) loads one HTML page once. After that, "navigating" should just mean swap which components are on screen. But two problems appear the moment you try to fake pages with plain state:
- The URL never changes β users can't bookmark, share, or refresh into a specific screen
- The Back/Forward buttons break β the browser thinks you never left
The fix is a browser API called history.pushState. It updates the address bar and adds a history entry without triggering a page load:
// This is (conceptually) what a router does on every <Link> click:
// 1. Stop the browser's default full-page navigation
event.preventDefault();
// 2. Update the address bar + history β NO request, NO reload
history.pushState({}, '', '/about');
// 3. Re-render: match the new URL against your route table
// and swap in the right components
renderRouteFor(window.location.pathname);
// 4. Listen for Back/Forward so those work too
window.addEventListener('popstate', () => {
renderRouteFor(window.location.pathname);
});You could build this by hand β many of us did, once, as a rite of passage β but it gets hairy fast: URL params, nested layouts, redirects, scroll restoration, data loading. React Router is the de-facto standard library that handles all of it. Ang importante dito: the router's whole job is keeping the URL and your UI in sync, in both directions.
Install & Setup: createBrowserRouter
One package, whether you're on Vite, CRA, or anything else that renders in a browser:
npm install react-router-domModern React Router (v6.4 onwards, including v7) wants you to define routes as a data structure with createBrowserRouter, then hand it to RouterProvider:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Home from './pages/Home';
import About from './pages/About';
const router = createBrowserRouter([
{ path: '/', element: <Home /> },
{ path: '/about', element: <About /> },
]);
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);You'll also still meet the classic JSX style in older codebases and tutorials β BrowserRouter wrapping Routes and Route elements:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</BrowserRouter>
);
}Which one should you pick? Both work, and route matching behaves the same. But createBrowserRouter is the one the React Router team recommends, because the data APIs β loader, action, errorElement (sections 9 and 11) β only work with it. Everything in this post uses createBrowserRouter; recognize the classic style, write the modern one.
index.html for every path. If refreshing /users/2 in production gives a 404, that's not React Router β it's your server missing an SPA fallback rewrite (Netlify _redirects, Vercel rewrites, nginx try_files).Your First Routes: path + element
A route is the simplest possible mapping: when the URL is this path, render this element. Try the mini app below β click Home, About, and Contact and watch the address bar change while the page never reloads.
useState β the current "path" lives in state and a tiny matcher decides what to render, with a fake address bar on top. Visually it behaves exactly like the real thing, and each π Code tab shows the real react-router-dom code you'd write in your own project.π Home
Welcome! This view swapped in without a page reload.
Simulated inline with useState β the Code tab shows the real react-router-dom version.
And the page components are just⦠components. Nothing router-specific about them:
export default function About() {
return (
<main>
<h1>About</h1>
<p>We build small, fast apps.</p>
</main>
);
}Link & NavLink: Navigation Without Reload
Routes decide what renders where β Link is how users get there. It renders a real <a> tag (so right-click β open in new tab, hover preview, and accessibility all still work), but intercepts the click and does the pushState dance instead of a full reload.
NavLink is Link with one superpower: it knows whether its to matches the current URL, and hands you an isActive flag for styling. Perfect for nav bars β see how the highlighted pill follows you around:
The home page. Look at the nav β the current page's link is highlighted, exactly what NavLink's isActive gives you.
<a href> for internal navigation. It triggers a full page load: your entire React app unmounts, all state is lost, and the JS bundle re-downloads. The symptom is unmistakable β a white flash and a reset app. Reserve <a> for external links only; inside your app, it's Link or NavLink, always.Dynamic Routes & useParams
Real apps don't have a route per user β they have one route pattern that matches them all. A : in a path segment makes it dynamic:
const router = createBrowserRouter([
{ path: '/users', element: <UserList /> },
{ path: '/users/:id', element: <UserDetail /> },
// ^^^ ":id" matches /users/1, /users/2, /users/abcβ¦
]);Inside the matched component, useParams() returns every dynamic segment. Click a user card below and watch the URL β the detail "page" reads the id straight out of it:
/users/2 gives you { id: "2" } β the string "2", not the number 2. A strict comparison like u.id === Number(id) forgotten the wrong way round is a classic "my detail page is blank" bug. Convert explicitly (Number(id)) or keep your ids as strings everywhere.You can stack multiple params in one path β each one shows up in the same object:
{ path: '/teams/:teamId/members/:memberId', element: <Member /> }
// In Member.jsx:
// const { teamId, memberId } = useParams();
// "/teams/frontend/members/7" β { teamId: "frontend", memberId: "7" }Nested Routes & Outlet: Layout Routes
Here's where React Router goes from "nice" to "how did I live without this". Most apps have persistent chrome β a navbar, a dashboard sidebar β that should stay put while an inner panel changes. Copy-pasting the shell into every page is the beginner move; the router move is a layout route:
- The parent route renders the shell (sidebar, header)
- Child routes render inside it, wherever the shell places
<Outlet /> - An
index: truechild is the default β what shows at the parent's own URL
Click around the sidebar below. The shell never re-mounts β only the Outlet area swaps:
Overview
This is the index route β what renders in the Outlet at /dashboard.
Notice the child paths in the Code tab: analytics, not /analytics. Child paths are relative β they extend the parent's path. That's how /dashboard + analytics becomes /dashboard/analytics.
Programmatic Navigation with useNavigate
Links cover navigation the user initiates. But sometimes your code decides to move: after a successful form submit, after logout, after a countdown. That's useNavigate β call it like a function, go somewhere:
Create your account
Submitting calls navigate("/welcome") β no link clicked.
The useful variations, in one place:
const navigate = useNavigate();
navigate('/welcome'); // push a new history entry
navigate('/welcome', { replace: true }); // REPLACE the current entry β
// Back won't return to this page
navigate(-1); // same as the browser Back button
navigate('/orders', { state: { justCreated: true } }); // pass hidden state
// read it on the other side with: const { state } = useLocation();navigate where a Link belongs. A <div onClick={() => navigate('/about')}> looks the same on screen but loses everything an anchor gives you for free: open-in-new-tab, keyboard focus, screen reader announcement, SEO crawlability. Rule of thumb: if the user clicks it to go somewhere, it's a Link; if code decides to redirect, it's navigate().Search Params with useSearchParams
Filters, search text, sort order, page number β where should that state live? Put it in useState and it evaporates on refresh; a teammate can't send you a link to "page 3, sorted by price". Put it in the URL and it becomes shareable, bookmarkable, refresh-proof, and Back-button-friendly for free.
useSearchParams is useState for the query string. Type in the search box below and watch the address bar update live:
Watch the address bar: the filter state lives in the URL, so it survives refresh and can be shared.
useSearchParams. If it's ephemeral UI (a dropdown being open, a hover) keep it in useState.The 404 Route & errorElement
Two different failure modes, two different tools. First: the URL matches no route at all. The path: "*" splat route catches everything nothing else claimed β your 404 page:
import { Link } from 'react-router-dom';
function NotFound() {
return (
<main>
<h1>404 β Page not found</h1>
<p>That page doesn't exist (or moved).</p>
<Link to="/">β Back to home</Link>
</main>
);
}
const router = createBrowserRouter([
{ path: '/', element: <Home /> },
{ path: '/users/:id', element: <UserDetail /> },
{ path: '*', element: <NotFound /> }, // matches ANYTHING unmatched β keep it LAST in spirit
]);Second: a route matched, but something threw β a render bug, a failed loader (section 11), a Response you threw on purpose. errorElement is the route-level error boundary for that:
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
function ErrorPage() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
// e.g. a loader did: throw new Response('Not Found', { status: 404 })
return <h1>{error.status} β {error.statusText}</h1>;
}
return <h1>Something went wrong π¬</h1>;
}
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <ErrorPage />, // catches errors from this route AND its children
children: [
{ index: true, element: <Home /> },
{ path: 'users/:id', element: <UserDetail />, loader: userLoader },
],
},
]);errorElement on your root route as a safety net, then add more specific ones on child routes where you want a nicer, contextual error (e.g. "User not found" inside the dashboard shell instead of a full-page crash).Protected Routes: The Auth Guard Pattern
Some pages are members-only. The standard pattern is a tiny guard component that wraps a private route's element: logged in β render children; logged out β <Navigate to="/login" />. Toggle the checkbox below and try to visit /profile in both states:
π Public home page. Try opening /profile while logged out.
Two details make this pattern production-grade rather than a toy:
The guard passes state={{ from: location }} to the login page, and after a successful login you navigate(from, { replace: true }). Users land on the page they originally wanted β not dumped on the homepage.
// Wrap ONE layout route β every child is protected automatically
{
path: '/account',
element: (
<RequireAuth>
<AccountLayout />
</RequireAuth>
),
children: [
{ index: true, element: <AccountHome /> },
{ path: 'orders', element: <Orders /> },
{ path: 'settings', element: <Settings /> },
],
}Data Loading: Loaders & Actions
The classic SPA data flow is: route renders β useEffect fires β spinner β data arrives β re-render. It works, but every page starts with a loading waterfall, and every component carries its own loading/error state boilerplate.
Since v6.4, React Router flips the order: a loader fetches data before the route renders. Navigation waits for the data, then renders the page complete. The component just reads the result with useLoaderData():
import { useLoaderData } from 'react-router-dom';
// Runs BEFORE the component renders. Route params come in as an argument.
export async function userLoader({ params }) {
const res = await fetch(`/api/users/${params.id}`);
if (!res.ok) {
// Thrown Responses are caught by the nearest errorElement
throw new Response('User not found', { status: 404 });
}
return res.json();
}
export function UserDetail() {
// No useEffect, no loading state, no null checks β
// by the time this renders, the data is guaranteed to be here
const user = useLoaderData();
return (
<div>
<h1>{user.name}</h1>
<p>{user.role}</p>
</div>
);
}
// Wire it up in main.jsx:
// { path: '/users/:id', element: <UserDetail />, loader: userLoader }action is the write-side twin: it receives form submissions. Pair it with React Router's <Form> component and you get mutations that automatically revalidate every loader on the page afterwards β your UI can't drift out of sync with the server:
import { Form, redirect } from 'react-router-dom';
// Runs when the <Form> below is submitted
export async function newUserAction({ request }) {
const formData = await request.formData();
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: formData.get('name') }),
});
const user = await res.json();
return redirect(`/users/${user.id}`); // straight to the new detail page
}
export function NewUser() {
// method="post" submits to this route's action β no onSubmit handler needed
return (
<Form method="post">
<input name="name" placeholder="Name" required />
<button type="submit">Create user</button>
</Form>
);
}
// { path: '/users/new', element: <NewUser />, action: newUserAction }Honest take: for small apps, useEffect fetching is fine and one less concept. Reach for loaders/actions when you're tired of spinner waterfalls and hand-rolled revalidation β or reach for TanStack Query, which solves overlapping problems (caching, background refetch) and composes well with React Router. More on that in the data fetching post.
Common Mistakes
Every one of these comes from a real code review. Learn them here instead of in production. π
<a href> full-reloads your SPA.// β BAD: full page reload β the whole React app remounts
<a href="/about">About</a>
// β
GOOD: client-side navigation, state preserved
<Link to="/about">About</Link>
// β
Plain <a> is CORRECT for external links only
<a href="https://github.com/thirdygayares" target="_blank" rel="noreferrer">
GitHub
</a>/dashboard/analyticsβ¦ and the content area shows nothing. No error, no warning β the child route matched and rendered into an Outlet that doesn't exist. This one eats a solid 30 minutes the first time.// β BAD: children match but have nowhere to render
function DashboardLayout() {
return (
<div>
<Sidebar />
{/* child content silently vanishes */}
</div>
);
}
// β
GOOD: Outlet marks where children appear
import { Outlet } from 'react-router-dom';
function DashboardLayout() {
return (
<div>
<Sidebar />
<main>
<Outlet />
</main>
</div>
);
}An index route is the child that renders at the parent's own URL. It gets index: true and no path β giving it both is a config error, and giving it path: "/" inside a nested route doesn't do what you hope:
// β BAD: an "index" with a path, or a nested absolute "/"
children: [
{ index: true, path: 'overview', element: <Overview /> }, // config error
]
// β
GOOD: index = "what shows at /dashboard itself"
children: [
{ index: true, element: <Overview /> }, // /dashboard
{ path: 'analytics', element: <Analytics /> }, // /dashboard/analytics
]// β BAD: absolute child path that doesn't extend the parent β
// React Router throws: "absolute route path must start with parent path"
{
path: '/dashboard',
children: [
{ path: '/analytics', element: <Analytics /> }, // π₯
],
}
// β
GOOD: relative child paths extend the parent
{
path: '/dashboard',
children: [
{ path: 'analytics', element: <Analytics /> }, // β /dashboard/analytics
],
}
// Same idea in Links inside nested routes:
// <Link to="settings"> β relative: /dashboard/settings
// <Link to="/settings"> β absolute: /settings (top level!)Best Practices + Hook/Component Reference
createBrowserRouter β it unlocks loaders, actions, and errorElementLink/NavLink for users, navigate() for code β never a plain <a> internallyOutlet for shared chrome instead of copy-pasting shells into every page/users/:id), search params for view options (?q=&sort=)path: "*" route and a root errorElement β users will hit both, guaranteedRequireAuth around a section protects every child, and the server still enforces the real rulesYour scanner-friendly cheat sheet β the seven names you'll type daily:
| API | Type | What it does |
|---|---|---|
<Link to> | Component | Client-side navigation; renders a real <a> without the reload |
<NavLink to> | Component | Link that knows if it's active β className/style receive { isActive } |
<Outlet /> | Component | Placeholder in a layout route where the matched child renders |
useParams() | Hook | Reads dynamic segments: "/users/:id" β { id: "2" } (always strings) |
useNavigate() | Hook | Programmatic navigation: navigate('/x'), navigate(-1), { replace, state } |
useSearchParams() | Hook | Read/write the query string like useState: ?q=&sort= filters, pagination |
useLocation() | Hook | The current location object: pathname, search, and navigation state |
Practice Project + What's Next
Cement all of this by building a small "Mini Store" β it touches every concept from this post:
/β home page with aNavLinknav bar (active styling)/productsβ product grid with?q=search and?sort=viauseSearchParams/products/:idβ detail page readinguseParams, with a loader fetching from a fake API (or FastAPI, if you followed the backend series!)/accountβ a layout route withOutlet+ sidebar (Profile / Orders), wrapped in aRequireAuthguard with a fake login- After "checkout",
navigate("/thank-you", { replace: true }) - A
path: "*"404 page and a rooterrorElement
If every piece works β active nav, shareable filter URLs, a guard that redirects and then returns you β you genuinely know React Router. Kayang-kaya mo na 'to. πͺ
- Code Splitting β lazy-load each route with
React.lazyso users only download the page they're on - Data Fetching β go deeper than loaders: caching, background refetch, and TanStack Query
- Testing React β test routed components with a memory router, including params and redirects
Keep building! πͺ Routing is the skeleton of every real React app β once URLs, layouts, and guards feel natural, you stop building "pages" and start building applications.