React Router: Multiple Pages, Links, Params, Layouts and Protected Routes

React.js Full Course: Beginner to Advanced · Hooks and Data · 12 min read · Updated

A page change without reload

  1. The person clicks a Link. The address changes, but the browser does not reload.
  2. React Router compares the address with your list of routes.
  3. The shared layout stays on the screen, and the Outlet shows the matching page.
  4. The page reads the id from the address with useParams and loads that product.
  5. Unknown addresses match the * route and show a not found page.

A real website has many pages: home, about, product pages, a login page. A React app is made of one HTML file, so how do we show different pages? We use React Router, the most used routing library for React. It changes what the screen shows when the address changes, without reloading the page. That makes the app feel fast.

Install and set up

npm install react-router-dom

Wrap your app in a router once, in main.jsx:

import { BrowserRouter } from 'react-router-dom';

createRoot(document.getElementById('root')).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

BrowserRouter watches the address bar and tells your app when it changes.

Routes: which component for which address

import { Routes, Route } from 'react-router-dom';

function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
      <Route path="/products" element={<Products />} />
      <Route path="*" element={<NotFound />} />
    </Routes>
  );
}

Each Route pairs a path with an element. React Router shows the one that matches the address. The path * matches anything else, so it is perfect for a 404 page. Always add one.

A normal <a href> makes the browser load a whole new page and your state is lost. Use Link instead, which changes the address and shows the new page without a reload:

import { Link, NavLink } from 'react-router-dom';

function Menu() {
  return (
    <nav>
      <Link to="/">Home</Link>
      <NavLink to="/about" className={({ isActive }) => (isActive ? 'active' : '')}>About</NavLink>
      <NavLink to="/products">Products</NavLink>
    </nav>
  );
}

NavLink is a Link that knows when it matches the current page, so you can style the active link. For links to other websites, keep using a normal <a href>.

URL parameters

A shop has one page design for many products, such as /products/1 and /products/2. Put a parameter in the path with a colon, and read it with useParams:

<Route path="/products/:id" element={<ProductPage />} />

import { useParams } from 'react-router-dom';

function ProductPage() {
  const { id } = useParams();
  return <h1>Product number {id}</h1>;
}

Parameters are always strings, so convert with Number(id) if you need a number. Use the id to load the right data, as in the fetching lesson.

Query strings (search parameters)

The part after a question mark, like /products?category=books&page=2, is the query string. It is perfect for filters, search and pages, because people can share and bookmark the exact view, and the back button works.

import { useSearchParams } from 'react-router-dom';

function Products() {
  const [params, setParams] = useSearchParams();
  const category = params.get('category') || 'all';

  return (
    <div>
      <p>Showing: {category}</p>
      <button onClick={() => setParams({ category: 'books' })}>Books</button>
      <button onClick={() => setParams({})}>All</button>
    </div>
  );
}

It works like useState, but the state lives in the address. Keeping filters in the URL, rather than in private state, is a very good habit.

Shared layouts with nested routes

Most pages share a header and a footer. Instead of repeating them, make a layout route. It draws the shared parts and an Outlet, where the matching child page appears.

import { Outlet } from 'react-router-dom';

function Layout() {
  return (
    <div>
      <Menu />
      <main>
        <Outlet />
      </main>
      <footer>Copyright</footer>
    </div>
  );
}

function App() {
  return (
    <Routes>
      <Route element={<Layout />}>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/products/:id" element={<ProductPage />} />
        <Route path="*" element={<NotFound />} />
      </Route>
    </Routes>
  );
}

The layout stays on the screen while only the content in the Outlet changes. The menu is not drawn again from nothing, so things like its state stay.

Moving with code: useNavigate

Sometimes you must change page from code, for example after a form is saved:

import { useNavigate } from 'react-router-dom';

function LoginForm() {
  const navigate = useNavigate();

  async function handleSubmit(event) {
    event.preventDefault();
    // ... check the login
    navigate('/dashboard');
  }
  return <form onSubmit={handleSubmit}>...</form>;
}

navigate(-1) goes back one page, like the back button. navigate('/x', { replace: true }) replaces the current page in the history, so the back button does not return to it. That is useful after a login.

For a simple redirect while drawing, return <Navigate to="/login" />.

Protected routes

Some pages should be open only to signed-in people. Make a small component that checks, and redirects if needed:

import { Navigate, Outlet, useLocation } from 'react-router-dom';

function RequireAuth() {
  const { user } = useAuth();        // from the context lesson
  const location = useLocation();

  if (!user) {
    return <Navigate to="/login" replace state={{ from: location }} />;
  }
  return <Outlet />;
}

// in the routes
<Route element={<RequireAuth />}>
  <Route path="/dashboard" element={<Dashboard />} />
  <Route path="/settings" element={<Settings />} />
</Route>

If there is no user, we send the visitor to the login page and remember where they wanted to go in state. After login, you can send them back with navigate(location.state?.from?.pathname || '/').

Scroll and titles

  • When the page changes, the scroll position can stay where it was. Add a small component that calls window.scrollTo(0, 0) when useLocation().pathname changes.
  • Set a different document.title on each page. It helps people and search engines. A client-side React app is harder for search engines to read than normal HTML, so for pages that must rank well, think about server rendering with a framework such as Next.js.
  • Move focus to the main heading after a change, to help keyboard and screen reader users.

Data routers (a short note)

Newer versions of React Router also have a data router style, with createBrowserRouter, loaders and actions, which load data before showing a page. It is a good next step once you know the basics here. The ideas (routes, links, params, layouts) are the same.

Deploy note

With client-side routing, a visit directly to /products/3 asks the server for that path. Your server or host must send index.html for unknown paths, or visitors see a 404 on reload. Most hosts have a setting for this, called “single page app fallback” or a rewrite rule.

Common mistakes

  • Using <a href> for inside links, which reloads the page.
  • Forgetting the * route for 404.
  • Forgetting to render <Outlet /> in the layout, so child pages never appear.
  • Treating useParams values as numbers. They are strings.
  • Forgetting the host fallback, so a page reload gives a server 404.

A small complete app

Here is how the pieces fit together in one small file. It has a layout, three pages, a product page that reads the URL and a page for unknown addresses.

import { BrowserRouter, Routes, Route, NavLink, Outlet, useParams, Link } from 'react-router-dom';

const products = [
  { id: '1', name: 'Notebook' },
  { id: '2', name: 'Pen' },
];

function Layout() {
  return (
    <>
      <nav>
        <NavLink to="/">Home</NavLink> <NavLink to="/products">Products</NavLink>
      </nav>
      <main><Outlet /></main>
    </>
  );
}

function Products() {
  return (
    <ul>
      {products.map((p) => <li key={p.id}><Link to={'/products/' + p.id}>{p.name}</Link></li>)}
    </ul>
  );
}

function Product() {
  const { id } = useParams();
  const product = products.find((p) => p.id === id);
  if (!product) return <p>Product not found.</p>;
  return <h1>{product.name}</h1>;
}

export default function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route element={<Layout />}>
          <Route path="/" element={<h1>Welcome</h1>} />
          <Route path="/products" element={<Products />} />
          <Route path="/products/:id" element={<Product />} />
          <Route path="*" element={<h1>Page not found</h1>} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

Read it from the bottom. The router watches the address. The layout draws the menu, and the Outlet shows the page that matches. The product page reads the id from the address and finds its data, and shows a message if the id is unknown. That last check matters: someone can type any address, so a page that uses a parameter must always handle a missing item.

Questions beginners often ask

What is the difference between BrowserRouter and HashRouter? BrowserRouter gives clean addresses like /about and needs the server fallback to index.html. HashRouter puts the page after a #, like /#/about, and works on any host without extra setup, but the addresses look less clean.

How do I go back with a button? Call navigate(-1), which works like the browser back button.

How do I highlight the current link? Use NavLink. It gives you isActive so you can add a class or style to the link of the current page.

Can I read the URL from a deep component? Yes. Call useParams, useLocation or useSearchParams in any component under the router. There is no need to pass the values down as props.

Your task

  1. Make an app with Home, About and Products pages, a shared layout with a menu, and a 404 page.
  2. Add /products/:id and show the id. Link to three products.
  3. Add a category filter that lives in the query string.
  4. Add a fake login with context, a protected Dashboard page, and a redirect back after login.

How to check it worked

  • Changing pages does not reload the browser.
  • Reloading on /products/2 still works in the dev server.
  • Visiting the Dashboard while signed out sends you to the login page.

Summary

  • React Router shows different components for different addresses without reloading.
  • Use Routes and Route, Link and NavLink, useParams, useSearchParams and useNavigate.
  • Layout routes with Outlet share headers and footers.
  • Protect routes with a wrapper that redirects, and always check permissions on the server too.

Frequently asked questions

Why use Link instead of an anchor tag?

Link changes the page without a full reload, so your state and speed are kept. A normal anchor makes the browser load a whole new page.

How do I read an id from the URL?

Define the route with a parameter, like /products/:id, and read it with useParams. It is always a string.

Why do I get a 404 when I reload a page after deploying?

The server looks for a real file at that path. Set up a fallback so unknown paths return index.html and let React Router show the page.

Are protected routes secure?

They only change what the browser shows. Anything sent to the browser can be read. Your server must also check permissions for the data.