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.
- 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.
bashnpm install react-router-dom
tsximport { 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} /> }
The catch-all route
path: "*" matches anything no other route claimed. Always include one so unknown URLs render a proper 404 page instead of a blank screen.Navigating Between Routes
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.
Basic client-side navigation with no reload:
tsximport { Link } from "react-router-dom" <Link to="/about">About us</Link> <Link to="/products/42">View product</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.
tsximport { 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.
tsximport { 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.
tsximport { 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> }
Throw, don't return, for errors
throws a Response (or any error) hands control to the nearest errorElement. Returning an error object instead means your component has to defensively check for it on every render.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.
tsximport { 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.
tsximport { 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 }, ])
Preserve where they came from
from query param or router state) lets you send users back to their intended page after they sign in, instead of dumping everyone on the home page.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.
tsximport { 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.
tsxconst router = createBrowserRouter([ { path: "reports", lazy: () => import("./routes/reports"), // exports Component + loader }, ])
Common Pitfalls
Best Practices
- One root layout with shared chrome and a single
errorElement. 2. Load data in loaders, mutate in actions — keep components focused on rendering. 3. ThrowResponseobjects 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
Next Lesson
Performance Optimization