React – Routing with React Router

August 2, 20266 min readUpdated 8/18/2026

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-dom

The 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>

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.

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.

Next

useMemo, useCallback and memo.