Module 5: Forms, Styling and Routing
Routing with React Router
How client-side routing works, defining nested routes, links, dynamic params, search params, redirects and lazy routes.
A React app built with Vite is one HTML page. Yet users expect /products, /products/42 and /cart to behave like separate pages, with working back buttons and shareable URLs. Client-side routing is the trick that makes one page feel like many.
How it works#
The browser has a History API. JavaScript can change the address bar (history.pushState) without a page reload, and can listen for when the user hits back or forward. A router library watches the URL, decides which component matches, and renders it.
user clicks <Link to="/cart">
│
▼
router calls history.pushState → URL becomes /cart (no reload)
│
▼
router matches "/cart" against route config
│
▼
React renders <Cart /> where the route says it belongs
React Router is the most widely used library for this. Version 7 ships everything from a single package.
npm install react-router
Defining routes#
React Router has a few ways to set up. The one I'd recommend for a new app is a route configuration object passed to createBrowserRouter.
// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter } from "react-router";
import { RouterProvider } from "react-router/dom";
import Layout from "./Layout.jsx";
import Home from "./pages/Home.jsx";
import Products from "./pages/Products.jsx";
import ProductDetail from "./pages/ProductDetail.jsx";
import NotFound from "./pages/NotFound.jsx";
const router = createBrowserRouter([
{
path: "/",
Component: Layout,
children: [
{ index: true, Component: Home },
{ path: "products", Component: Products },
{ path: "products/:productId", Component: ProductDetail },
{ path: "*", Component: NotFound },
],
},
]);
createRoot(document.getElementById("root")).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);
Import RouterProvider from react-router/dom in browser apps. It's the same component, with the DOM-specific wiring done for you.
Each route is an object with a path and a Component. Routes can have children, which gives you nesting.
Layouts with Outlet#
A parent route renders its layout, and uses <Outlet /> to mark where the matching child goes.
// src/Layout.jsx
import { NavLink, Outlet } from "react-router";
export default function Layout() {
return (
<>
<header>
<nav>
<NavLink to="/" end>Home</NavLink>
<NavLink to="/products">Products</NavLink>
</nav>
</header>
<main>
<Outlet />
</main>
<footer>Footer</footer>
</>
);
}
URL: /products/42
Layout ← route "/"
├─ header, nav
├─ <Outlet /> ──► ProductDetail ← route "products/:productId"
└─ footer
The header and footer stay mounted while the content swaps, so they don't flash and any state in them is preserved. That's the main reason to use nested routes.
An index: true route is what renders in the Outlet when the URL exactly matches the parent (/).
Links#
import { Link, NavLink } from "react-router";
<Link to="/products">Products</Link>
<NavLink to="/products" className={({ isActive }) => (isActive ? "active" : "")}>
Products
</NavLink>
Use Link instead of a plain <a href>. A normal anchor triggers a full page reload and throws away your app's in-memory state. NavLink is a Link that knows when it matches the current URL, which is what you want for menus. Add end to the home link so / isn't "active" on every page.
Dynamic segments#
A path segment starting with a colon is a parameter. Read it with useParams.
import { useParams } from "react-router";
function ProductDetail() {
const { productId } = useParams(); // always a string
return <h1>Product {productId}</h1>;
}
Params are strings. If you need a number for a lookup, convert it, and decide what to do when it isn't valid.
Navigating from code#
Links cover most cases. For after a form submit or login, use useNavigate.
import { useNavigate } from "react-router";
function Login() {
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
await login();
navigate("/dashboard", { replace: true }); // replace: back button won't return to login
}
// ...
}
For an immediate redirect during render (for example, a protected route), render <Navigate to="/login" replace /> instead.
Search params#
State that belongs in the URL, like filters, sort order and page number, should live there so people can bookmark and share it.
import { useSearchParams } from "react-router";
function Products() {
const [searchParams, setSearchParams] = useSearchParams();
const sort = searchParams.get("sort") ?? "name";
return (
<>
<select value={sort} onChange={(e) => setSearchParams({ sort: e.target.value })}>
<option value="name">Name</option>
<option value="price">Price</option>
</select>
{/* URL becomes /products?sort=price */}
</>
);
}
It works like useState where the "state" is the query string. Refresh the page and the filter is still there, which is a nice upgrade over component state.
Protected routes#
A layout route that checks authentication and either renders its children or redirects:
import { Navigate, Outlet } from "react-router";
function RequireAuth() {
const { user } = useAuth(); // from your context
if (!user) return <Navigate to="/login" replace />;
return <Outlet />;
}
// in the route config
{ Component: RequireAuth, children: [{ path: "account", Component: Account }] }
Remember this only hides UI. The server must still check permissions on every API request.
Lazy-loaded routes#
You can keep a route's code out of the initial bundle. Route objects accept a lazy function that dynamically imports the module and returns the components and settings:
{
path: "reports",
lazy: async () => {
const { default: Reports } = await import("./pages/Reports.jsx");
return { Component: Reports };
},
}
We'll look at code splitting properly in module 7.
Loaders and actions#
Data-router mode can also fetch data before rendering a route, using a loader on the route and useLoaderData in the component. It's powerful and worth learning from the official docs. In this course, we'll fetch data inside components with TanStack Query, which gives us caching and background refresh that loaders don't provide on their own.
Server setup for deep links#
If someone loads /products/42 directly, the browser asks your server for a file at that path, and there isn't one. In dev, Vite handles it. In production, your host must be configured to serve index.html for unknown paths so React Router can take over. We'll cover that in the deployment lesson. This is the cause of the classic "works on click, 404 on refresh" bug.
Where people get burned#
- Using
<a href>for internal navigation and losing state with a full reload. - Forgetting
<Outlet />in the layout, then seeing nothing render for child routes. - Forgetting
endon a rootNavLinkso it's always highlighted. - Treating
useParamsvalues as numbers. - No catch-all
*route, which leaves users on a blank page for bad URLs. - Deploying without a rewrite rule and getting 404s on refresh.
- Redirecting with
navigate()during render rather than in a handler or with<Navigate />.
Practice#
- Create a three-page app (Home, Products, ProductDetail) with a shared layout and a working 404 page.
- Build a product list where each item links to
/products/:id, and have the detail page read the param and look up the product in a local array. - Add
?sort=and?q=search params to the product list and check that a page refresh preserves the filter. - Write a
RequireAuthlayout and use it to protect an/accountroute, redirecting to/login.