On a regular website, the browser handles navigation: click a link — the server returns a new page. In a React application, that doesn't happen. The browser loaded one HTML page, and from then on everything is JavaScript. So how do you move between screens? How do you make the back button work? How do you preserve direct links to specific sections?
Routing answers these questions. The standard in the React ecosystem is React Router.
What client-side routing is
On a regular website, URL and page are one and the same: new URL = new server request = new HTML. In a React application, the URL changes but no server request is made — React Router intercepts the navigation and decides what to render.
This is called client-side routing. The user sees different "pages," even though the application never reloaded. The URL is real: you can copy it, open it in a new tab, bookmark it — everything works.
To connect React Router, wrap the application in BrowserRouter:
import { BrowserRouter } from "react-router-dom";
ReactDOM.createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<App />
</BrowserRouter>
);
Routes and nesting
A route is a mapping between a URL path and the component to render. The basic form:
import { Routes, Route } from "react-router-dom";
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<ProductList />} />
<Route path="/about" element={<About />} />
</Routes>
);
}
So far the routes are flat. But a real application is structured differently: there's a shared header, sidebar, and footer — and they don't change when navigating. Only the central part of the screen updates.
For this, routes are nested inside each other, and the slot where the nested screen goes is marked with the Outlet component:
import { Routes, Route, Outlet } from "react-router-dom";
function AppLayout() {
return (
<div>
<Header />
<nav><Sidebar /></nav>
<main>
<Outlet /> {/* nested route renders here */}
</main>
<Footer />
</div>
);
}
function AppRoutes() {
return (
<Routes>
<Route element={<AppLayout />}>
<Route path="/products" element={<ProductList />} />
<Route path="/products/:id" element={<ProductDetails />} />
<Route path="/account" element={<Account />} />
</Route>
</Routes>
);
}
AppLayout renders once and stays on screen. When navigating between /products and /account, only the content inside <main> changes. The header and footer don't flicker.
URL parameters and programmatic navigation
A dynamic segment of a path is denoted with a colon: /products/:id. This is a route parameter — it can be any value: 123, abc, some-slug.
To read the parameter inside a component, use useParams:
import { useParams } from "react-router-dom";
function ProductDetails() {
const { id } = useParams(); // id === "123" if URL is /products/123
// then we fetch the product by this id
}
There are two ways to navigate between routes. A regular link — the Link component (it behaves like <a> but doesn't reload the page):
import { Link } from "react-router-dom";
<Link to="/products">All products</Link>
Programmatic navigation after an action (for example, after saving a form) — the useNavigate hook:
import { useNavigate } from "react-router-dom";
function CreateProductForm() {
const navigate = useNavigate();
async function handleSubmit() {
await saveProduct(formData);
navigate("/products"); // navigate after successful save
}
}
navigate(-1) works like the browser's back button.
Route-level data loading
When the user navigates to /products/123, you need to load the product's data. There are two common approaches.
The first — the screen component loads data itself using hooks (e.g., TanStack Query). The URL parameter becomes the query key:
function ProductDetails() {
const { id } = useParams();
const { data: product, isLoading } = useQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id!),
});
if (isLoading) return <Spinner />;
return <div>{product?.name}</div>;
}
This approach is simple and works well with TanStack Query — one tool manages all server data.
The second — a data router with loader functions. Data is loaded before the screen component renders:
const router = createBrowserRouter([
{
path: "/products/:id",
loader: ({ params }) => fetchProduct(params.id!),
element: <ProductDetails />,
},
]);
function ProductDetails() {
const product = useLoaderData() as Product;
return <div>{product.name}</div>; // data is already available, no spinner needed
}
For most applications the first approach is sufficient. loader functions are useful when you want to avoid showing the screen at all without data — they eliminate the intermediate loading state entirely.
Protected routes
Some screens are only available to authenticated users: account pages, settings, admin sections. To redirect an unauthenticated user to the login page, make a wrapper component:
import { Navigate } from "react-router-dom";
function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useCurrentUser();
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
Use it as a wrapper around a route:
<Route
path="/account"
element={
<RequireAuth>
<Account />
</RequireAuth>
}
/>
Or as a parent route for an entire group of protected screens:
<Route element={<RequireAuth><Outlet /></RequireAuth>}>
<Route path="/account" element={<Account />} />
<Route path="/settings" element={<Settings />} />
</Route>
An important boundary: this is a convenience for the user, not a security measure. An unauthenticated user simply won't see the screen — but the server must still check permissions on every request. Hiding a button without a backend check is not enough.
In short
- Client-side routing — the URL changes without a page reload; browser back/forward buttons and direct links work.
Routes+Route— declare which component to show for which path.- Nested routes +
Outlet— the shared layout stays on screen, only the central part changes. :idin a path — a route parameter; read withuseParams.Link— navigation via a link;useNavigate— programmatic navigation.- Route data is loaded either by the component (TanStack Query +
useParams) or by aloaderfunction in a data router. - A protected route is a wrapper component that redirects to
/loginif the user is not authenticated. - Frontend permission checks are a convenience; real security lives on the server.
What to read next
- Data fetching: TanStack Query — how to load and cache server data.
- Components and state — the basics of the component-based approach in React.
- Styling and design tokens — how to style the interface.