Skip to content
React rc routing 4 min read

Routing with React Router

A React app built with Vite ships as a single HTML page — the browser loads one document and React swaps the UI in place. That is great for speed, but a real app still needs distinct “pages”: a home view, a product detail view, a settings screen, each with its own URL. A router is what maps the browser’s address bar to the component tree, so the URL becomes part of your application state. React Router is the de facto library for this, and it lets users navigate, bookmark, and use the back button without ever triggering a full page reload.

Why a single-page app needs a router

In a traditional server-rendered site, each URL is a separate request that returns a fresh HTML document. A single-page app (SPA) loads once and then takes over navigation in JavaScript. Without a router you would either be stuck on one screen or forced to render everything conditionally from a hand-rolled useState flag — which breaks deep links, the back button, and refresh.

A router gives you three things for free:

  • URL-driven rendering — the path decides which components mount.
  • History integration — back/forward buttons and bookmarks just work.
  • Declarative navigation — links and redirects without window.location reloads.

Installing react-router-dom

React Router for the web lives in the react-router-dom package. Install it into a Vite + React project:

npm install react-router-dom

Output:

added 3 packages in 1s

The library is framework-agnostic at its core, but react-router-dom includes the browser-specific pieces — BrowserRouter, Link, createBrowserRouter, and the DOM history bindings — so it is the only package most web apps need.

The classic approach: BrowserRouter

The simplest way to add routing is to wrap your app in BrowserRouter and declare routes with JSX. BrowserRouter uses the HTML5 history API, so URLs look clean (/about, not /#/about).

// main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import App from "./App";
import Home from "./pages/Home";
import About from "./pages/About";

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<App />}>
          <Route index element={<Home />} />
          <Route path="about" element={<About />} />
        </Route>
      </Routes>
    </BrowserRouter>
  </StrictMode>
);

Routes picks the single best match for the current URL, and each Route maps a path to an element. The nested index route renders inside App when the path is exactly /. This style is concise and ideal for small to mid-size apps.

The modern approach: createBrowserRouter

Recent versions of React Router introduced a data router created with createBrowserRouter. Instead of declaring routes as JSX children, you describe them as a plain array of objects. This unlocks data loading, actions, and pending UI that the JSX form cannot express.

// main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import App from "./App";
import Home from "./pages/Home";
import About from "./pages/About";

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
    children: [
      { index: true, element: <Home /> },
      { path: "about", element: <About /> },
    ],
  },
]);

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>
);

The route tree is identical, but the object form lets each route attach a loader to fetch data before the component renders, an action to handle form submissions, and an errorElement for graceful failures. For any non-trivial app, this is the recommended setup.

BrowserRouter vs. createBrowserRouter

AspectBrowserRouter + RoutescreateBrowserRouter
Route definitionJSX <Route> elementsArray of route objects
Data loadersNot supportedloader per route
Form actionsNot supportedaction per route
Error boundariesManualBuilt-in errorElement
Best forSmall apps, quick demosProduction apps, data-heavy UIs

Tip: Pick one approach per app and stick with it. Mixing the data router’s loader/action features with the JSX Routes form does not work — those data APIs only run under RouterProvider.

What routing gives you in components

Once a router is in place, any component in the tree can read and change the URL through hooks like useParams, useNavigate, and useLocation, and render links with Link and NavLink. The Outlet component marks where a parent route should render its matched child — that is how layouts work.

// App.jsx — a shared layout with a nav bar and an outlet
import { Link, Outlet } from "react-router-dom";

export default function App() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link> | <Link to="/about">About</Link>
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
}

The nav bar stays mounted across navigations while Outlet swaps in Home or About. No reload, no flicker — just the matched view changing in place.

Best Practices

  • Prefer createBrowserRouter with RouterProvider for new apps so loaders, actions, and error boundaries are available from day one.
  • Use a single root layout route with an Outlet for shared chrome like headers and footers instead of repeating it in every page.
  • Reach for Link / NavLink for navigation rather than <a href>, which forces a full page reload and discards app state.
  • Define an index route for each layout so the parent path renders something meaningful.
  • Keep route configuration in one module so the app’s URL surface is easy to scan and audit.
  • Wrap the app in StrictMode during development to surface unsafe lifecycle and effect patterns early.
Last updated June 14, 2026
Was this helpful?