Lesson 15 of 20

React Router

What Client-Side Routing Means

A traditional website has one HTML file per page. Click a link and the browser throws away everything it has, asks the server for a new document, and rebuilds the page from scratch. A React app built with Vite has exactly one HTML file, so that model does not apply — you need something that changes which components render when the URL changes, without ever asking the server for a new document.

That is client-side routing, and React Router is the library almost everyone uses for it. It watches the browser's address bar, matches the current path against a list of routes you define, and renders the matching component. The URL changes, the back button works, links can be bookmarked and shared — but no page load ever happens, so your app's state survives navigation and the transition is instant.

The useful way to think about it: the URL becomes another piece of state that your UI reads. /products/42 is not a file on a server; it is a value your app interprets and renders. That framing explains why you read route parameters with a hook, just like any other data.

One caution before the code. React Router's API changed significantly between major versions. This lesson uses version 6 and later, which is what you get when you install it today. If you find a tutorial using <Switch> or component={Home}, it is written for version 5 and will not work.

Example
npm install react-router-dom
Notes
  • React itself has no routing. This is one of the places where being a library rather than a framework shows: routing is a separate package with its own release cycle and its own documentation, which is worth checking against the version in your package.json.

Defining Routes

Three components do the setup. BrowserRouter wraps your whole application and connects it to the browser's address bar — put it in main.jsx around <App />. Inside, <Routes> holds a list of <Route> elements, each mapping a path to the element that should render there.

Routes picks the single best match and renders only that one. The order you write them in does not decide the winner — React Router scores the routes and prefers the most specific — so a catch-all path="*" route is safe anywhere in the list and gives you a proper 404 page for anything unmatched. That catch-all is not optional in a real app: without it, an unknown URL renders nothing at all and looks like a broken build.

Paths can contain dynamic segments, written with a colon: /products/:productId. That route matches /products/42 and /products/kurta-blue alike, and the component reads the actual value with useParams(). One route definition covers every product in your catalogue.

Example
// src/main.jsx
import { BrowserRouter } from 'react-router-dom';

createRoot(document.getElementById('root')).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

// src/App.jsx
import { Routes, Route } from 'react-router-dom';

function App() {
  return (
    <>
      <NavBar />
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/products" element={<ProductList />} />
        <Route path="/products/:productId" element={<ProductDetail />} />
        <Route path="/cart" element={<Cart />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
      <Footer />
    </>
  );
}

// Anything outside <Routes> — the nav bar and footer here — renders on every page.
  • BrowserRouter — wraps the app, once, at the top
  • Routes — renders the one best-matching route
  • Route path element — a path and the element to render for it
  • :name in a path — a dynamic segment
  • path="*" — the catch-all, for 404 pages
  • Anything outside <Routes> stays on screen across navigations
Notes
  • The element prop takes a rendered element, element={<Home />}, not a component reference. Writing element={Home} renders nothing and produces a confusing error — this is one of the differences from older versions of the library.

Navigating: Link, NavLink and useNavigate

Never use a plain <a href> for internal navigation. It does exactly what it does on any website — asks the server for a new document — so your entire React app unloads and restarts, losing all state and taking a visible moment. The symptom is a page that flashes white when you click a menu item.

Use <Link to="/cart"> instead. It renders a real anchor tag, so middle-click and right-click still work as users expect, but it intercepts the click and updates the route without a page load. <NavLink> is the same thing with a bonus: it tells you whether it is the current page, so you can highlight the active menu item without comparing URLs yourself.

For navigation that is not a click on a link — after a successful login, once a form has saved, when a timer expires — use the useNavigate hook. It returns a function you call with a path. Passing { replace: true } replaces the current history entry instead of adding one, which is what you want after a login so that pressing Back does not return the user to the login form.

Plain <a href> remains correct for links that leave your app — an external site, a PDF, a mailto: address. Those genuinely are new documents.

Example
import { Link, NavLink, useNavigate } from 'react-router-dom';

function NavBar() {
  return (
    <nav>
      <Link to="/">Home</Link>

      {/* NavLink knows when it is the current route */}
      <NavLink
        to="/products"
        className={({ isActive }) => (isActive ? 'link active' : 'link')}
      >
        Products
      </NavLink>

      {/* External links stay as plain anchors */}
      <a href="https://example.com/docs" target="_blank" rel="noreferrer">Docs</a>
    </nav>
  );
}

function LoginForm() {
  const navigate = useNavigate();

  async function handleSubmit(e) {
    e.preventDefault();
    await signIn(email, password);
    navigate('/dashboard', { replace: true });   // Back should not return here
  }

  return <form onSubmit={handleSubmit}>…</form>;
}

// navigate(-1) goes back one entry, like the browser's Back button
Notes
  • useNavigate must be called at the top of the component, like every hook, and the function it returns is what you call inside handlers. Calling useNavigate() inside an if or a handler breaks the rules of hooks.

Reading the URL: Params and Query Strings

A route with a dynamic segment gives its component the matched value through useParams(), keyed by the name you used in the path. It is the standard way to build a detail page: one ProductDetail component serves every product, reading the id from the URL and fetching that product.

Everything from the URL is a string. useParams on /products/42 gives you '42', not 42. Compare it against a numeric id from your data with === and it will never match — a bug that is invisible until you add a console.log. Convert with Number() at the boundary, or keep your ids as strings consistently.

For filters, sorting and pagination — anything the user can change without it being a different page — the query string is the right home, and useSearchParams reads and writes it. It behaves like useState, except the value lives in the URL. That is a real feature, not a curiosity: a filtered, sorted, paginated view becomes a link the user can bookmark, share on WhatsApp, or reload without losing their place.

Finally, remember that the URL changing does not remount your component if the same route still matches. Moving from /products/42 to /products/43 re-renders ProductDetail with a new param — it does not create a new one. Any fetch must therefore live in an effect that depends on the param, or you will keep showing product 42.

Example
import { useParams, useSearchParams } from 'react-router-dom';

// Route: <Route path="/products/:productId" element={<ProductDetail />} />
function ProductDetail() {
  const { productId } = useParams();      // always a string: '42'
  const [product, setProduct] = useState(null);

  useEffect(() => {
    let ignore = false;
    fetchProduct(productId).then(p => { if (!ignore) setProduct(p); });
    return () => { ignore = true; };
  }, [productId]);                        // re-runs when the id changes

  if (!product) return <p>Loading…</p>;
  return <h1>{product.name}</h1>;
}

// /products?sort=price&page=2
function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();

  const sort = searchParams.get('sort') ?? 'name';
  const page = Number(searchParams.get('page') ?? 1);

  return (
    <>
      <select value={sort} onChange={e => setSearchParams({ sort: e.target.value, page: 1 })}>
        <option value="name">Name</option>
        <option value="price">Price</option>
      </select>
      <button onClick={() => setSearchParams({ sort, page: page + 1 })}>Next page</button>
    </>
  );
}
Notes
  • Because the filters live in the URL, the back button undoes them one at a time, which is exactly what users expect and what a plain useState version cannot do.

Nested Routes and Shared Layouts

Most applications have sections that share a shell. A dashboard with a sidebar, where the sidebar stays put and only the panel beside it changes. You could repeat the sidebar in every page component, but nested routes express it properly: a parent route renders the layout, and child routes render inside it.

The connection is the <Outlet /> component. Put it in the layout wherever the child should appear, and React Router renders the matching child route there. Child paths are written relative to the parent, so path="settings" under a parent at /dashboard becomes /dashboard/settings. A child with index instead of a path is what renders at the parent's own URL.

The same structure gives you protected routes cleanly. Make a layout component that checks whether the user is signed in: if they are, render <Outlet />; if not, render <Navigate to="/login" replace />. Every route nested inside is then protected by one piece of code, rather than by a check copied into each page.

Do not mistake this for security. A client-side guard only hides UI — anyone can edit the JavaScript in their own browser. The server must check permissions on every request that returns real data, without exception.

Example
import { Routes, Route, Outlet, Navigate } from 'react-router-dom';

function DashboardLayout() {
  return (
    <div className="dashboard">
      <Sidebar />
      <main>
        <Outlet />        {/* the matching child route renders here */}
      </main>
    </div>
  );
}

function RequireAuth() {
  const { user } = useAuth();
  if (!user) return <Navigate to="/login" replace />;
  return <Outlet />;
}

function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/login" element={<Login />} />

      <Route element={<RequireAuth />}>
        <Route path="/dashboard" element={<DashboardLayout />}>
          <Route index element={<Overview />} />          {/* /dashboard */}
          <Route path="courses" element={<MyCourses />} /> {/* /dashboard/courses */}
          <Route path="settings" element={<Settings />} /> {/* /dashboard/settings */}
        </Route>
      </Route>

      <Route path="*" element={<NotFound />} />
    </Routes>
  );
}
Notes
  • The sidebar in this arrangement never unmounts as you move between dashboard pages, so its scroll position, open sections and any state it holds all survive navigation.

The 404 That Appears Only After You Deploy

This one catches nearly everybody, so meet it here rather than in production. Your app works perfectly in development. You upload the dist/ folder to a host, click around, and everything is fine — until you refresh the page while on /products/42, or paste that link to someone. Then the server returns 404.

The reason is that only your app knows about that path. Clicking a Link is handled entirely in the browser; the server is never involved. But a refresh or a pasted link is a real HTTP request for /products/42, and the server goes looking for a file or folder with that name. There isn't one — your build produced a single index.html.

The fix is a server rule: for any request that does not match a real file, return index.html anyway and let React Router work out the rest. Every host has a way to express this. On Apache-based shared hosting it is a .htaccess file placed next to index.html; Netlify uses a _redirects file; Vercel and most static hosts have a setting for it. The rule must exclude real files, or your CSS and JavaScript will be served the HTML page too.

If you serve the app from a subfolder rather than the domain root, you need two more adjustments: set base in vite.config.js so assets resolve, and pass the same prefix as basename to BrowserRouter so routes match. Getting one but not the other produces a blank page with 404s in the network tab.

Example
# .htaccess for Apache / cPanel shared hosting — place it beside index.html
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>

# Netlify: a file named _redirects in the output folder
/*    /index.html   200

// Serving from https://example.com/app/ instead of the root:
// vite.config.js
export default defineConfig({ plugins: [react()], base: '/app/' });

// and
<BrowserRouter basename="/app">
  <App />
</BrowserRouter>
  • Works when clicking links, 404 on refresh — the server needs a fallback rule
  • Apache or cPanel — a .htaccess rewrite to index.html
  • Netlify — a _redirects file; Vercel and others have an equivalent setting
  • Blank page with 404s for CSS and JS — the base path is wrong
  • Routes never match under a subfolder — set basename on BrowserRouter
  • Always test with npm run preview before uploading
Notes
  • HashRouter, which puts routes after a # as in /#/products/42, needs no server configuration at all and is a legitimate fallback when you cannot change the server. The URLs are uglier and search engines handle them less well, so treat it as a last resort.
Ask AI