Himanshu SharmaSoftware engineer
Let’s talk

React · Lesson 31 of 38

Routing with React Router

Add pages to a React app with React Router: define routes, link between pages, read URL parameters and query strings, share layouts and go live.

22 minBuilding an appUpdated 24 October 2026

By the end of this lessonBuild a multi-page React app with routes, a shared layout, links, URL parameters, search parameters and a not-found page.

A website is made of pages. In a traditional site each page is a separate HTML file, and clicking a link makes the browser load a new document. A React app is usually a single page application (SPA): the browser loads one index.html, and JavaScript changes what is displayed when the URL changes. A router does the matching: for each URL, which component should be on screen? The most widely used router for React is React Router.

This lesson builds a small multi-page app. I wrote and tested it in a Vite project with React Router 8 and React 19 (the package is named react-router; older tutorials import from react-router-dom, which still works and re-exports the same things). Routing needs a real URL bar, so the lesson editor cannot run it; the output shown below comes from tests that render the app at different URLs.

Install and set up

In a Vite React project:

bash
npm install react-router

Wrap the app in a router in src/main.jsx. BrowserRouter uses the browser's History API so the URLs look normal (/courses), with no #:

jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import './index.css'
import App from './App.jsx'

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

Defining routes

Inside the router, Routes looks at the URL and renders the first Route that matches. Each Route pairs a path with an element:

jsx
import { Route, Routes } from "react-router";
import Layout from "./Layout.jsx";
import Home from "./pages/Home.jsx";
import Courses from "./pages/Courses.jsx";
import CourseDetail from "./pages/CourseDetail.jsx";
import NotFound from "./pages/NotFound.jsx";

export default function App() {
  return (
    <Routes>
      <Route element={<Layout />}>
        <Route index element={<Home />} />
        <Route path="courses" element={<Courses />} />
        <Route path="courses/:slug" element={<CourseDetail />} />
        <Route path="*" element={<NotFound />} />
      </Route>
    </Routes>
  );
}

How to read it:

  • index marks the default child route, shown at the parent's own URL (/).
  • path="courses" matches /courses.
  • path="courses/:slug" matches /courses/react, /courses/html and so on. A segment starting with a colon is a dynamic parameter.
  • path="*" matches anything else: a catch-all for the "not found" page.
  • A Route with no path, only an element, is a layout route: it wraps its children, and the next section shows how.

Layouts and Outlet

Navigation bars and footers should appear on every page without being repeated. The layout route renders the shared parts and puts an <Outlet /> where the matched child should appear:

jsx
import { NavLink, Outlet } from "react-router";

export default function Layout() {
  return (
    <div>
      <nav>
        <NavLink to="/" end>Home</NavLink>{" | "}
        <NavLink to="/courses">Courses</NavLink>
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
}

At /, the outlet shows Home; at /courses, it shows Courses. The navigation stays put and is not re-created as you move between pages.

A normal <a href="/courses"> makes the browser request a fresh page from the server, throwing away the running app and all its state. Use React Router's components, which change the URL with the History API and let the router swap the component with no page reload:

Component Use
<Link to="/courses"> an ordinary internal link
<NavLink to="/courses"> a link that knows when it is the current page: it gets the class active and aria-current="page" automatically, ideal for menus
<Navigate to="/login" /> redirect when rendered

end on the Home NavLink stops it from also being "active" on every other URL, since all paths start with /. External sites still use <a href="https://...">.

Reading URL parameters: useParams

The dynamic part of the path is available through useParams. This page also uses useNavigate, a function for moving to another page from code, such as after a form is submitted:

jsx
import { Link, useNavigate, useParams } from "react-router";

export default function CourseDetail() {
  const { slug } = useParams();
  const navigate = useNavigate();
  return (
    <div>
      <h1>Course: {slug}</h1>
      <button onClick={() => navigate("/courses")}>Back to courses</button>{" "}
      <Link to="/">Home</Link>
    </div>
  );
}

Rendering the app at /courses/react produced the text Home | CoursesCourse: reactBack to courses Home (the navigation, then the heading, the button and the link, run together because it is the page text). The value react came from the URL. Use the parameter to look up the item to show (from a list, or with a fetch in an effect, remembering the cleanup from the fetching lesson).

Query strings: useSearchParams

Filters, sort orders and page numbers belong in the query string (/courses?level=beginner) so that the page can be bookmarked and shared. useSearchParams works like useState for the query string:

jsx
import { Link, useSearchParams } from "react-router";

const courses = [
  { slug: "html", title: "HTML", level: "beginner" },
  { slug: "react", title: "React", level: "intermediate" },
  { slug: "mysql", title: "MySQL", level: "beginner" },
];

export default function Courses() {
  const [params, setParams] = useSearchParams();
  const level = params.get("level");
  const shown = level ? courses.filter(c => c.level === level) : courses;

  return (
    <div>
      <h1>Courses</h1>
      <button onClick={() => setParams({ level: "beginner" })}>Beginner only</button>{" "}
      <button onClick={() => setParams({})}>All</button>
      <ul>
        {shown.map(c => (
          <li key={c.slug}><Link to={`/courses/${c.slug}`}>{c.title}</Link></li>
        ))}
      </ul>
    </div>
  );
}

In the test, /courses listed HTML, React, MySQL; pressing "Beginner only" changed the URL to /courses?level=beginner and the list became HTML, MySQL. Clicking the MySQL link then showed Course: mysql. The filter lives in the URL rather than in state, so refreshing the page or sharing the link keeps the filter. Always read from the URL; do not copy it into state.

A not-found page

The path="*" route catches unknown URLs:

jsx
import { Link } from "react-router";

export default function NotFound() {
  return (
    <div>
      <h1>Page not found</h1>
      <Link to="/">Go home</Link>
    </div>
  );
}

At /nowhere, the test rendered Home | CoursesPage not foundGo home. For a missing course (/courses/unknown), the detail page itself should check whether the slug exists and show a not-found message, since the router only knows that the pattern matched.

Testing routes without a browser

MemoryRouter keeps the URL in memory, so you can render the app at any path in a test or an example:

jsx
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import App from './App.jsx'

test('course detail reads the slug', () => {
  render(
    <MemoryRouter initialEntries={['/courses/react']}>
      <App />
    </MemoryRouter>,
  )
  expect(screen.getByRole('heading', { name: 'Course: react' })).toBeInTheDocument()
})

The testing lesson later in the course sets up the tools for this.

Deploying an SPA: send every URL to index.html

When someone opens https://example.com/courses/react directly (a bookmark, a refresh), the server receives that URL. A static host has only index.html and the built assets; there is no file called courses/react, so it answers 404. The server must be configured to return index.html for unknown URLs, and then React Router reads the URL and shows the right page. The development server and vite preview already do this (in my test, /, /courses, /courses/react and /nowhere all answered with status 200 from vite preview). On Apache, a .htaccess file with a rewrite rule does it:

text
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]

The same idea applies to Nginx (try_files $uri /index.html) and to hosting services (a "rewrite all to index.html" setting). The deployment lesson returns to this. Note also that an SPA serves one HTML file, so each page has the same <title> unless your code changes it, and search engines may see little content; sites that depend on search traffic often use a framework with server rendering (Next.js, or React Router in framework mode).

Beyond the basics

  • Data routers (createBrowserRouter and RouterProvider) let you attach loaders (fetch data before a route renders), actions (handle form submissions) and error elements to routes. They are a different style of setup that builds on the same concepts, and a good next step.
  • Protected routes: a layout route that renders <Navigate to="/login" /> when the user is not signed in, and <Outlet /> otherwise.
  • Lazy loading: load a page's code only when the route is visited, with React.lazy, covered in the Suspense lesson.
  • Scroll restoration and focus: moving to a new page should move keyboard focus and scroll to the top, which frameworks and the router's helpers can handle; see the accessibility lesson.

Common mistakes

  • Using <a href> for internal links, which reloads the app.
  • Putting BrowserRouter inside the component that uses useNavigate or Link (the router must be an ancestor).
  • Forgetting end on the Home NavLink, so it is always highlighted.
  • Copying route parameters or query values into state instead of reading them from the URL.
  • Not handling a parameter that matches no item.
  • Opening deep links on a static host without the rewrite rule, getting 404 errors.
  • Importing from react-router-dom and react-router in the same project at different versions.

Exercise

  1. Add an "About" page at /about and a link to it in the navigation.
  2. Add a level filter link for "intermediate" and show a message "No courses" when nothing matches.
  3. Make CourseDetail look the slug up in the courses list (move the list to its own file) and show the not-found message for an unknown slug.
  4. Add a /login page and a protected /account route that redirects to /login unless a loggedIn state is true.

Key points

  • A single-page app changes what it shows when the URL changes; a router matches the URL to components without reloading the page.
  • Install react-router, wrap the app in BrowserRouter, and describe pages with Routes and Route elements; use Link and NavLink instead of a tags.
  • A route with a path like courses/:slug passes the dynamic part to useParams; query strings are read and written with useSearchParams.
  • Nested routes share a layout through Outlet; a path of * catches unknown URLs, and your static host must send every URL to index.html.

Check yourself

1. Why use <Link to='/courses'> instead of <a href='/courses'>?
2. What does useParams() return for the route 'courses/:slug' at /courses/react?
3. What is the Outlet component for?
4. A user opens /courses/react directly on your static server and sees a 404. Why?