Difficulty: Intermediate
How does React Router work? Explain nested routes, route guards, and common routing patterns.
React Router is the standard routing library for React single-page applications, and understanding it well is essential because almost every non-trivial React app needs routing. Let me walk you through how it works, from the basics to the patterns you will use in production.
At its core, React Router synchronizes the browser's URL with your component tree. When the URL changes, React Router figures out which components should render based on the route configuration. The key insight is that this all happens on the client side -- no server requests are made when you navigate between routes. The browser's History API (pushState, replaceState) updates the URL, and React Router intercepts these changes.
In React Router v6, the main building blocks are BrowserRouter (the provider that enables routing), Routes (the container for route definitions), Route (individual route definitions), and several hooks. The Routes component looks at the current URL and renders the first Route whose path matches. Each Route has a path and an element prop -- the path says 'when the URL looks like this' and the element says 'render this component.'
Nested routes are one of the most powerful features and the foundation of real application layouts. Think about any dashboard application. You have a sidebar and header that stay the same across all pages, and only the main content area changes. Nested routes let you express this naturally. You define a parent route with a layout component (sidebar + header + an Outlet), and child routes that render inside that Outlet. The Outlet component is like a placeholder that says 'render the matched child route here.' When you navigate between child routes, only the Outlet content changes -- the layout stays intact.
The index route (using the index prop instead of path) is the default child route that renders when the parent route matches exactly. So if your dashboard is at /dashboard, the index route shows the overview page, and /dashboard/analytics shows the analytics page -- both rendered inside the same DashboardLayout.
Route guards, also called protected routes, are how you restrict access to certain pages based on authentication or authorization. The pattern is elegant: you create a wrapper component (usually called ProtectedRoute or RequireAuth) that checks whether the user is authenticated. If they are, it renders an Outlet (allowing child routes to render). If not, it redirects to the login page using the Navigate component. You can even save the intended destination using the state prop on Navigate, so after login you can redirect the user back to where they were trying to go.
Role-based access control extends this pattern. Your ProtectedRoute component accepts an allowedRoles prop and checks not just authentication but also whether the user's role is permitted. This lets you have separate route groups for students, admins, and recruiters, each protected by different role requirements.
Dynamic routes use URL parameters (like /users/:userId) that you access with the useParams hook. This is how you build detail pages -- a product page, a user profile, or a blog post. The parameter value comes from the URL, and you typically use it to fetch data.
Search params (useSearchParams) are criminally underused in many React applications. They let you store state in the URL query string -- things like filters, sort order, pagination, and selected tabs. This is powerful because the URL becomes shareable and bookmarkable. A user can copy the URL with their exact filter configuration and send it to a colleague. It survives page refreshes. And it works with the browser's back and forward buttons. For any state that represents 'what the user is looking at,' the URL is often the right place to store it.
The useNavigate hook gives you programmatic navigation. Unlike the Link component (which renders an anchor tag for click-based navigation), useNavigate returns a function you call in event handlers, after form submissions, or in response to API results. The replace option replaces the current history entry instead of pushing a new one -- useful after login redirects so the user does not go back to the login page when pressing the back button.
React Router v6.4 introduced loaders and actions, inspired by Remix. Loaders let you fetch data before a route renders, eliminating the loading state waterfall where components mount, then start fetching, then render children, which start their own fetching. Actions handle form submissions and mutations. This moves data concerns from components into the router itself, which is a significant architectural shift.
A common pattern for handling 404 pages is a catch-all route with path='*' at the bottom of your Routes. This matches any URL that did not match an earlier route and renders your NotFound component.
import { BrowserRouter, Routes, Route, Outlet, NavLink } from 'react-router-dom';
// Layout component with persistent sidebar
function DashboardLayout() {
return (
<div className="flex">
<nav className="sidebar">
<NavLink to="/dashboard" end
className={({ isActive }) => isActive ? 'active' : ''}
>Overview</NavLink>
<NavLink to="/dashboard/analytics">Analytics</NavLink>
<NavLink to="/dashboard/settings">Settings</NavLink>
</nav>
<main className="content">
{/* Child routes render here */}
<Outlet />
</main>
</div>
);
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<Overview />} />
<Route path="analytics" element={<Analytics />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
Nested routes let you compose layouts. DashboardLayout provides the sidebar, and Outlet renders the matched child route. The 'index' route matches the parent path exactly.
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuthStore } from './stores/auth';
// Route guard component
function ProtectedRoute({ allowedRoles }) {
const { user, isAuthenticated } = useAuthStore();
const location = useLocation();
if (!isAuthenticated) {
// Redirect to login, saving intended destination
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (allowedRoles && !allowedRoles.includes(user.role)) {
return <Navigate to="/unauthorized" replace />;
}
return <Outlet />;
}
// Usage in route configuration
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
{/* Student routes */}
<Route element={<ProtectedRoute allowedRoles={['student']} />}>
<Route path="/student/dashboard" element={<StudentDashboard />} />
<Route path="/student/profile" element={<StudentProfile />} />
</Route>
{/* Admin routes */}
<Route element={<ProtectedRoute allowedRoles={['admin']} />}>
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/admin/users" element={<UserManagement />} />
</Route>
</Routes>
);
}
// After login, redirect back to intended page
function LoginPage() {
const location = useLocation();
const from = location.state?.from?.pathname || '/dashboard';
// After successful login: navigate(from, { replace: true });
}
ProtectedRoute checks auth and role before rendering child routes via Outlet. The state prop on Navigate saves the intended URL so users can be redirected back after login.
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
// Dynamic route: /users/:userId
function UserProfile() {
const { userId } = useParams();
const navigate = useNavigate();
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error('Not found');
return res.json();
})
.then(setUser)
.catch(() => navigate('/404', { replace: true }));
}, [userId, navigate]);
if (!user) return <Spinner />;
return <div><h1>{user.name}</h1></div>;
}
// Search params for filters: /products?category=shoes&sort=price
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') || 'all';
const sort = searchParams.get('sort') || 'name';
const page = parseInt(searchParams.get('page') || '1');
function updateFilters(updates) {
setSearchParams(prev => {
const next = new URLSearchParams(prev);
Object.entries(updates).forEach(([key, value]) => {
if (value) next.set(key, value);
else next.delete(key);
});
return next;
});
}
return (
<div>
<select value={category}
onChange={e => updateFilters({ category: e.target.value })}>
<option value="all">All</option>
<option value="shoes">Shoes</option>
</select>
<button onClick={() => updateFilters({ page: String(page + 1) })}>
Next Page
</button>
</div>
);
}
useParams extracts route parameters. useSearchParams manages URL query strings as state, enabling shareable and bookmarkable filter configurations.
React Router, Nested Routes, Route Guards, Navigation