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:
npm install react-routerWrap the app in a router in src/main.jsx. BrowserRouter uses the browser's History API so the URLs look normal (/courses), with no #:
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:
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:
indexmarks the default child route, shown at the parent's own URL (/).path="courses"matches/courses.path="courses/:slug"matches/courses/react,/courses/htmland 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
Routewith nopath, only anelement, 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:
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.
Links: never use plain a tags for internal 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:
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:
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:
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:
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:
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 (
createBrowserRouterandRouterProvider) 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
BrowserRouterinside the component that usesuseNavigateorLink(the router must be an ancestor). - Forgetting
endon the HomeNavLink, 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-domandreact-routerin the same project at different versions.
Exercise
- Add an "About" page at
/aboutand a link to it in the navigation. - Add a
levelfilter link for "intermediate" and show a message "No courses" when nothing matches. - Make
CourseDetaillook the slug up in the courses list (move the list to its own file) and show the not-found message for an unknown slug. - Add a
/loginpage and a protected/accountroute that redirects to/loginunless aloggedInstate 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.