On this page

React Router v7: Client-Side Navigation and Loaders

15 min read TextCh. 3 — Patterns and Navigation

Why React Router?

React is a UI library, not a framework. It does not include routing out of the box. React Router v7 is the most widely used routing solution for React and has become so integrated with React's architecture that it is now the recommended approach for full-stack React apps as well.

React Router v7 introduces:

  • File-based routing (optional, via Vite plugin)
  • Type-safe loaders and actions
  • Parallel data loading across nested routes
  • Pending/optimistic UI built in
  • Full SSR support (used by Remix under the hood)

Installation

npm install react-router

React Router v7 ships as a single react-router package (the old react-router-dom is merged in).

Creating a Router

Use createBrowserRouter for SPAs. The router is defined as a tree of route objects:

import { createBrowserRouter, RouterProvider } from 'react-router';

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      { index: true, element: <HomePage /> },
      { path: 'about', element: <AboutPage /> },
      { path: 'contact', element: <ContactPage /> },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

Nested Routes and Layouts

Nested routes share a common layout. The parent component renders <Outlet /> to display the active child:

import { Outlet, NavLink } from 'react-router';

function RootLayout() {
  return (
    <div className="app-shell">
      <nav>
        <NavLink
          to="/"
          className={({ isActive }) => isActive ? 'nav-link nav-link--active' : 'nav-link'}
          end
        >
          Home
        </NavLink>
        <NavLink
          to="/courses"
          className={({ isActive }) => isActive ? 'nav-link nav-link--active' : 'nav-link'}
        >
          Courses
        </NavLink>
      </nav>
      <main>
        {/* Active child route renders here */}
        <Outlet />
      </main>
    </div>
  );
}

NavLink automatically applies an active class (or a custom class via the callback) when its to path matches the current URL.

Dynamic Route Parameters

Use :paramName in the path to capture dynamic segments:

const router = createBrowserRouter([
  {
    path: '/users/:userId',
    element: <UserPage />,
  },
  {
    path: '/products/:category/:productId',
    element: <ProductPage />,
  },
]);

Read params in the component with useParams:

import { useParams, Link } from 'react-router';

function UserPage() {
  const { userId } = useParams<{ userId: string }>();

  return (
    <div>
      <h1>User: {userId}</h1>
      <Link to="/users">← Back to users</Link>
    </div>
  );
}

Data Loading with Loaders

Loaders allow you to co-locate data fetching with route definitions. They run before the component renders, eliminating loading states inside components:

import { useLoaderData, type LoaderFunctionArgs } from 'react-router';

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
}

// Define the loader alongside the route
async function productLoader({ params }: LoaderFunctionArgs): Promise<Product> {
  const res = await fetch(`/api/products/${params.productId}`);
  if (!res.ok) throw new Response('Not Found', { status: 404 });
  return res.json() as Promise<Product>;
}

// Component receives pre-loaded data
function ProductPage() {
  const product = useLoaderData() as Product;

  return (
    <article>
      <h1>{product.name}</h1>
      <p>${product.price}</p>
      <p>{product.description}</p>
    </article>
  );
}

// Route configuration
const router = createBrowserRouter([
  {
    path: '/products/:productId',
    element: <ProductPage />,
    loader: productLoader,
  },
]);

Programmatic Navigation

Use useNavigate for programmatic redirects (after form submission, authentication, etc.):

import { useNavigate } from 'react-router';

function LoginForm() {
  const navigate = useNavigate();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    try {
      await login(email, password);
      navigate('/dashboard', { replace: true }); // replace so back button doesn't return to login
    } catch {
      // handle error
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
      <button type="submit">Login</button>
    </form>
  );
}

Search Params

Manage URL query parameters with useSearchParams:

import { useSearchParams } from 'react-router';

function ProductCatalog() {
  const [searchParams, setSearchParams] = useSearchParams();
  const category = searchParams.get('category') ?? 'all';
  const page = Number(searchParams.get('page') ?? '1');

  function handleCategoryChange(newCategory: string) {
    setSearchParams({ category: newCategory, page: '1' });
  }

  function handleNextPage() {
    setSearchParams({ category, page: String(page + 1) });
  }

  return (
    <div>
      <select
        value={category}
        onChange={(e) => handleCategoryChange(e.target.value)}
        aria-label="Filter by category"
      >
        <option value="all">All</option>
        <option value="frontend">Frontend</option>
        <option value="backend">Backend</option>
      </select>

      <ProductList category={category} page={page} />

      <button type="button" onClick={handleNextPage}>Next page</button>
    </div>
  );
}

Error Handling with errorElement

Each route can define an errorElement to handle loader errors, 404s, and rendering errors:

import { useRouteError, isRouteErrorResponse, Link } from 'react-router';

function ErrorPage() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    return (
      <main>
        <h1>{error.status} — {error.statusText}</h1>
        <p>{typeof error.data === 'string' ? error.data : 'Page not found.'}</p>
        <Link to="/">Go home</Link>
      </main>
    );
  }

  return (
    <main>
      <h1>Unexpected Error</h1>
      <p>Something went wrong. Please try again.</p>
      <Link to="/">Go home</Link>
    </main>
  );
}

Lazy Loading Route Components

Use dynamic import() combined with React.lazy to split route code into separate bundles:

import { lazy, Suspense } from 'react';

const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'));

const router = createBrowserRouter([
  {
    path: '/dashboard',
    element: (
      <Suspense fallback={<PageSkeleton />}>
        <DashboardPage />
      </Suspense>
    ),
  },
  {
    path: '/analytics',
    element: (
      <Suspense fallback={<PageSkeleton />}>
        <AnalyticsPage />
      </Suspense>
    ),
  },
]);

This ensures each page's code is only downloaded when the user navigates to it, keeping the initial bundle small.

In the next lesson, you will learn how to build controlled forms with validation and leverage React 19's new form actions.

Loaders run in parallel for nested routes
React Router runs all loaders for a matched route tree simultaneously, not sequentially. This means the parent route loader and child route loader execute at the same time, reducing total load time. Design your loaders to be independent of each other.
useLoaderData is fully typed with TypeScript
When you pass the router as a generic to `createBrowserRouter`, TypeScript can infer the return type of `useLoaderData()` in each route component. Use `typeof courseLoader` in the component to get full type safety without manual type annotation.
Avoid navigation inside event handlers when possible
Prefer `<Link>` and `<NavLink>` for all navigational elements — they are accessible, work with keyboard navigation, support right-click behavior, and respect the browser's history stack. Only use `useNavigate()` programmatically (e.g., after a form submission or redirect after login).
import { createBrowserRouter, RouterProvider } from 'react-router';
import { RootLayout } from './layouts/RootLayout';
import { HomePage } from './pages/HomePage';
import { CoursesPage } from './pages/CoursesPage';
import { CourseDetailPage } from './pages/CourseDetailPage';
import { LessonPage } from './pages/LessonPage';
import { NotFoundPage } from './pages/NotFoundPage';
import { courseLoader, lessonLoader } from './loaders';

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <NotFoundPage />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: 'courses',
        children: [
          { index: true, element: <CoursesPage /> },
          {
            path: ':courseSlug',
            element: <CourseDetailPage />,
            loader: courseLoader,
            children: [
              {
                path: ':lessonSlug',
                element: <LessonPage />,
                loader: lessonLoader,
              },
            ],
          },
        ],
      },
    ],
  },
]);

export function AppRouter() {
  return <RouterProvider router={router} />;
}