intermediate
frontend

React Router

Client-side routing, nested routes, loaders, and protected route patterns

react fundamentals
28 min read

Routing in Single-Page Apps

A React app renders a single HTML document, so navigation between "pages" happens entirely in the browser. React Router is the de-facto library for mapping URLs to components, keeping the address bar in sync with what the user sees, and fetching the data each screen needs. From v6.4 onward it ships a data router API (createBrowserRouter) that co-locates routing, data loading, mutations, and error handling — the same foundation that powers React Router v7. Getting this layer right is what separates a jarring, flicker-prone app from one that feels instant and predictable.

Learning Objectives
  • Configure a data router with createBrowserRouter and RouterProvider
  • Compose nested layouts using Outlet and index routes
  • Read URL params and query strings with useParams and useSearchParams
  • Fetch and mutate data with loaders and actions
  • Guard private routes with loader redirects and a fallback component

Setting Up the Router

Install the package and create a router from an array of route objects. Each object maps a path to an element. RouterProvider wires the router into React.

bash
npm install react-router-dom
tsx
import { createBrowserRouter, RouterProvider } from "react-router-dom" const router = createBrowserRouter([ { path: "/", element: <Home /> }, { path: "/about", element: <About /> }, { path: "*", element: <NotFound /> }, ]) export default function App() { return <RouterProvider router={router} /> }

Never use a plain <a href> for internal links — it triggers a full-page reload and throws away your app's state. Use Link for standard navigation and NavLink when you need to style the currently active link.

Nested Routes and Layouts

Real apps share chrome — a header, sidebar, or footer — across many pages. Nest routes under a parent and render the parent's <Outlet /> where children should appear. An index: true route is what shows at the parent's exact path.

tsx
import { Outlet } from "react-router-dom" function RootLayout() { return ( <div> <NavBar /> <main> <Outlet /> </main> </div> ) } const router = createBrowserRouter([ { path: "/", element: <RootLayout />, children: [ { index: true, element: <Home /> }, { path: "products", element: <ProductList /> }, { path: "products/:productId", element: <ProductDetail /> }, ], }, ])

Here /, /products, and /products/123 all render inside RootLayout, so the nav bar never unmounts or flickers between navigations.

Reading URL Parameters

The :productId segment is a dynamic param. Read path params with useParams and query string values with useSearchParams.

tsx
import { useParams, useSearchParams } from "react-router-dom" function ProductDetail() { const { productId } = useParams() // "/products/123" -> "123" const [searchParams, setSearchParams] = useSearchParams() const tab = searchParams.get("tab") ?? "overview" // "?tab=reviews" -> "reviews" return ( <button onClick={() => setSearchParams({ tab: "reviews" })}> Show reviews for {productId} </button> ) }

Loading Data with Loaders

The data router lets each route declare a loader that runs before the component renders. React Router waits for the loader, so components never render in a half-loaded state — no useEffect fetch waterfalls and no manual loading flags for the initial render. Read the result with useLoaderData.

tsx
import { useLoaderData } from "react-router-dom" async function productLoader({ params }) { const res = await fetch(`/api/products/${params.productId}`) if (!res.ok) { throw new Response("Product not found", { status: 404 }) } return res.json() } const router = createBrowserRouter([ { path: "products/:productId", element: <ProductDetail />, loader: productLoader, }, ]) function ProductDetail() { const product = useLoaderData() as { id: string; name: string } return <h1>{product.name}</h1> }

Mutations with Actions

The mirror image of a loader is an action: it runs when a route's <Form> is submitted, performs the write, and can redirect afterward. React Router automatically re-runs the page's loaders on success so the UI reflects fresh data.

tsx
import { Form, redirect } from "react-router-dom" async function createProductAction({ request }) { const formData = await request.formData() await fetch("/api/products", { method: "POST", body: JSON.stringify({ name: formData.get("name") }), }) return redirect("/products") } function NewProduct() { return ( <Form method="post"> <input name="name" /> <button type="submit">Create</button> </Form> ) }

Protecting Routes

Two patterns coexist, and they solve slightly different problems.

The idiomatic data-router approach: check auth inside the loader and redirect before the component ever mounts. This blocks unauthorized data from being fetched at all.

tsx
import { redirect } from "react-router-dom" async function protectedLoader({ request }) { const user = await getCurrentUser() if (!user) { const from = new URL(request.url).pathname return redirect(`/login?from=${encodeURIComponent(from)}`) } return user } const router = createBrowserRouter([ { path: "dashboard", element: <Dashboard />, loader: protectedLoader }, ])

Handling Errors

Attach an errorElement to a route (usually the root) to catch anything thrown by that route's element, loader, or action. Inside it, useRouteError gives you the thrown value.

tsx
import { useRouteError, isRouteErrorResponse } from "react-router-dom" function ErrorBoundary() { const error = useRouteError() if (isRouteErrorResponse(error)) { return <h1>{error.status} — {error.statusText}</h1> } return <h1>Something went wrong.</h1> } const router = createBrowserRouter([ { path: "/", element: <RootLayout />, errorElement: <ErrorBoundary /> }, ])

Code Splitting Routes

Ship less JavaScript up front by lazily loading a route's component and data logic. The lazy property imports a module that exports Component, loader, and/or action only when the route is first visited.

tsx
const router = createBrowserRouter([ { path: "reports", lazy: () => import("./routes/reports"), // exports Component + loader }, ])

Common Pitfalls

Best Practices

✅ Routing checklist
  1. One root layout with shared chrome and a single errorElement. 2. Load data in loaders, mutate in actions — keep components focused on rendering. 3. Throw Response objects for expected failures like 404s. 4. Guard on the server side with loader redirects for anything truly private. 5. Lazy-load heavy routes to shrink the initial bundle. 6. Give every dynamic screen a * fallback and a real 404.

Next Steps

Continue Learning
Memoization and code splitting.

Next Lesson

Performance Optimization