React Router 7 Overview: Complete Routing Guide for SPAs
Building a modern single-page application (SPA) without a router is like driving a car without steering—you might move forward, but you'll eventually crash. React Router has been the go-to solution for years, and for good reason. It handles URL synchronization, browser navigation, data fetching, and code splitting in ways that hand-rolled solutions simply can't match.
But here's what surprises many developers: React Router is more than just URL matching. It's a complete framework for managing application state based on the URL, orchestrating data fetching, and coordinating component lifecycles across your entire app. Mastering it unlocks possibilities you didn't know were possible.
Let's explore how to build robust, maintainable routing architectures with React Router 7.
Table of Contents
- What Is React Router and Why You Need It
- Installation and Core Concepts
- Setting Up Your Router
- Declaring Routes
- Navigation Components: Link and NavLink
- Nested Routes and Route Composition
- Route Parameters and Dynamic Segments
- Programmatic Navigation with useNavigate
- Search Parameters and Query Strings
- Error Boundaries and Error Pages
- Code Splitting and Lazy Loading
- Data Loading with Data Loaders
- FAQ
What Is React Router and Why You Need It {#what-is-router}
React Router solves a fundamental problem: how do you synchronize your application's UI with the browser's URL? Without a router, clicking a link does nothing—it just reloads the page and loses all application state.
A router like React Router does four critical things:
Synchronizes URL with component rendering - When the user navigates to
/products/5, React Router renders the appropriate component and passes the product ID as a parameter.Manages browser history - Back/forward buttons work without you building custom history management.
Enables bookmarking and sharing - Users can share URLs that load the exact state of your application.
Supports code splitting - Routes can be lazy-loaded, keeping your initial bundle small.
Without these capabilities, you're forced to either build them yourself (which is painful) or accept a degraded user experience.
Installation and Core Concepts {#installation}
Install React Router
npm install react-router-dom
TypeScript types are included, so no separate @types package is needed.
Core Concepts
Route - A mapping between a URL path and a component to render. When the user navigates to that path, the component appears.
Router - The component that manages routes and navigation. React Router provides several router types for different environments (browsers, servers, etc.).
Navigation - Moving between routes. Can be done via <Link>, <NavLink>, <Form>, or programmatically with useNavigate().
Outlet - A placeholder for nested route components. When a nested route matches, its component renders inside the <Outlet> of the parent route.
Loader - A function that fetches data before a route renders. This ensures data is available before the component appears.
Action - A function that handles form submissions. Similar to loaders but for mutations.
Setting Up Your Router {#router-setup}
React Router uses createBrowserRouter() to configure routes and RouterProvider to inject the router into your app.
TypeScript Version
import {
createBrowserRouter,
RouterProvider,
Navigate,
} from 'react-router-dom';
import { ReactNode } from 'react';
// Page components
import HomePage from './pages/HomePage';
import ProductsPage from './pages/ProductsPage';
import ProductDetailPage from './pages/ProductDetailPage';
import NotFoundPage from './pages/NotFoundPage';
// Layout components
interface LayoutProps {
children?: ReactNode;
}
function RootLayout({ children }: LayoutProps) {
return (
<div className="app">
<header>
<nav>
{/* Navigation here */}
</nav>
</header>
<main>{children}</main>
<footer>© 2025 My App</footer>
</div>
);
}
// Define routes
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout><HomePage /></RootLayout>,
errorElement: <NotFoundPage />,
},
{
path: '/products',
element: <ProductsPage />,
},
{
path: '/products/:id',
element: <ProductDetailPage />,
},
{
path: '*',
element: <Navigate to="/" />,
},
]);
// Provide router to app
export default function App() {
return <RouterProvider router={router} />;
}
JavaScript Version
import {
createBrowserRouter,
RouterProvider,
Navigate,
} from 'react-router-dom';
// Page components
import HomePage from './pages/HomePage';
import ProductsPage from './pages/ProductsPage';
import ProductDetailPage from './pages/ProductDetailPage';
import NotFoundPage from './pages/NotFoundPage';
function RootLayout({ children }) {
return (
<div className="app">
<header>
<nav>{/* Navigation */}</nav>
</header>
<main>{children}</main>
<footer>© 2025 My App</footer>
</div>
);
}
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout><HomePage /></RootLayout>,
errorElement: <NotFoundPage />,
},
{
path: '/products',
element: <ProductsPage />,
},
{
path: '/products/:id',
element: <ProductDetailPage />,
},
{
path: '*',
element: <Navigate to="/" />,
},
]);
export default function App() {
return <RouterProvider router={router} />;
}
Declaring Routes {#declaring-routes}
Routes are defined as plain JavaScript objects. Each route object has these key properties:
| Property | Purpose | Required |
|---|---|---|
path |
URL path to match (can include parameters like :id) |
Yes |
element |
Component to render when path matches | Yes |
errorElement |
Component to render if an error occurs | No |
children |
Nested routes | No |
loader |
Function to fetch data before rendering | No |
action |
Function to handle form submissions | No |
TypeScript Version: Advanced Route Configuration
import {
LoaderFunctionArgs,
ActionFunctionArgs,
redirect,
} from 'react-router-dom';
interface Product {
id: string;
name: string;
price: number;
}
// Loader functions fetch data before the route renders
const productLoader = async ({ params }: LoaderFunctionArgs) => {
const { id } = params;
const response = await fetch(`/api/products/${id}`);
if (!response.ok) {
throw new Response('Product not found', { status: 404 });
}
return response.json() as Promise<Product>;
};
// Action functions handle form submissions
const createProductAction = async ({ request }: ActionFunctionArgs) => {
if (request.method === 'POST') {
const formData = await request.formData();
const response = await fetch('/api/products', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Response('Failed to create product', { status: 400 });
}
const product = await response.json();
return redirect(`/products/${product.id}`);
}
return null;
};
interface RouteConfig {
path: string;
element: JSX.Element;
loader?: (args: LoaderFunctionArgs) => Promise<Product | null>;
action?: (args: ActionFunctionArgs) => Promise<any>;
errorElement?: JSX.Element;
children?: RouteConfig[];
}
const routes: RouteConfig[] = [
{
path: '/products/:id',
element: <ProductDetailPage />,
loader: productLoader,
errorElement: <ErrorPage />,
},
{
path: '/products/new',
element: <CreateProductPage />,
action: createProductAction,
},
];
JavaScript Version
// Loader function
const productLoader = async ({ params }) => {
const { id } = params;
const response = await fetch(`/api/products/${id}`);
if (!response.ok) {
throw new Response('Product not found', { status: 404 });
}
return response.json();
};
// Action function
const createProductAction = async ({ request }) => {
if (request.method === 'POST') {
const formData = await request.formData();
const response = await fetch('/api/products', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Response('Failed to create product', { status: 400 });
}
const product = await response.json();
return redirect(`/products/${product.id}`);
}
return null;
};
const routes = [
{
path: '/products/:id',
element: <ProductDetailPage />,
loader: productLoader,
errorElement: <ErrorPage />,
},
{
path: '/products/new',
element: <CreateProductPage />,
action: createProductAction,
},
];
Navigation Components: Link and NavLink {#navigation}
React Router provides two components for navigation: <Link> and <NavLink>.
<Link> - Renders an <a> tag that navigates without a full page reload. Use this for most links in your app.
<NavLink> - A special <Link> that's aware of whether its route is active. Perfect for navigation menus and breadcrumbs.
TypeScript Version
import { Link, NavLink, useLocation } from 'react-router-dom';
interface NavigationProps {
isOpen?: boolean;
}
export function Navigation({ isOpen }: NavigationProps) {
const location = useLocation();
// Determine if a link is active
const isActive = (path: string) => location.pathname === path;
return (
<nav className="navbar">
{/* Basic Link - no styling awareness */}
<Link to="/" className="logo">
My App
</Link>
{/* NavLink with className function - gets isActive prop */}
<NavLink
to="/products"
className={({ isActive }) =>
isActive ? 'nav-link active' : 'nav-link'
}
>
Products
</NavLink>
{/* NavLink with style function */}
<NavLink
to="/about"
style={({ isActive }) => ({
color: isActive ? 'blue' : 'gray',
textDecoration: isActive ? 'underline' : 'none',
})}
>
About
</NavLink>
{/* Link to dynamic routes with state */}
<Link
to="/products/42"
state={{ referrer: 'search' }}
className="link"
>
Product 42
</Link>
</nav>
);
}
JavaScript Version
import { Link, NavLink, useLocation } from 'react-router-dom';
export function Navigation({ isOpen }) {
const location = useLocation();
const isActive = (path) => location.pathname === path;
return (
<nav className="navbar">
<Link to="/" className="logo">
My App
</Link>
<NavLink
to="/products"
className={({ isActive }) =>
isActive ? 'nav-link active' : 'nav-link'
}
>
Products
</NavLink>
<NavLink
to="/about"
style={({ isActive }) => ({
color: isActive ? 'blue' : 'gray',
textDecoration: isActive ? 'underline' : 'none',
})}
>
About
</NavLink>
<Link
to="/products/42"
state={{ referrer: 'search' }}
className="link"
>
Product 42
</Link>
</nav>
);
}
Nested Routes and Route Composition {#nested-routes}
Nested routes allow you to structure your URL hierarchy and UI hierarchy together. A parent route renders an <Outlet> component, and child routes render their components inside that outlet.
TypeScript Version
import {
createBrowserRouter,
RouterProvider,
Outlet,
} from 'react-router-dom';
// Parent layout component
export function AdminLayout() {
return (
<div className="admin-container">
<aside className="admin-sidebar">
<AdminMenu />
</aside>
<main className="admin-main">
{/* Child routes render here */}
<Outlet />
</main>
</div>
);
}
// Admin page components
export function AdminDashboard() {
return <h1>Admin Dashboard</h1>;
}
export function AdminUsers() {
return <h1>Manage Users</h1>;
}
export function AdminSettings() {
return <h1>Settings</h1>;
}
// Define nested routes
const routes = [
{
path: '/admin',
element: <AdminLayout />,
children: [
{
path: '', // Default nested route
element: <AdminDashboard />,
},
{
path: 'users',
element: <AdminUsers />,
},
{
path: 'settings',
element: <AdminSettings />,
},
],
},
];
// Now these paths work:
// /admin → renders AdminLayout with AdminDashboard in Outlet
// /admin/users → renders AdminLayout with AdminUsers in Outlet
// /admin/settings → renders AdminLayout with AdminSettings in Outlet
JavaScript Version
import { createBrowserRouter, Outlet } from 'react-router-dom';
export function AdminLayout() {
return (
<div className="admin-container">
<aside className="admin-sidebar">
<AdminMenu />
</aside>
<main className="admin-main">
<Outlet />
</main>
</div>
);
}
export function AdminDashboard() {
return <h1>Admin Dashboard</h1>;
}
export function AdminUsers() {
return <h1>Manage Users</h1>;
}
export function AdminSettings() {
return <h1>Settings</h1>;
}
const routes = [
{
path: '/admin',
element: <AdminLayout />,
children: [
{
path: '',
element: <AdminDashboard />,
},
{
path: 'users',
element: <AdminUsers />,
},
{
path: 'settings',
element: <AdminSettings />,
},
],
},
];
Route Parameters and Dynamic Segments {#route-parameters}
Routes can have dynamic segments (like :id) that capture values from the URL. Access these values with the useParams() hook.
TypeScript Version
import { useParams, useLoaderData } from 'react-router-dom';
interface Product {
id: string;
name: string;
price: number;
description: string;
}
interface Params {
id?: string;
}
// Loader function receives params
const productLoader = async ({ params }: { params: Params }) => {
const { id } = params;
const response = await fetch(`/api/products/${id}`);
return response.json() as Promise<Product>;
};
// Component accesses params and loader data
export function ProductDetailPage() {
const { id } = useParams<Params>();
const product = useLoaderData() as Product;
return (
<div>
<h1>{product.name}</h1>
<p>Price: ${product.price}</p>
<p>ID: {id}</p>
<p>{product.description}</p>
</div>
);
}
// Route definition
const productRoute = {
path: '/products/:id',
element: <ProductDetailPage />,
loader: productLoader,
};
// Possible URLs:
// /products/123 → id = "123"
// /products/abc → id = "abc"
// Multiple parameters
const reviewRoute = {
path: '/products/:productId/reviews/:reviewId',
element: <ReviewPage />,
loader: ({ params }) => {
const { productId, reviewId } = params;
return fetch(`/api/products/${productId}/reviews/${reviewId}`).then(r => r.json());
},
};
JavaScript Version
import { useParams, useLoaderData } from 'react-router-dom';
const productLoader = async ({ params }) => {
const { id } = params;
const response = await fetch(`/api/products/${id}`);
return response.json();
};
export function ProductDetailPage() {
const { id } = useParams();
const product = useLoaderData();
return (
<div>
<h1>{product.name}</h1>
<p>Price: ${product.price}</p>
<p>ID: {id}</p>
<p>{product.description}</p>
</div>
);
}
const productRoute = {
path: '/products/:id',
element: <ProductDetailPage />,
loader: productLoader,
};
Programmatic Navigation with useNavigate {#programmatic-navigation}
Sometimes you need to navigate programmatically (not via a link click). Use the useNavigate() hook.
TypeScript Version
import { useNavigate } from 'react-router-dom';
interface LoginPageProps {
onSuccess?: () => void;
}
export function LoginPage({ onSuccess }: LoginPageProps) {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
setError('Invalid credentials');
return;
}
onSuccess?.();
// Navigate to dashboard after successful login
navigate('/dashboard', {
replace: true, // Replace history entry so back button doesn't go to login
state: { loginSuccess: true },
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
}
};
return (
<form onSubmit={handleLogin}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
{error && <p className="error">{error}</p>}
<button type="submit">Login</button>
</form>
);
}
// Navigation methods
export function NavigationExamples() {
const navigate = useNavigate();
return (
<div>
{/* Navigate forward */}
<button onClick={() => navigate('/products')}>
Go to Products
</button>
{/* Navigate with state */}
<button
onClick={() =>
navigate('/checkout', {
state: { cartTotal: 99.99 },
})
}
>
Checkout
</button>
{/* Navigate relative to current location */}
<button onClick={() => navigate('../')}>
Go to parent
</button>
{/* Navigate backward in history */}
<button onClick={() => navigate(-1)}>
Back
</button>
{/* Replace current history entry */}
<button
onClick={() =>
navigate('/home', { replace: true })
}
>
Go Home (replace history)
</button>
</div>
);
}
JavaScript Version
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
export function LoginPage({ onSuccess }) {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const handleLogin = async (e) => {
e.preventDefault();
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
setError('Invalid credentials');
return;
}
onSuccess?.();
navigate('/dashboard', {
replace: true,
state: { loginSuccess: true },
});
} catch (err) {
setError(err.message || 'Login failed');
}
};
return (
<form onSubmit={handleLogin}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
{error && <p className="error">{error}</p>}
<button type="submit">Login</button>
</form>
);
}
Search Parameters and Query Strings {#search-parameters}
Search parameters (query strings) let you store UI state in the URL without defining new routes. Access them with useSearchParams().
TypeScript Version
import { useSearchParams } from 'react-router-dom';
interface Filter {
category?: string;
minPrice?: number;
maxPrice?: number;
sort?: 'name' | 'price' | 'rating';
}
export function ProductsPage() {
const [searchParams, setSearchParams] = useSearchParams();
// Extract search params
const category = searchParams.get('category') || '';
const minPrice = searchParams.get('minPrice')
? Number(searchParams.get('minPrice'))
: undefined;
const sort = searchParams.get('sort') || 'name';
// Update search params
const handleFilterChange = (filters: Filter) => {
const params = new URLSearchParams();
if (filters.category) params.set('category', filters.category);
if (filters.minPrice) params.set('minPrice', String(filters.minPrice));
if (filters.maxPrice) params.set('maxPrice', String(filters.maxPrice));
if (filters.sort) params.set('sort', filters.sort);
setSearchParams(params);
};
const handleClearFilters = () => {
setSearchParams({});
};
return (
<div>
<h1>Products</h1>
<aside className="filters">
<input
type="text"
placeholder="Search by category"
value={category}
onChange={(e) =>
handleFilterChange({ category: e.target.value, sort: sort as any })
}
/>
<select
value={sort}
onChange={(e) =>
handleFilterChange({
category,
sort: e.target.value as any,
})
}
>
<option value="name">Sort by Name</option>
<option value="price">Sort by Price</option>
<option value="rating">Sort by Rating</option>
</select>
<button onClick={handleClearFilters}>Clear Filters</button>
</aside>
<main className="products">
{/* Products filtered by category, minPrice, maxPrice, sort */}
<ProductList
category={category}
minPrice={minPrice}
sort={sort}
/>
</main>
</div>
);
}
JavaScript Version
import { useSearchParams } from 'react-router-dom';
export function ProductsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') || '';
const minPrice = searchParams.get('minPrice')
? Number(searchParams.get('minPrice'))
: undefined;
const sort = searchParams.get('sort') || 'name';
const handleFilterChange = (filters) => {
const params = new URLSearchParams();
if (filters.category) params.set('category', filters.category);
if (filters.minPrice) params.set('minPrice', String(filters.minPrice));
if (filters.maxPrice) params.set('maxPrice', String(filters.maxPrice));
if (filters.sort) params.set('sort', filters.sort);
setSearchParams(params);
};
const handleClearFilters = () => {
setSearchParams({});
};
return (
<div>
<h1>Products</h1>
<aside className="filters">
<input
type="text"
placeholder="Search by category"
value={category}
onChange={(e) =>
handleFilterChange({ category: e.target.value, sort })
}
/>
<select
value={sort}
onChange={(e) =>
handleFilterChange({ category, sort: e.target.value })
}
>
<option value="name">Sort by Name</option>
<option value="price">Sort by Price</option>
<option value="rating">Sort by Rating</option>
</select>
<button onClick={handleClearFilters}>Clear Filters</button>
</aside>
<main className="products">
<ProductList
category={category}
minPrice={minPrice}
sort={sort}
/>
</main>
</div>
);
}
Error Boundaries and Error Pages {#error-handling}
React Router handles errors elegantly with errorElement and custom error boundaries.
TypeScript Version
import {
isRouteErrorResponse,
useRouteError,
} from 'react-router-dom';
export function ErrorPage() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div className="error-container">
<h1>{error.status}</h1>
<p>{error.statusText || 'An error occurred'}</p>
{error.data?.message && (
<p className="error-detail">{error.data.message}</p>
)}
</div>
);
}
if (error instanceof Error) {
return (
<div className="error-container">
<h1>Error</h1>
<p>{error.message}</p>
{import.meta.env.DEV && (
<details>
<summary>Stack trace (dev only)</summary>
<pre>{error.stack}</pre>
</details>
)}
</div>
);
}
return (
<div className="error-container">
<h1>Unknown Error</h1>
<p>Something went wrong. Please try again.</p>
</div>
);
}
// Use in routes
const routes = [
{
path: '/',
element: <Home />,
errorElement: <ErrorPage />,
},
{
path: '/products/:id',
element: <ProductDetailPage />,
loader: ({ params }) => {
if (!params.id) {
throw new Response('Product ID is required', { status: 400 });
}
return fetch(`/api/products/${params.id}`);
},
errorElement: <ErrorPage />,
},
];
JavaScript Version
import {
isRouteErrorResponse,
useRouteError,
} from 'react-router-dom';
export function ErrorPage() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div className="error-container">
<h1>{error.status}</h1>
<p>{error.statusText || 'An error occurred'}</p>
{error.data?.message && (
<p className="error-detail">{error.data.message}</p>
)}
</div>
);
}
if (error instanceof Error) {
return (
<div className="error-container">
<h1>Error</h1>
<p>{error.message}</p>
</div>
);
}
return (
<div className="error-container">
<h1>Unknown Error</h1>
<p>Something went wrong. Please try again.</p>
</div>
);
}
Code Splitting and Lazy Loading {#lazy-loading}
Load route components only when needed using React.lazy() and Suspense. This dramatically reduces your initial bundle size.
TypeScript Version
import {
lazy,
Suspense,
ComponentType,
ReactNode,
} from 'react';
import { createBrowserRouter } from 'react-router-dom';
// Lazy-loaded page components
const HomePage = lazy(() => import('./pages/HomePage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
// Loading fallback
interface LoadingProps {
message?: string;
}
function LoadingFallback({ message = 'Loading...' }: LoadingProps) {
return (
<div className="loading-container">
<div className="spinner" />
<p>{message}</p>
</div>
);
}
// Wrapper to handle Suspense
interface LazyRouteProps {
Component: ComponentType;
loadingMessage?: string;
}
function LazyRoute({ Component, loadingMessage }: LazyRouteProps) {
return (
<Suspense fallback={<LoadingFallback message={loadingMessage} />}>
<Component />
</Suspense>
);
}
// Define routes with lazy loading
const routes = [
{
path: '/',
element: <LazyRoute Component={HomePage} />,
},
{
path: '/admin',
element: <LazyRoute Component={AdminPage} loadingMessage="Loading admin..." />,
},
{
path: '/settings',
element: <LazyRoute Component={SettingsPage} />,
},
];
export const router = createBrowserRouter(routes);
JavaScript Version
import { lazy, Suspense } from 'react';
import { createBrowserRouter } from 'react-router-dom';
const HomePage = lazy(() => import('./pages/HomePage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
function LoadingFallback({ message = 'Loading...' }) {
return (
<div className="loading-container">
<div className="spinner" />
<p>{message}</p>
</div>
);
}
function LazyRoute({ Component, loadingMessage }) {
return (
<Suspense fallback={<LoadingFallback message={loadingMessage} />}>
<Component />
</Suspense>
);
}
const routes = [
{
path: '/',
element: <LazyRoute Component={HomePage} />,
},
{
path: '/admin',
element: <LazyRoute Component={AdminPage} loadingMessage="Loading admin..." />,
},
{
path: '/settings',
element: <LazyRoute Component={SettingsPage} />,
},
];
export const router = createBrowserRouter(routes);
Data Loading with Data Loaders {#data-loaders}
Data loaders fetch data before a route renders. This prevents "loading spinners" in your components—the data is already there when the component mounts.
TypeScript Version
import {
LoaderFunctionArgs,
useLoaderData,
defer,
} from 'react-router-dom';
import { Suspense } from 'react';
import { Await } from 'react-router-dom';
interface Product {
id: string;
name: string;
price: number;
}
interface Reviews {
id: string;
rating: number;
text: string;
}
// Eager loader (waits for all data)
const productLoader = async ({ params }: LoaderFunctionArgs) => {
const productRes = fetch(`/api/products/${params.id}`);
const reviewsRes = fetch(`/api/products/${params.id}/reviews`);
const [productData, reviewsData] = await Promise.all([
productRes,
reviewsRes,
]);
return {
product: (await productData.json()) as Product,
reviews: (await reviewsData.json()) as Reviews[],
};
};
// Deferred loader (renders page while loading data)
const productLoaderDeferred = async ({
params,
}: LoaderFunctionArgs) => {
const productPromise = fetch(`/api/products/${params.id}`).then(
(r) => r.json()
);
const reviewsPromise = fetch(
`/api/products/${params.id}/reviews`
).then((r) => r.json());
return defer({
product: productPromise,
reviews: reviewsPromise,
});
};
interface LoadedData {
product: Product;
reviews: Reviews[];
}
// Component using eager loader (has data immediately)
export function ProductPage() {
const { product, reviews } = useLoaderData() as LoadedData;
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<h2>Reviews</h2>
<ul>
{reviews.map((review) => (
<li key={review.id}>{review.text}</li>
))}
</ul>
</div>
);
}
// Component using deferred loader (renders while loading)
export function ProductPageDeferred() {
const { product, reviews } = useLoaderData() as any;
return (
<div>
<Suspense fallback={<h1>Loading product...</h1>}>
<Await resolve={product}>
{(resolvedProduct: Product) => (
<h1>{resolvedProduct.name}</h1>
)}
</Await>
</Suspense>
<h2>Reviews</h2>
<Suspense fallback={<p>Loading reviews...</p>}>
<Await resolve={reviews}>
{(resolvedReviews: Reviews[]) => (
<ul>
{resolvedReviews.map((review) => (
<li key={review.id}>{review.text}</li>
))}
</ul>
)}
</Await>
</Suspense>
</div>
);
}
// Route definitions
const routes = [
{
path: '/products/:id',
element: <ProductPage />,
loader: productLoader,
},
{
path: '/products/:id/deferred',
element: <ProductPageDeferred />,
loader: productLoaderDeferred,
},
];
JavaScript Version
import { useLoaderData, defer, Await } from 'react-router-dom';
import { Suspense } from 'react';
const productLoader = async ({ params }) => {
const productRes = fetch(`/api/products/${params.id}`);
const reviewsRes = fetch(`/api/products/${params.id}/reviews`);
const [productData, reviewsData] = await Promise.all([
productRes,
reviewsRes,
]);
return {
product: await productData.json(),
reviews: await reviewsData.json(),
};
};
const productLoaderDeferred = async ({ params }) => {
const productPromise = fetch(`/api/products/${params.id}`).then(
(r) => r.json()
);
const reviewsPromise = fetch(
`/api/products/${params.id}/reviews`
).then((r) => r.json());
return defer({
product: productPromise,
reviews: reviewsPromise,
});
};
export function ProductPage() {
const { product, reviews } = useLoaderData();
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<h2>Reviews</h2>
<ul>
{reviews.map((review) => (
<li key={review.id}>{review.text}</li>
))}
</ul>
</div>
);
}
export function ProductPageDeferred() {
const { product, reviews } = useLoaderData();
return (
<div>
<Suspense fallback={<h1>Loading product...</h1>}>
<Await resolve={product}>
{(resolvedProduct) => <h1>{resolvedProduct.name}</h1>}
</Await>
</Suspense>
<h2>Reviews</h2>
<Suspense fallback={<p>Loading reviews...</p>}>
<Await resolve={reviews}>
{(resolvedReviews) => (
<ul>
{resolvedReviews.map((review) => (
<li key={review.id}>{review.text}</li>
))}
</ul>
)}
</Await>
</Suspense>
</div>
);
}
Practical Application: Building a complete e-commerce routing system
Here's a real-world example combining nested routes, parameters, loaders, and lazy loading:
TypeScript Version
import {
createBrowserRouter,
RouterProvider,
Outlet,
LoaderFunctionArgs,
} from 'react-router-dom';
import { lazy, Suspense } from 'react';
interface Product {
id: string;
name: string;
price: number;
}
interface LoadingProps {
message?: string;
}
function LoadingFallback({ message = 'Loading...' }: LoadingProps) {
return <div className="loading">{message}</div>;
}
// Lazy load pages
const HomePage = lazy(() => import('./pages/HomePage'));
const ProductsPage = lazy(() => import('./pages/ProductsPage'));
const ProductDetailPage = lazy(() => import('./pages/ProductDetailPage'));
const CartPage = lazy(() => import('./pages/CartPage'));
const CheckoutPage = lazy(() => import('./pages/CheckoutPage'));
const OrderSuccessPage = lazy(() => import('./pages/OrderSuccessPage'));
// Layout components
export function RootLayout() {
return (
<div className="app">
<header className="app-header">
<nav>
<NavMenu />
</nav>
</header>
<main className="app-main">
<Suspense fallback={<LoadingFallback />}>
<Outlet />
</Suspense>
</main>
<footer>© 2025 E-commerce Store</footer>
</div>
);
}
// Loaders
const productLoader = async ({ params }: LoaderFunctionArgs) => {
const response = await fetch(`/api/products/${params.id}`);
if (!response.ok) {
throw new Response('Product not found', { status: 404 });
}
return response.json() as Promise<Product>;
};
// Router configuration
export const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <ErrorPage />,
children: [
{
path: '',
element: (
<Suspense fallback={<LoadingFallback message="Loading home..." />}>
<HomePage />
</Suspense>
),
},
{
path: 'products',
element: (
<Suspense fallback={<LoadingFallback message="Loading products..." />}>
<ProductsPage />
</Suspense>
),
},
{
path: 'products/:id',
element: (
<Suspense fallback={<LoadingFallback message="Loading product..." />}>
<ProductDetailPage />
</Suspense>
),
loader: productLoader,
},
{
path: 'cart',
element: (
<Suspense fallback={<LoadingFallback message="Loading cart..." />}>
<CartPage />
</Suspense>
),
},
{
path: 'checkout',
element: (
<Suspense fallback={<LoadingFallback message="Processing..." />}>
<CheckoutPage />
</Suspense>
),
},
{
path: 'order-success/:orderId',
element: (
<Suspense fallback={<LoadingFallback message="Confirming order..." />}>
<OrderSuccessPage />
</Suspense>
),
},
],
},
]);
export default function App() {
return <RouterProvider router={router} />;
}
Performance note: This setup uses lazy loading for all pages, nested routes for consistent layout, loaders for data fetching, and error handling for a complete, production-ready routing system.
FAQ
Q: What's the difference between useNavigate and <Link>?
A: <Link> is for navigation in JSX (like in templates). useNavigate() is a hook for programmatic navigation (e.g., after a form submission). Use <Link> most of the time; use useNavigate() when you need to navigate conditionally or after an async operation.
Q: How do I pass data between routes?
A: You have three options:
- URL state: Use route parameters or search params
- Navigation state: Pass
stateprop to<Link>or options touseNavigate() - Global state: Use Context, Redux, or Zustand for shared state
Route parameters are best for data that should be bookmarkable. Navigation state is for transient data.
Q: Should I use loaders or useEffect for data fetching?
A: Prefer loaders. They fetch data before rendering, eliminating loading states in components. Use useEffect only when data isn't part of route configuration (like polling or real-time updates).
Q: How do I protect routes that require authentication?
A: Create a wrapper component that checks auth status:
export function ProtectedRoute({ children, isAuthenticated }: any) {
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}
// Use in routes
{
path: '/admin',
element: <ProtectedRoute isAuthenticated={user !== null}><AdminPage /></ProtectedRoute>
}
Q: Can I use React Router without a router?
A: Technically yes, but you lose browser integration. Without React Router, back buttons don't work, bookmarks don't work, and sharing URLs fails. For any multi-page app, use a router.
Q: What's the best way to handle 404 pages?
A: Add a catch-all route at the end:
{
path: '*',
element: <NotFoundPage />
}
Ready to build your routing system? Share your routing challenges in the comments. What router patterns have solved your navigation problems?
Google AdSense Placeholder
CONTENT SLOT