React

Routing with React Router

Thirdy Gayares
14 min read

πŸŽ“ What You Will Learn

  • Why SPAs need a router: client-side navigation vs full page reloads, and what history.pushState actually does
  • Setup done right: createBrowserRouter + RouterProvider (and when the classic BrowserRouter style still makes sense)
  • Navigation: Link, NavLink active styling, and programmatic redirects with useNavigate
  • Dynamic & nested routes: /users/:id with useParams, layout routes with Outlet
  • 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.

1

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:

what-a-router-does.js
// 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.

πŸ’‘ Mental model: the URL is just another piece of state β€” arguably your app's most important state, because it's the only one users can see, bookmark, share, and hit Back on. React Router is the state manager for it.
2

Install & Setup: createBrowserRouter

One package, whether you're on Vite, CRA, or anything else that renders in a browser:

terminal
npm install react-router-dom

Modern React Router (v6.4 onwards, including v7) wants you to define routes as a data structure with createBrowserRouter, then hand it to RouterProvider:

src/main.jsx
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:

src/App.jsx (classic style)
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.

⚠️ Deploy gotcha: a browser router needs your host to serve 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).
3

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.

πŸ’‘ About the demos in this post: react-router-dom isn't installed inside this article, so every demo simulates the router with plain 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.
πŸ”’ myapp.com/

🏠 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:

src/pages/About.jsx
export default function About() {
  return (
    <main>
      <h1>About</h1>
      <p>We build small, fast apps.</p>
    </main>
  );
}
βœ… Checkpoint: if clicking between views updates the address bar with no white flash and Back/Forward walk through your history, your router is wired correctly. That's the entire foundation β€” everything else in this post is refinement.
4

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:

πŸ”’ myapp.com/

The home page. Look at the nav β€” the current page's link is highlighted, exactly what NavLink's isActive gives you.

⚠️ Never use a plain <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.
5

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:

src/main.jsx (routes excerpt)
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:

πŸ”’ myapp.com/users
⚠️ Params are always strings. /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:

src/main.jsx (multiple params)
{ path: '/teams/:teamId/members/:memberId', element: <Member /> }

// In Member.jsx:
// const { teamId, memberId } = useParams();
// "/teams/frontend/members/7" β†’ { teamId: "frontend", memberId: "7" }
6

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: true child 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:

πŸ”’ myapp.com/dashboard
<Outlet /> renders here

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.

πŸ’‘ Because the layout persists, so does its state. A search box in the sidebar keeps its text while you hop between Overview and Analytics β€” no lifting state up, no context, no effort. This is why data-heavy apps (dashboards, admin panels, settings pages) lean so hard on nested routes.
7

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:

πŸ”’ myapp.com/signup

Create your account

Submitting calls navigate("/welcome") β€” no link clicked.

The useful variations, in one place:

useNavigate-recipes.jsx
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();
⚠️ Don't use 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().
8

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:

πŸ”’ myapp.com/products?sort=asc
Laptop Stand$35
USB-C Hub$49
Webcam$59
Mechanical Keyboard$89
Noise-Canceling Headphones$179

Watch the address bar: the filter state lives in the URL, so it survives refresh and can be shared.

βœ… Rule of thumb for where state lives: if the user would expect a refresh or a shared link to reproduce the screen β€” filters, tabs, pagination, search β€” put it in the URL with useSearchParams. If it's ephemeral UI (a dropdown being open, a hover) keep it in useState.
9

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:

src/main.jsx (404 route)
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:

src/main.jsx (errorElement)
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 },
    ],
  },
]);
πŸ’‘ Put an 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).
10

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:

πŸ”’ myapp.com/

🏠 Public home page. Try opening /profile while logged out.

Two details make this pattern production-grade rather than a toy:

1Remember where they were going

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.

2Guard the layout, not every leaf
src/main.jsx (guard a whole section)
// 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 /> },
  ],
}
⚠️ Client-side guards are UX, not security. Everything in your bundle ships to the browser β€” a determined user can bypass any redirect. The guard's job is a good experience for honest users; the API must still reject unauthenticated requests server-side. If your backend is FastAPI, that's exactly what the JWT tutorial covers.
11

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():

src/pages/UserDetail.jsx (with loader)
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:

src/pages/NewUser.jsx (with action)
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.

12

Common Mistakes

Every one of these comes from a real code review. Learn them here instead of in production. πŸ˜…

1Using <a> instead of <Link>
⚠️ The symptom: navigation "works", but the screen flashes white and all state resets β€” cart emptied, form wiped, user logged-out-looking. A plain <a href> full-reloads your SPA.
mistake-anchor-tag.jsx
// ❌ 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>
2Forgetting <Outlet /> in a layout route
⚠️ The symptom: you click a sidebar link, the URL changes to /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.
mistake-missing-outlet.jsx
// ❌ 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>
  );
}
3Index route confusion

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:

mistake-index-route.jsx
// ❌ 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
]
4Absolute vs relative paths in children
mistake-absolute-child.jsx
// ❌ 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!)
13

Best Practices + Hook/Component Reference

βœ…Use createBrowserRouter β€” it unlocks loaders, actions, and errorElement
βœ…Link/NavLink for users, navigate() for code β€” never a plain <a> internally
βœ…Layout routes + Outlet for shared chrome instead of copy-pasting shells into every page
βœ…Shareable state goes in the URL β€” params for identity (/users/:id), search params for view options (?q=&sort=)
βœ…Always ship a path: "*" route and a root errorElement β€” users will hit both, guaranteed
βœ…Guard layouts, not leaves β€” one RequireAuth around a section protects every child, and the server still enforces the real rules

Your scanner-friendly cheat sheet β€” the seven names you'll type daily:

APITypeWhat it does
<Link to>ComponentClient-side navigation; renders a real <a> without the reload
<NavLink to>ComponentLink that knows if it's active β€” className/style receive { isActive }
<Outlet />ComponentPlaceholder in a layout route where the matched child renders
useParams()HookReads dynamic segments: "/users/:id" β†’ { id: "2" } (always strings)
useNavigate()HookProgrammatic navigation: navigate('/x'), navigate(-1), { replace, state }
useSearchParams()HookRead/write the query string like useState: ?q=&sort= filters, pagination
useLocation()HookThe current location object: pathname, search, and navigation state
14

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 a NavLink nav bar (active styling)
  • /products β€” product grid with ?q= search and ?sort= via useSearchParams
  • /products/:id β€” detail page reading useParams, with a loader fetching from a fake API (or FastAPI, if you followed the backend series!)
  • /account β€” a layout route with Outlet + sidebar (Profile / Orders), wrapped in a RequireAuth guard with a fake login
  • After "checkout", navigate("/thank-you", { replace: true })
  • A path: "*" 404 page and a root errorElement

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. πŸ’ͺ

πŸš€ What's Next:
  • Code Splitting β€” lazy-load each route with React.lazy so 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.

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.