So far, we've built React applications using components, props, state, Hooks, and TypeScript.
But most real-world applications have more than one page.
Think about an e-commerce application:
-
/— Home page -
/products— Product listing -
/products/42— Product details -
/cart— Shopping cart -
/login— Login page -
/dashboard— User dashboard
How can we navigate between these pages without reloading the entire application?
The answer is React Router.
React Router is a routing library that allows React applications to display different components based on the current URL.
In this guide, we'll learn how to configure React Router, create dynamic routes, build nested layouts, protect routes, navigate programmatically, and handle common routing scenarios.
Let's get started!
1. What Is React Router?
React Router connects URLs to React components.
For example:
/ → HomePage
/about → AboutPage
/products → ProductsPage
/products/42 → ProductDetailsPage
/contact → ContactPage
When a user visits a URL, React Router matches it against your route definitions and renders the appropriate component.
Without a routing library, you would need to manually manage URL changes, browser history, and conditional rendering.
React Router handles these responsibilities for you.
Why Use React Router?
Some important benefits include:
- Client-side navigation without full-page reloads.
- Dynamic route parameters.
- Nested routes and shared layouts.
- Browser back and forward navigation.
- URL-based application state.
- Navigation links and redirects.
- Route-level loading and error handling.
2. Installing React Router
For a new React application, you can start with Vite:
npm create vite@latest my-react-app -- --template react
cd my-react-app
npm install
Install React Router:
npm install react-router
For current React Router declarative examples, we'll import the APIs from react-router.
If you're maintaining an older project that uses react-router-dom, check its version-specific documentation before mixing APIs. React Router's package organization has evolved across major versions.
3. Creating Your First Routes
Let's create a basic application with three pages:
- Home
- About
- Contact
First, create the page components.
HomePage.jsx
function HomePage() {
return <h1>Welcome to the Home Page</h1>;
}
export default HomePage;
AboutPage.jsx
function AboutPage() {
return <h1>About Us</h1>;
}
export default AboutPage;
ContactPage.jsx
function ContactPage() {
return <h1>Contact Us</h1>;
}
export default ContactPage;
Now configure the router in App.jsx:
import {
BrowserRouter,
Routes,
Route
} from "react-router";
import HomePage from "./pages/HomePage";
import AboutPage from "./pages/AboutPage";
import ContactPage from "./pages/ContactPage";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
</Routes>
</BrowserRouter>
);
}
export default App;
Let's understand the main components.
BrowserRouter
BrowserRouter enables client-side routing using the browser's History API.
It provides routing context to the components inside it.
Routes
Routes contains your route definitions.
React Router matches the current URL against the available routes.
Route
Each Route connects a URL pattern to a React element.
For example:
<Route path="/about" element={<AboutPage />} />
When the URL matches /about, React Router renders AboutPage.
4. Navigation with Link
You could use a normal HTML anchor:
<a href="/about">About</a>
However, a regular anchor performs a full browser navigation.
For client-side navigation, use React Router's Link component.
import { Link } from "react-router";
function Navbar() {
return (
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/contact">Contact</Link>
</nav>
);
}
export default Navbar;
The Link component changes the URL and lets React Router render the matching route without a full-page reload.
Active Navigation with NavLink
Sometimes you want to style the current navigation item.
Use NavLink:
import { NavLink } from "react-router";
function Navbar() {
return (
<nav>
<NavLink
to="/"
className={({ isActive }) =>
isActive ? "active" : ""
}
end
>
Home
</NavLink>
<NavLink to="/about">
About
</NavLink>
<NavLink to="/contact">
Contact
</NavLink>
</nav>
);
}
NavLink provides an isActive value that tells you whether the link matches the current route.
The end prop makes the home link match only the exact / route.
5. Dynamic Routes and URL Parameters
Real applications often need dynamic URLs.
For example:
/products/1
/products/2
/products/3
Creating a separate route for every product would be impractical.
Instead, define a dynamic route:
<Route
path="/products/:productId"
element={<ProductDetails />}
/>
The :productId section represents a URL parameter.
Now use useParams to access it:
import { useParams } from "react-router";
function ProductDetails() {
const { productId } = useParams();
return (
<div>
<h1>Product Details</h1>
<p>Product ID: {productId}</p>
</div>
);
}
export default ProductDetails;
When the user visits:
/products/42
The component receives:
productId === "42"
Remember that URL parameters are strings, even when they contain numbers.
If you need a number, validate and convert it:
const id = Number(productId);
if (!Number.isInteger(id)) {
return <p>Invalid product ID</p>;
}
6. Using URL Parameters to Fetch Data
Let's make the product details page more realistic.
import { useEffect, useState } from "react";
import { useParams } from "react-router";
function ProductDetails() {
const { productId } = useParams();
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
async function loadProduct() {
try {
setLoading(true);
setError("");
const response = await fetch(
`/api/products/${productId}`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error("Product not found");
}
const data = await response.json();
setProduct(data);
} catch (err) {
if (err.name !== "AbortError") {
setError(err.message || "Something went wrong");
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
loadProduct();
return () => controller.abort();
}, [productId]);
if (loading) return <p>Loading product...</p>;
if (error) return <p>{error}</p>;
if (!product) return <p>Product not found.</p>;
return (
<article>
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>{product.description}</p>
</article>
);
}
export default ProductDetails;
This example demonstrates:
- Reading a dynamic URL parameter.
- Fetching data based on that parameter.
- Displaying loading and error states.
- Cancelling outdated requests when the route changes or the component unmounts.
For more complex applications, React Router's data APIs or a data-fetching library can handle these responsibilities more cleanly.
7. Nested Routes
Nested routes are useful when several pages share the same layout.
Imagine a dashboard with:
/dashboard
/dashboard/profile
/dashboard/settings
/dashboard/orders
All these pages should share a sidebar and header.
Instead of repeating the layout in every page, define a parent route.
DashboardLayout.jsx
import { Link, Outlet } from "react-router";
function DashboardLayout() {
return (
<div className="dashboard">
<aside>
<h2>Dashboard</h2>
<nav>
<Link to="/dashboard">Overview</Link>
<Link to="/dashboard/profile">Profile</Link>
<Link to="/dashboard/settings">Settings</Link>
<Link to="/dashboard/orders">Orders</Link>
</nav>
</aside>
<main>
<Outlet />
</main>
</div>
);
}
export default DashboardLayout;
The important part is:
<Outlet />
Outlet renders the matching child route inside the parent layout.
Now configure the nested routes:
import {
BrowserRouter,
Routes,
Route
} from "react-router";
function App() {
return (
<BrowserRouter>
<Routes>
<Route
path="/dashboard"
element={<DashboardLayout />}
>
<Route
index
element={<DashboardHome />}
/>
<Route
path="profile"
element={<ProfilePage />}
/>
<Route
path="settings"
element={<SettingsPage />}
/>
<Route
path="orders"
element={<OrdersPage />}
/>
</Route>
</Routes>
</BrowserRouter>
);
}
Notice that child paths don't begin with /.
React Router combines them with the parent path.
For example:
Parent: /dashboard
Child: profile
Result: /dashboard/profile
What Is an Index Route?
An index route is the default child page rendered when the parent route matches exactly.
<Route index element={<DashboardHome />} />
This means DashboardHome appears at /dashboard.
8. Programmatic Navigation with useNavigate
Sometimes navigation needs to happen after an action.
For example:
- After a successful login.
- After submitting a form.
- After deleting an item.
- After completing a checkout.
Use the useNavigate Hook.
import { useNavigate } from "react-router";
function LoginPage() {
const navigate = useNavigate();
function handleLogin() {
// Perform authentication here.
navigate("/dashboard");
}
return (
<button onClick={handleLogin}>
Login
</button>
);
}
Replace the Current History Entry
You can replace the current history entry:
navigate("/dashboard", { replace: true });
This is useful when users shouldn't return to the previous page using the browser's back button, such as after certain authentication transitions.
Navigate Back
navigate(-1);
This moves back one entry in the browser history.
Be careful: the user may not have a previous application page to return to.
9. Passing State During Navigation
Sometimes you need to pass temporary data to another route.
For example:
import { useNavigate } from "react-router";
function ProductsPage() {
const navigate = useNavigate();
function openProduct() {
navigate("/products/42", {
state: {
from: "featured-products"
}
});
}
return (
<button onClick={openProduct}>
View Product
</button>
);
}
Read the state in the destination component:
import { useLocation } from "react-router";
function ProductDetails() {
const location = useLocation();
const source = location.state?.from;
return (
<div>
<h1>Product Details</h1>
<p>Navigation source: {source || "Direct visit"}</p>
</div>
);
}
Navigation state is useful for temporary navigation context.
However, it is not a replacement for URL parameters or persistent storage.
A user may open the destination URL directly, in which case the navigation state may not exist.
10. Working with Query Parameters
URL parameters and query parameters are different.
A route parameter is part of the route path:
/products/42
A query parameter appears after ?:
/products?category=electronics&sort=price
Use useSearchParams to read and update query parameters.
import { useSearchParams } from "react-router";
function ProductsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get("category") || "all";
const sort = searchParams.get("sort") || "default";
function updateCategory(event) {
setSearchParams({
category: event.target.value,
sort
});
}
return (
<div>
<h1>Products</h1>
<select
value={category}
onChange={updateCategory}
>
<option value="all">All</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<p>Current sort: {sort}</p>
</div>
);
}
Query parameters are particularly useful for:
- Search filters.
- Sorting.
- Pagination.
- Category selection.
- Shareable application states.
Because they're part of the URL, users can bookmark or share the current filtered view.
11. Protected Routes
Many applications have pages that should only be available to authenticated users.
Examples include:
- Dashboard.
- Account settings.
- Order history.
- Admin pages.
A simple protected route component can check whether the user is authenticated.
import { Navigate, Outlet } from "react-router";
function ProtectedRoute({ isAuthenticated }) {
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}
Configure it:
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute isAuthenticated={true} />}>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Routes>
In a real application, isAuthenticated should come from your authentication system or application state.
You might also want to preserve the page the user originally requested:
function ProtectedRoute({ isAuthenticated }) {
const location = useLocation();
if (!isAuthenticated) {
return (
<Navigate
to="/login"
state={{ from: location }}
replace
/>
);
}
return <Outlet />;
}
After login, you can navigate back to the requested page.
Important Security Note
A protected React route is only a user-interface restriction.
It does not secure your backend.
Your API must independently verify authentication and authorization for every protected operation. Never rely on client-side route checks to protect sensitive data.
12. Handling 404 Pages
What happens when a user visits a URL that doesn't match any route?
You should show a custom not-found page.
Create a component:
function NotFoundPage() {
return (
<div>
<h1>404</h1>
<p>Sorry, the page you're looking for doesn't exist.</p>
</div>
);
}
Add a wildcard route:
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
The wildcard path matches URLs that don't match another route.
This creates a better user experience than leaving users on a blank page.
13. Layout Routes
A layout route allows several pages to share a common interface.
For example, an application might have:
- A public layout with a navbar and footer.
- An authenticated layout with a sidebar.
- An admin layout with administrative navigation.
A public layout could look like this:
import { Outlet } from "react-router";
function MainLayout() {
return (
<>
<header>
<h1>My Application</h1>
</header>
<main>
<Outlet />
</main>
<footer>
<p>Copyright 2026</p>
</footer>
</>
);
}
Configure it:
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
</Route>
</Routes>
The layout remains mounted while the child route changes, allowing shared UI to stay consistent.
14. React Router with TypeScript
If you're using TypeScript, you can type route parameters and component props.
For example:
import { useParams } from "react-router";
function ProductDetails() {
const { productId } = useParams<"productId">();
if (!productId) {
return <p>Product ID is missing.</p>;
}
return <h1>Product ID: {productId}</h1>;
}
The generic parameter describes the name of the route parameter. It does not guarantee that the URL contains a valid product ID.
You still need to validate and convert it when necessary:
const id = Number(productId);
if (!Number.isInteger(id) || id <= 0) {
return <p>Invalid product ID.</p>;
}
For navigation state, define an explicit type:
type NavigationState = {
from?: string;
};
Then validate or narrow the state when reading it, because navigation state may be absent or may not have the shape you expect.
15. Data Routers: Loaders and Actions
React Router also provides a data-router approach.
Instead of fetching data inside every component with useEffect, you can define a loader for a route.
A loader runs as part of route navigation and provides data to the route component.
Example:
import {
createBrowserRouter,
RouterProvider,
useLoaderData
} from "react-router";
async function productLoader({ params }) {
const response = await fetch(
`/api/products/${params.productId}`
);
if (!response.ok) {
throw new Response("Product not found", {
status: 404
});
}
return response.json();
}
function ProductDetails() {
const product = useLoaderData();
return (
<article>
<h1>{product.name}</h1>
<p>${product.price}</p>
</article>
);
}
Create the router:
const router = createBrowserRouter([
{
path: "/products/:productId",
element: <ProductDetails />,
loader: productLoader
}
]);
function App() {
return <RouterProvider router={router} />;
}
This is a simplified example. In production, validate the loader's external data and handle route errors appropriately.
What Are Actions?
Actions handle mutations associated with route navigation, such as:
- Creating a product.
- Updating a profile.
- Submitting a form.
- Deleting an item.
They can work with React Router's <Form> component and route data APIs.
A simplified action:
async function contactAction({ request }) {
const formData = await request.formData();
const email = formData.get("email");
const response = await fetch("/api/contact", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ email })
});
if (!response.ok) {
throw new Response("Unable to submit form", {
status: response.status
});
}
return { success: true };
}
Data routers are especially useful when your application has complex route-based data loading and mutations.
16. Handling Route Errors
Data routers support route-level error boundaries.
You can define an ErrorBoundary for a route:
import { isRouteErrorResponse, useRouteError } from "react-router";
function RouteErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status}</h1>
<p>{error.statusText}</p>
</div>
);
}
return (
<div>
<h1>Something went wrong</h1>
<p>Please try again later.</p>
</div>
);
}
Attach it to a route:
{
path: "/products/:productId",
loader: productLoader,
element: <ProductDetails />,
ErrorBoundary: RouteErrorBoundary
}
This lets you show useful error UI when a loader or route rendering fails.
For production applications, you should also log unexpected errors and provide a way for users to recover.
17. Common React Router Mistakes
Here are some mistakes worth avoiding.
Mistake 1: Using Anchor Tags for Every Internal Link
Avoid:
<a href="/about">About</a>
Prefer:
<Link to="/about">About</Link>
Use regular anchors for external URLs or situations where a full document navigation is intended.
Mistake 2: Forgetting Outlet in Nested Layouts
If a parent route uses child routes but doesn't render <Outlet />, the child content won't appear inside that layout.
Mistake 3: Treating URL Parameters as Numbers
Remember:
const { productId } = useParams();
Returns a string or undefined, not a number.
Validate and convert the value before using it as a numeric identifier.
Mistake 4: Using Navigation State as Permanent Data
Navigation state is temporary context.
Use route parameters, query parameters, or persistent storage when data needs to survive direct visits or page reloads.
Mistake 5: Treating Protected Routes as Backend Security
Client-side route protection is not authentication security.
Your backend must verify permissions independently.
Mistake 6: Forgetting Server Configuration
In production, a client-side application may need server configuration so that direct requests to routes such as /products/42 return the application's entry HTML.
Without a proper fallback, refreshing a nested route may produce a server-side 404 even though client-side navigation works.
18. Recommended Project Structure
As your application grows, organizing route definitions and page components becomes important.
A practical structure could look like this:
src/
├── components/
│ ├── Navbar.jsx
│ └── Footer.jsx
├── layouts/
│ ├── MainLayout.jsx
│ └── DashboardLayout.jsx
├── pages/
│ ├── HomePage.jsx
│ ├── AboutPage.jsx
│ ├── ProductsPage.jsx
│ ├── ProductDetails.jsx
│ ├── LoginPage.jsx
│ └── NotFoundPage.jsx
├── routes/
│ └── router.jsx
├── services/
│ └── productApi.js
├── App.jsx
└── main.jsx
For larger projects, keep route configuration separate from individual page components.
This makes the routing system easier to understand and maintain.
19. Best Practices
Here are some recommendations for building maintainable routing systems:
- Use
LinkandNavLinkfor internal navigation. - Keep route definitions organized.
- Use nested routes for shared layouts.
- Use dynamic parameters for resource-specific pages.
- Use query parameters for filters, sorting, and pagination.
- Validate URL parameters before using them.
- Use navigation state only for temporary context.
- Handle unknown routes with a 404 page.
- Use route-level error handling for data routers.
- Protect sensitive operations on the backend.
- Configure your production server for client-side route fallback.
- Choose declarative routing or data routers based on your application's needs.
20. Practice Project: Build a Small E-Commerce Router
Now it's your turn to put these concepts together.
Build a small e-commerce application with the following routes:
| Route | Page |
|---|---|
/ |
Home |
/products |
Product list |
/products/:productId |
Product details |
/cart |
Shopping cart |
/login |
Login |
/dashboard |
Protected dashboard |
* |
Not found |
Your application should include:
- A navigation bar.
- Dynamic product detail pages.
- Product category filters using query parameters.
- A shared dashboard layout.
- A protected dashboard route.
- A custom 404 page.
- Browser back and forward navigation.
Challenge: Add a product search field that updates the URL query parameters so users can share their search results.
Final Thoughts
React Router is an essential tool for building modern React applications.
It gives your application a clear navigation structure and makes it possible to build complex interfaces with multiple pages, shared layouts, and dynamic content.
Start with basic routes and Link.
Then learn dynamic parameters, nested layouts, and navigation state.
Finally, explore loaders, actions, and route-level error handling when your application needs more advanced data management.
Remember: good routing isn't just about changing URLs. It's about creating a predictable navigation experience for your users.
What's Next?
In Part 11: State Management in React — Context API, Redux Toolkit & Zustand, we'll explore how to manage shared application state as React projects become larger.
We'll cover:
- Local state vs global state
- React Context API
- Prop drilling and how to avoid it
- Redux Toolkit fundamentals
- Zustand for lightweight state management
- Managing asynchronous state
- Choosing the right state management solution
Keep learning, keep building, and see you in the next part! 🚀
Top comments (0)