React Router: Complete Navigation Guide
React Router is the standard routing library for React applications, enabling you to build single-page applications with dynamic, client-side navigation. Whether you're building a simple portfolio site or a complex enterprise dashboard, understanding React Router is essential for creating seamless user experiences without full page reloads.
In this comprehensive guide, we'll explore React Router v6 from the ground up, covering everything from basic setup to advanced patterns like nested routes, protected routes, lazy loading, and programmatic navigation. By the end, you'll have the skills to implement sophisticated navigation patterns in any React application.
- React Router v6 fundamentals and key differences from v5
- Setting up routes with BrowserRouter and createBrowserRouter
- Nested routes and the Outlet component
- Dynamic routes with URL parameters
- Programmatic navigation with useNavigate
- Protected routes and authentication patterns
- Code splitting with lazy loading
- Data loading with loaders and actions
- Navigation state and location handling
- Best practices and common patterns
Understanding Client-Side Routing
Before diving into React Router, let's understand what client-side routing is and why it matters. In traditional multi-page applications (MPAs), every navigation triggers a full page reload from the server. In contrast, single-page applications (SPAs) use client-side routing to update the URL and render different components without reloading the page.
Client-side routing eliminates full page reloads for faster, smoother navigation
Installation and Setup
Let's start by installing React Router and setting up a basic routing configuration. React Router v6 introduced significant changes from v5, so we'll focus on the modern approach.
# Install React Router
npm install react-router-dom
# Or with yarn
yarn add react-router-dom
# Or with pnpm
pnpm add react-router-dom
Basic Router Setup
There are two main ways to set up React Router v6: the traditional component-based approach with BrowserRouter, and the newer data API approach with createBrowserRouter. Let's explore both:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import About from './pages/About';
import Contact from './pages/Contact';
import NotFound from './pages/NotFound';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
export default App;
import {
createBrowserRouter,
RouterProvider
} from 'react-router-dom';
import Home from './pages/Home';
import About from './pages/About';
import Contact from './pages/Contact';
import NotFound from './pages/NotFound';
const router = createBrowserRouter([
{
path: "/",
element: <Home />,
},
{
path: "/about",
element: <About />,
},
{
path: "/contact",
element: <Contact />,
},
{
path: "*",
element: <NotFound />,
},
]);
function App() {
return <RouterProvider router={router} />;
}
export default App;
The Data API approach (createBrowserRouter) is recommended for new projects as it enables powerful features like data loaders, actions, and error boundaries at the route level.
Navigation Components
React Router provides several components for navigation. Understanding when to use each one is crucial for building intuitive UIs.
Link
Basic navigation component that renders an anchor tag. Use for standard navigation links.
NavLink
Like Link but adds active styling. Perfect for navigation menus where you need to highlight the current page.
Navigate
Declarative component for redirects. Use in render logic to redirect users based on conditions.
useNavigate
Hook for programmatic navigation. Use in event handlers or after async operations.
Link and NavLink Examples
import { Link, NavLink } from 'react-router-dom';
function Navigation() {
return (
<nav className="main-nav">
{/* Basic Link - no active styling */}
<Link to="/">Home</Link>
{/* NavLink with active class */}
<NavLink
to="/dashboard"
className={({ isActive }) =>
isActive ? 'nav-link active' : 'nav-link'
}
>
Dashboard
</NavLink>
{/* NavLink with inline active styles */}
<NavLink
to="/profile"
style={({ isActive }) => ({
fontWeight: isActive ? 'bold' : 'normal',
color: isActive ? '#61dafb' : 'inherit'
})}
>
Profile
</NavLink>
{/* Link with state */}
<Link
to="/products"
state={{ from: 'navigation' }}
>
Products
</Link>
{/* Link with replace (no history entry) */}
<Link to="/login" replace>
Login
</Link>
</nav>
);
}
Nested Routes and Layouts
One of React Router's most powerful features is nested routing, which allows you to create consistent layouts where only parts of the page change during navigation. This is perfect for dashboards, admin panels, and any app with a persistent sidebar or header.
The Outlet component renders child routes within parent layouts
import { Outlet, NavLink } from 'react-router-dom';
function DashboardLayout() {
return (
<div className="dashboard-layout">
<header className="dashboard-header">
<h1>Admin Dashboard</h1>
<UserMenu />
</header>
<div className="dashboard-body">
<aside className="sidebar">
<nav>
<NavLink to="/dashboard" end>
<i className="icon-home" /> Overview
</NavLink>
<NavLink to="/dashboard/users">
<i className="icon-users" /> Users
</NavLink>
<NavLink to="/dashboard/products">
<i className="icon-box" /> Products
</NavLink>
<NavLink to="/dashboard/settings">
<i className="icon-cog" /> Settings
</NavLink>
</nav>
</aside>
<main className="dashboard-content">
{/* Child routes render here */}
<Outlet />
</main>
</div>
</div>
);
}
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import DashboardLayout from './layouts/DashboardLayout';
import Overview from './pages/dashboard/Overview';
import Users from './pages/dashboard/Users';
import Products from './pages/dashboard/Products';
import Settings from './pages/dashboard/Settings';
const router = createBrowserRouter([
{
path: "/dashboard",
element: <DashboardLayout />,
children: [
{
index: true, // Matches /dashboard exactly
element: <Overview />,
},
{
path: "users", // Matches /dashboard/users
element: <Users />,
},
{
path: "products", // Matches /dashboard/products
element: <Products />,
},
{
path: "settings", // Matches /dashboard/settings
element: <Settings />,
},
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
The index: true property creates an "index route" that renders when the parent path is matched exactly. It's equivalent to using path="" but more explicit. In the example above, the Overview component renders at /dashboard while other child routes render at their respective paths.
Dynamic Routes and URL Parameters
Dynamic routes allow you to capture variable segments from the URL. This is essential for user profiles, product pages, blog posts, and any content that needs unique URLs.
const router = createBrowserRouter([
{
path: "/users",
element: <UsersLayout />,
children: [
{
index: true,
element: <UsersList />,
},
{
path: ":userId", // Dynamic segment
element: <UserProfile />,
},
{
path: ":userId/edit",
element: <EditUser />,
},
],
},
{
// Multiple dynamic segments
path: "/products/:category/:productId",
element: <ProductDetail />,
},
{
// Optional segments with ?
path: "/search/:query?",
element: <SearchResults />,
},
{
// Catch-all with *
path: "/docs/*",
element: <Documentation />,
},
]);
Accessing URL Parameters with useParams
import { useParams, Link } from 'react-router-dom';
import { useState, useEffect } from 'react';
function UserProfile() {
const { userId } = useParams();
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchUser() {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (error) {
console.error('Failed to fetch user:', error);
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]); // Re-fetch when userId changes
if (loading) return <LoadingSpinner />;
if (!user) return <NotFound message="User not found" />;
return (
<div className="user-profile">
<img src={user.avatar} alt={user.name} />
<h1>{user.name}</h1>
<p>{user.email}</p>
<Link to={`/users/${userId}/edit`}>Edit Profile</Link>
</div>
);
}
Essential Router Hooks
React Router v6 provides several hooks for accessing routing information and performing navigation. Here are the most important ones:
| Hook | Purpose | Returns |
|---|---|---|
useNavigate |
Programmatic navigation | Navigate function |
useParams |
Access URL parameters | Object with params |
useSearchParams |
Read/write query strings | [searchParams, setSearchParams] |
useLocation |
Current location object | { pathname, search, hash, state } |
useMatch |
Check if route matches | Match object or null |
useOutletContext |
Access parent context | Context value from Outlet |
useNavigate - Programmatic Navigation
import { useNavigate } from 'react-router-dom';
function LoginForm() {
const navigate = useNavigate();
async function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.target);
try {
await loginUser(formData);
// Navigate to dashboard after successful login
navigate('/dashboard');
// Or with options:
navigate('/dashboard', {
replace: true, // Replace current history entry
state: { from: 'login' } // Pass state to destination
});
} catch (error) {
setError(error.message);
}
}
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button type="submit">Login</button>
{/* Go back */}
<button type="button" onClick={() => navigate(-1)}>
Go Back
</button>
{/* Go forward */}
<button type="button" onClick={() => navigate(1)}>
Go Forward
</button>
</form>
);
}
useSearchParams - Query String Management
import { useSearchParams } from 'react-router-dom';
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
// Read query parameters
const category = searchParams.get('category') || 'all';
const sort = searchParams.get('sort') || 'newest';
const page = parseInt(searchParams.get('page') || '1');
// Update query parameters
function handleCategoryChange(newCategory) {
setSearchParams(prev => {
prev.set('category', newCategory);
prev.set('page', '1'); // Reset to page 1
return prev;
});
}
function handleSortChange(newSort) {
setSearchParams(prev => {
prev.set('sort', newSort);
return prev;
});
}
function handlePageChange(newPage) {
setSearchParams(prev => {
prev.set('page', newPage.toString());
return prev;
});
}
// URL becomes: /products?category=electronics&sort=price&page=2
return (
<div>
<Filters
category={category}
sort={sort}
onCategoryChange={handleCategoryChange}
onSortChange={handleSortChange}
/>
<ProductGrid category={category} sort={sort} page={page} />
<Pagination page={page} onPageChange={handlePageChange} />
</div>
);
}
Protected Routes and Authentication
Most applications need to protect certain routes from unauthorized access. Here's how to implement protected routes with React Router:
Protected routes check authentication before rendering or redirecting
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
function ProtectedRoute({ children, requiredRole }) {
const { user, isLoading } = useAuth();
const location = useLocation();
// Show loading while checking auth
if (isLoading) {
return <LoadingSpinner />;
}
// Not logged in - redirect to login
if (!user) {
return (
<Navigate
to="/login"
state={{ from: location.pathname }}
replace
/>
);
}
// Check role if required
if (requiredRole && user.role !== requiredRole) {
return <Navigate to="/unauthorized" replace />;
}
// Authorized - render children
return children;
}
// Usage in routes:
const router = createBrowserRouter([
{
path: "/dashboard",
element: (
<ProtectedRoute>
<DashboardLayout />
</ProtectedRoute>
),
children: [/* ... */]
},
{
path: "/admin",
element: (
<ProtectedRoute requiredRole="admin">
<AdminPanel />
</ProtectedRoute>
),
},
]);
Redirect After Login
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
function Login() {
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
// Get the page they were trying to visit
const from = location.state?.from || '/dashboard';
async function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.target);
try {
await login(formData.get('email'), formData.get('password'));
// Redirect to the page they originally wanted
navigate(from, { replace: true });
} catch (error) {
setError('Invalid credentials');
}
}
return (
<form onSubmit={handleSubmit}>
<h1>Login</h1>
{from !== '/dashboard' && (
<p className="info">
Please log in to access {from}
</p>
)}
{/* form fields */}
</form>
);
}
Code Splitting with Lazy Loading
Large applications benefit from code splitting, where route components are loaded only when needed. This significantly improves initial load time.
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
// Lazy load route components
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
// Loading component
function PageLoader() {
return (
<div className="page-loader">
<div className="spinner" />
<p>Loading...</p>
</div>
);
}
const router = createBrowserRouter([
{
path: "/",
element: (
<Suspense fallback={<PageLoader />}>
<Home />
</Suspense>
),
},
{
path: "/dashboard",
element: (
<Suspense fallback={<PageLoader />}>
<Dashboard />
</Suspense>
),
},
// Or wrap multiple routes with a single Suspense
]);
// Better approach: Create a lazy wrapper
function LazyRoute({ component: Component }) {
return (
<Suspense fallback={<PageLoader />}>
<Component />
</Suspense>
);
}
If your component uses named exports, you can use: const Page = lazy(() => import('./Page').then(module => ({ default: module.Page })))
Data Loading with Loaders
React Router v6.4+ introduced data APIs that allow you to load data before rendering routes. This eliminates loading states in components and provides a better user experience.
import {
createBrowserRouter,
useLoaderData,
defer,
Await
} from 'react-router-dom';
import { Suspense } from 'react';
// Loader function runs before component renders
async function userLoader({ params }) {
const response = await fetch(`/api/users/${params.userId}`);
if (!response.ok) {
throw new Response('User not found', { status: 404 });
}
return response.json();
}
// Deferred loader for non-critical data
function dashboardLoader() {
return defer({
user: fetchUser(), // Critical - wait for this
stats: fetchStats(), // Non-critical - stream later
notifications: fetchNotifications(), // Non-critical
});
}
const router = createBrowserRouter([
{
path: "/users/:userId",
element: <UserProfile />,
loader: userLoader,
errorElement: <ErrorPage />,
},
{
path: "/dashboard",
element: <Dashboard />,
loader: dashboardLoader,
},
]);
// Component using loader data
function UserProfile() {
const user = useLoaderData();
// No loading state needed - data is already available!
return (
<div className="profile">
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// Component with deferred data
function Dashboard() {
const { user, stats, notifications } = useLoaderData();
return (
<div>
{/* User loads immediately */}
<WelcomeMessage user={user} />
{/* Stats stream in when ready */}
<Suspense fallback={<StatsSkeletons />}>
<Await resolve={stats}>
{(stats) => <StatsCards stats={stats} />}
</Await>
</Suspense>
{/* Notifications stream in when ready */}
<Suspense fallback={<NotificationsSkeleton />}>
<Await resolve={notifications}>
{(notifications) => <NotificationList data={notifications} />}
</Await>
</Suspense>
</div>
);
}
Form Handling with Actions
Actions handle form submissions and mutations. Combined with loaders, they provide a complete data flow solution.
import {
Form,
useActionData,
useNavigation,
redirect
} from 'react-router-dom';
// Action handles form submission
async function createUserAction({ request }) {
const formData = await request.formData();
const errors = validateUser(formData);
if (Object.keys(errors).length) {
return { errors };
}
try {
const user = await createUser({
name: formData.get('name'),
email: formData.get('email'),
role: formData.get('role'),
});
// Redirect after successful creation
return redirect(`/users/${user.id}`);
} catch (error) {
return { errors: { form: error.message } };
}
}
const router = createBrowserRouter([
{
path: "/users/new",
element: <CreateUserForm />,
action: createUserAction,
},
]);
function CreateUserForm() {
const actionData = useActionData();
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<Form method="post" className="user-form">
<h1>Create New User</h1>
{actionData?.errors?.form && (
<div className="error">{actionData.errors.form}</div>
)}
<div className="field">
<label htmlFor="name">Name</label>
<input
type="text"
name="name"
id="name"
required
/>
{actionData?.errors?.name && (
<span className="field-error">
{actionData.errors.name}
</span>
)}
</div>
<div className="field">
<label htmlFor="email">Email</label>
<input
type="email"
name="email"
id="email"
required
/>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create User'}
</button>
</Form>
);
}
Error Handling
React Router provides built-in error handling with error boundaries at the route level.
import {
useRouteError,
isRouteErrorResponse,
Link
} from 'react-router-dom';
function ErrorPage() {
const error = useRouteError();
// Handle different error types
if (isRouteErrorResponse(error)) {
if (error.status === 404) {
return (
<div className="error-page">
<h1>404 - Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<Link to="/">Go Home</Link>
</div>
);
}
if (error.status === 401) {
return (
<div className="error-page">
<h1>Unauthorized</h1>
<p>You need to log in to access this page.</p>
<Link to="/login">Log In</Link>
</div>
);
}
return (
<div className="error-page">
<h1>{error.status} Error</h1>
<p>{error.statusText}</p>
</div>
);
}
// Handle unexpected errors
return (
<div className="error-page">
<h1>Oops! Something went wrong</h1>
<p>{error.message || 'An unexpected error occurred'}</p>
<button onClick={() => window.location.reload()}>
Try Again
</button>
</div>
);
}
// Route configuration with error boundary
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
errorElement: <ErrorPage />, // Catches errors in this route and children
children: [
{
path: "users/:userId",
element: <UserProfile />,
loader: userLoader,
// Can have its own error element
errorElement: <UserNotFound />,
},
],
},
]);
Best Practices
Organize Routes by Feature
Group routes by feature or domain rather than by route type:
src/
├── features/
│ ├── auth/
│ │ ├── routes.jsx # Auth routes config
│ │ ├── Login.jsx
│ │ └── Register.jsx
│ ├── dashboard/
│ │ ├── routes.jsx # Dashboard routes
│ │ ├── Overview.jsx
│ │ └── Settings.jsx
│ └── users/
│ ├── routes.jsx # User routes
│ ├── UserList.jsx
│ └── UserProfile.jsx
└── App.jsx # Combines all routes
Use Relative Links in Nested Routes
When inside nested routes, use relative paths for better maintainability:
// Inside /dashboard route
<Link to="settings">Settings</Link> // Goes to /dashboard/settings
<Link to="../profile">Profile</Link> // Goes to /profile
<Link to=".">Current</Link> // Stays on current route
Quick Reference Checklist
- ✅ Use
createBrowserRouterfor new projects (enables data APIs) - ✅ Use
NavLinkfor navigation menus with active states - ✅ Implement
errorElementfor each route level - ✅ Use
loaderfor data fetching,actionfor mutations - ✅ Lazy load routes with
React.lazyfor code splitting - ✅ Use
useSearchParamsfor filters and pagination - ✅ Wrap protected routes in an authentication component
- ✅ Use the
endprop on NavLink for exact matching - ✅ Pass state via
Linkfor context between pages - ✅ Use
indexroutes for default child content
Conclusion
React Router is a powerful and flexible library that has evolved significantly with version 6. The new data APIs (loaders, actions, defer) bring React Router closer to full-stack frameworks like Remix, enabling more sophisticated data patterns without additional libraries.
Key takeaways from this guide:
- Use createBrowserRouter for new projects to access modern data APIs
- Nested routes with Outlet create consistent layouts effortlessly
- Loaders and actions handle data fetching and mutations at the route level
- Protected routes should check auth and redirect appropriately
- Lazy loading with Suspense improves initial load performance
- Error boundaries at route level provide graceful error handling
Master these concepts, and you'll be able to build complex, performant React applications with excellent navigation experiences. The patterns shown here scale from simple portfolio sites to enterprise-level dashboards.
