Routing
Routing maps URLs to components, enabling navigation between different views in a single-page application. Unlike traditional multi-page apps, React SPAs don't reload the page when navigating — the router intercepts URL changes and renders the appropriate component.
There are two main approaches: client-side routing with React Router (for SPAs), and file-based routing with Next.js/Remix (for server-rendered apps). File-based routing is now the recommended approach for new projects.
React Router is the standard routing library for React. Version 7 (formerly Remix) adds server-side rendering, data loading, and form handling. Here's the core API:
| 1 | import { BrowserRouter, Routes, Route, Link, NavLink, Navigate } from "react-router-dom"; |
| 2 | |
| 3 | function App() { |
| 4 | return ( |
| 5 | <BrowserRouter> |
| 6 | <Navigation /> |
| 7 | <Routes> |
| 8 | <Route path="/" element={<HomePage />} /> |
| 9 | <Route path="/about" element={<AboutPage />} /> |
| 10 | <Route path="/users" element={<UsersLayout />}> |
| 11 | <Route index element={<UserList />} /> |
| 12 | <Route path=":userId" element={<UserProfile />} /> |
| 13 | <Route path=":userId/posts" element={<UserPosts />} /> |
| 14 | </Route> |
| 15 | <Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} /> |
| 16 | <Route path="*" element={<NotFoundPage />} /> |
| 17 | </Routes> |
| 18 | </BrowserRouter> |
| 19 | ); |
| 20 | } |
| 21 | |
| 22 | function Navigation() { |
| 23 | return ( |
| 24 | <nav> |
| 25 | {/* NavLink adds "active" class automatically */} |
| 26 | <NavLink to="/" className={({ isActive }) => isActive ? "nav-link active" : "nav-link"}> |
| 27 | Home |
| 28 | </NavLink> |
| 29 | <NavLink to="/about" end>About</NavLink> |
| 30 | <NavLink to="/users">Users</NavLink> |
| 31 | <NavLink to="/dashboard">Dashboard</NavLink> |
| 32 | </nav> |
| 33 | ); |
| 34 | } |
| 35 | |
| 36 | // Redirect example |
| 37 | function OldRoutes() { |
| 38 | return ( |
| 39 | <Routes> |
| 40 | <Route path="/old-about" element={<Navigate to="/about" replace />} /> |
| 41 | <Route path="/old-blog/:slug" element={<Navigate to="/blog/:slug" replace />} /> |
| 42 | </Routes> |
| 43 | ); |
| 44 | } |
Dynamic route segments (prefixed with :) capture variable parts of the URL. Access them via the useParams hook. Query strings are accessed via useSearchParams.
| 1 | import { useParams, useSearchParams, useNavigate } from "react-router-dom"; |
| 2 | |
| 3 | // Dynamic route parameter |
| 4 | function UserProfile() { |
| 5 | const { userId } = useParams(); |
| 6 | const { data: user, isLoading } = useQuery({ |
| 7 | queryKey: ["users", userId], |
| 8 | queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()), |
| 9 | }); |
| 10 | |
| 11 | if (isLoading) return <Spinner />; |
| 12 | return <UserCard user={user} />; |
| 13 | } |
| 14 | |
| 15 | // Search params (query strings) |
| 16 | function ProductList() { |
| 17 | const [searchParams, setSearchParams] = useSearchParams(); |
| 18 | const category = searchParams.get("category") || "all"; |
| 19 | const sort = searchParams.get("sort") || "name"; |
| 20 | const page = parseInt(searchParams.get("page") || "1"); |
| 21 | |
| 22 | const updateFilter = (key, value) => { |
| 23 | setSearchParams(prev => { |
| 24 | prev.set(key, value); |
| 25 | if (key === "category") prev.set("page", "1"); // reset page on filter change |
| 26 | return prev; |
| 27 | }); |
| 28 | }; |
| 29 | |
| 30 | return ( |
| 31 | <div> |
| 32 | <select value={category} onChange={e => updateFilter("category", e.target.value)}> |
| 33 | <option value="all">All Categories</option> |
| 34 | <option value="electronics">Electronics</option> |
| 35 | <option value="clothing">Clothing</option> |
| 36 | </select> |
| 37 | <select value={sort} onChange={e => updateFilter("sort", e.target.value)}> |
| 38 | <option value="name">Name</option> |
| 39 | <option value="price">Price</option> |
| 40 | <option value="newest">Newest</option> |
| 41 | </select> |
| 42 | {/* Product list renders here */} |
| 43 | <Pagination current={page} onChange={p => updateFilter("page", String(p))} /> |
| 44 | </div> |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | // Programmatic navigation |
| 49 | function SearchForm() { |
| 50 | const navigate = useNavigate(); |
| 51 | const [query, setQuery] = useState(""); |
| 52 | |
| 53 | const handleSearch = (e) => { |
| 54 | e.preventDefault(); |
| 55 | navigate(`/search?q=${encodeURIComponent(query)}`); |
| 56 | }; |
| 57 | |
| 58 | return ( |
| 59 | <form onSubmit={handleSearch}> |
| 60 | <input value={query} onChange={e => setQuery(e.target.value)} /> |
| 61 | <button type="submit">Search</button> |
| 62 | </form> |
| 63 | ); |
| 64 | } |
info
Nested routes let you share layouts across multiple pages. Parent routes render an Outlet where child routes are displayed. This avoids duplicating headers, sidebars, and navigation in every page.
| 1 | import { Outlet } from "react-router-dom"; |
| 2 | |
| 3 | // Layout with shared sidebar |
| 4 | function DashboardLayout() { |
| 5 | return ( |
| 6 | <div className="flex"> |
| 7 | <aside className="w-64 border-r p-4"> |
| 8 | <nav> |
| 9 | <NavLink to="/dashboard" end>Overview</NavLink> |
| 10 | <NavLink to="/dashboard/analytics">Analytics</NavLink> |
| 11 | <NavLink to="/dashboard/users">Users</NavLink> |
| 12 | <NavLink to="/dashboard/settings">Settings</NavLink> |
| 13 | </nav> |
| 14 | </aside> |
| 15 | <main className="flex-1 p-8"> |
| 16 | {/* Child routes render here */} |
| 17 | <Outlet /> |
| 18 | </main> |
| 19 | </div> |
| 20 | ); |
| 21 | } |
| 22 | |
| 23 | // Nested route definitions |
| 24 | function App() { |
| 25 | return ( |
| 26 | <Routes> |
| 27 | <Route element={<AuthLayout />}> |
| 28 | <Route path="/login" element={<LoginPage />} /> |
| 29 | <Route path="/register" element={<RegisterPage />} /> |
| 30 | </Route> |
| 31 | |
| 32 | <Route element={<DashboardLayout />}> |
| 33 | <Route path="/dashboard" element={<OverviewPage />} /> |
| 34 | <Route path="/dashboard/analytics" element={<AnalyticsPage />} /> |
| 35 | <Route path="/dashboard/users" element={<UsersPage />}> |
| 36 | <Route path=":userId" element={<UserModal />} /> |
| 37 | </Route> |
| 38 | <Route path="/dashboard/settings" element={<SettingsPage />} /> |
| 39 | </Route> |
| 40 | |
| 41 | <Route element={<DocsLayout />}> |
| 42 | <Route path="/docs" element={<DocsIndex />} /> |
| 43 | <Route path="/docs/:category" element={<CategoryPage />} /> |
| 44 | <Route path="/docs/:category/:slug" element={<DocPage />} /> |
| 45 | </Route> |
| 46 | </Routes> |
| 47 | ); |
| 48 | } |
| 49 | |
| 50 | // Auth layout — wraps login/register with centered card |
| 51 | function AuthLayout() { |
| 52 | return ( |
| 53 | <div className="min-h-screen flex items-center justify-center bg-gray-50"> |
| 54 | <div className="w-full max-w-md p-8"> |
| 55 | <Outlet /> |
| 56 | </div> |
| 57 | </div> |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | // Layout component with shared data |
| 62 | function DocsLayout() { |
| 63 | const { data: categories } = useQuery({ |
| 64 | queryKey: ["doc-categories"], |
| 65 | queryFn: fetchDocCategories, |
| 66 | }); |
| 67 | |
| 68 | return ( |
| 69 | <div className="flex"> |
| 70 | <Sidebar categories={categories} /> |
| 71 | <main className="flex-1"> |
| 72 | <Outlet context={{ categories }} /> |
| 73 | </main> |
| 74 | </div> |
| 75 | ); |
| 76 | } |
Protected routes restrict access based on authentication and authorization. Wrap routes in a guard component that checks conditions before rendering the protected content.
| 1 | import { Navigate, useLocation } from "react-router-dom"; |
| 2 | |
| 3 | // Basic auth guard |
| 4 | function ProtectedRoute({ children, requiredRole }) { |
| 5 | const { user, isAuthenticated, isLoading } = useAuth(); |
| 6 | const location = useLocation(); |
| 7 | |
| 8 | if (isLoading) return <LoadingSpinner />; |
| 9 | |
| 10 | if (!isAuthenticated) { |
| 11 | // Save intended destination for redirect after login |
| 12 | return <Navigate to="/login" state={{ from: location }} replace />; |
| 13 | } |
| 14 | |
| 15 | if (requiredRole && user.role !== requiredRole) { |
| 16 | return <Navigate to="/unauthorized" replace />; |
| 17 | } |
| 18 | |
| 19 | return children; |
| 20 | } |
| 21 | |
| 22 | // Login page — redirects to intended destination after login |
| 23 | function LoginPage() { |
| 24 | const { login } = useAuth(); |
| 25 | const navigate = useNavigate(); |
| 26 | const location = useLocation(); |
| 27 | const from = location.state?.from?.pathname || "/dashboard"; |
| 28 | |
| 29 | const handleSubmit = async (e) => { |
| 30 | e.preventDefault(); |
| 31 | try { |
| 32 | await login(email, password); |
| 33 | navigate(from, { replace: true }); |
| 34 | } catch (err) { |
| 35 | setError(err.message); |
| 36 | } |
| 37 | }; |
| 38 | |
| 39 | return ( |
| 40 | <form onSubmit={handleSubmit}> |
| 41 | {/* login form */} |
| 42 | </form> |
| 43 | ); |
| 44 | } |
| 45 | |
| 46 | // Route configuration with guards |
| 47 | function AppRoutes() { |
| 48 | return ( |
| 49 | <Routes> |
| 50 | {/* Public routes */} |
| 51 | <Route path="/" element={<HomePage />} /> |
| 52 | <Route path="/login" element={<LoginPage />} /> |
| 53 | <Route path="/register" element={<RegisterPage />} /> |
| 54 | |
| 55 | {/* Protected routes — require authentication */} |
| 56 | <Route element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}> |
| 57 | <Route path="/dashboard" element={<OverviewPage />} /> |
| 58 | <Route path="/dashboard/analytics" element={<AnalyticsPage />} /> |
| 59 | </Route> |
| 60 | |
| 61 | {/* Admin-only routes */} |
| 62 | <Route element={<ProtectedRoute requiredRole="admin"><AdminLayout /></ProtectedRoute>}> |
| 63 | <Route path="/admin" element={<AdminDashboard />} /> |
| 64 | <Route path="/admin/users" element={<AdminUsers />} /> |
| 65 | </Route> |
| 66 | |
| 67 | {/* Fallback */} |
| 68 | <Route path="*" element={<NotFoundPage />} /> |
| 69 | </Routes> |
| 70 | ); |
| 71 | } |
warning
File-based routing (used by Next.js, Remix, and Nuxt) generates routes automatically from your file system structure. Each file in the app/ directory becomes a route. This eliminates manual route configuration.
| 1 | app/ |
| 2 | layout.tsx → Root layout (wraps all pages) |
| 3 | page.tsx → Route: / |
| 4 | loading.tsx → Loading UI for all routes |
| 5 | error.tsx → Error boundary for all routes |
| 6 | |
| 7 | about/ |
| 8 | page.tsx → Route: /about |
| 9 | |
| 10 | blog/ |
| 11 | page.tsx → Route: /blog |
| 12 | [slug]/ |
| 13 | page.tsx → Route: /blog/:slug |
| 14 | loading.tsx → Loading state for blog posts |
| 15 | |
| 16 | dashboard/ |
| 17 | layout.tsx → Dashboard layout (sidebar + outlet) |
| 18 | page.tsx → Route: /dashboard |
| 19 | analytics/ |
| 20 | page.tsx → Route: /dashboard/analytics |
| 21 | users/ |
| 22 | page.tsx → Route: /dashboard/users |
| 23 | [userId]/ |
| 24 | page.tsx → Route: /dashboard/users/:userId |
| 25 | |
| 26 | api/ |
| 27 | users/ |
| 28 | route.ts → API route: /api/users |
| 1 | // app/layout.tsx — wraps every page |
| 2 | export default function RootLayout({ children }) { |
| 3 | return ( |
| 4 | <html lang="en"> |
| 5 | <body> |
| 6 | <Header /> |
| 7 | {children} |
| 8 | <Footer /> |
| 9 | </body> |
| 10 | </html> |
| 11 | ); |
| 12 | } |
| 13 | |
| 14 | // app/dashboard/layout.tsx — wraps dashboard pages |
| 15 | export default function DashboardLayout({ children }) { |
| 16 | return ( |
| 17 | <div className="flex"> |
| 18 | <Sidebar /> |
| 19 | <main className="flex-1">{children}</main> |
| 20 | </div> |
| 21 | ); |
| 22 | } |
| 23 | |
| 24 | // app/dashboard/users/[userId]/page.tsx — dynamic route |
| 25 | export default async function UserPage({ params }) { |
| 26 | const { userId } = await params; |
| 27 | const user = await fetchUser(userId); |
| 28 | |
| 29 | return ( |
| 30 | <div> |
| 31 | <h1>{user.name}</h1> |
| 32 | <p>{user.email}</p> |
| 33 | </div> |
| 34 | ); |
| 35 | } |
| 36 | |
| 37 | // generateStaticParams for static generation |
| 38 | export async function generateStaticParams() { |
| 39 | const users = await fetchAllUsers(); |
| 40 | return users.map(user => ({ userId: user.id.toString() })); |
| 41 | } |
best practice