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.locationreloads.
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
| Aspect | BrowserRouter + Routes | createBrowserRouter |
|---|---|---|
| Route definition | JSX <Route> elements | Array of route objects |
| Data loaders | Not supported | loader per route |
| Form actions | Not supported | action per route |
| Error boundaries | Manual | Built-in errorElement |
| Best for | Small apps, quick demos | Production apps, data-heavy UIs |
Tip: Pick one approach per app and stick with it. Mixing the data router’s
loader/actionfeatures with the JSXRoutesform does not work — those data APIs only run underRouterProvider.
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
createBrowserRouterwithRouterProviderfor new apps so loaders, actions, and error boundaries are available from day one. - Use a single root layout route with an
Outletfor shared chrome like headers and footers instead of repeating it in every page. - Reach for
Link/NavLinkfor navigation rather than<a href>, which forces a full page reload and discards app state. - Define an
indexroute 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
StrictModeduring development to surface unsafe lifecycle and effect patterns early.