Routing (React Router)
In this chapter, you learn SPA routing with React Router.
What you learn in this chapter
- React Router basics (Routes, Route, Link)
- Dynamic routes and retrieving parameters
- Nested routes and Outlet
- Protected routes and redirects
This chapter uses the project created in Chapter 3. Start the dev server with npm run dev and learn while writing code.
What is React Router
React Router is a library that implements client-side routing in React applications.
SPAs and client-side routing
In traditional websites, HTML was fetched from the server on every page navigation. In an SPA (Single Page Application), you load one HTML at first, and subsequent page navigations are handled with JavaScript.
Traditional website:
/home → server request → home.html
/about → server request → about.html
SPA:
/home → switch components with JavaScript
/about → switch components with JavaScript (no request)
React Router implements this mechanism by detecting URL changes and switching React components.
Setup
npm install react-router
This book uses React Router v7 (released November 2024, 7.17.0 at the time of writing). In v7, the package was unified into react-router, so the react-router-dom installation found in v6-and-earlier tutorials is unnecessary.
The three modes of React Router v7
React Router v7 lets you use three modes depending on your purpose. All modes are provided from the same react-router package, so you can migrate your code gradually.
| Mode | Characteristic | Suited for |
|---|---|---|
| Declarative | Write <BrowserRouter> + <Routes> + <Route> in JSX. Almost the same notation as v6 and earlier | Small SPAs, learning purposes, migration from an existing project |
| Data | createBrowserRouter + loader / action move data fetching and updates to the router side | A full-fledged SPA that involves data fetching (most production projects) |
| Framework | SSR / SSG / file-based routing / type generation enabled via a Vite plugin | When you want to build a full-stack app as an alternative to Next.js |
In this chapter, you learn the basics in Declarative mode, and in the second half cover the Data mode (createBrowserRouter). Framework mode plays the same role as Next.js (Chapter 25), so it is out of scope for this SPA-focused book.
For a new SPA, declaring data fetching on the router side with Data mode's loader is recommended over writing data fetching with useEffect. The router-side API achieves parallel fetching at the initial display, avoiding the waterfall problem.
Basic usage
Configuring the router
// main.tsx
import { BrowserRouter } from 'react-router'
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<App />
</BrowserRouter>
)
BrowserRouter vs HashRouter
React Router has multiple kinds of routers.
| Router | URL format | Use |
|---|---|---|
BrowserRouter | /about | A general web app (recommended) |
HashRouter | /#/about | An environment where you cannot configure the server |
MemoryRouter | None | For testing |
When using BrowserRouter, you need to configure the server side so that all paths point to index.html (the Vite dev server handles this automatically).
Defining routes
// App.tsx
import { Routes, Route } from 'react-router'
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/users" element={<Users />} />
<Route path="*" element={<NotFound />} />
</Routes>
)
}
Navigation
The Link component
import { Link } from 'react-router'
function Navigation() {
return (
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/users">Users</Link>
</nav>
)
}
NavLink (with an active state)
import { NavLink } from 'react-router'
function Navigation() {
return (
<nav>
<NavLink
to="/"
className={({ isActive }) =>
isActive ? 'text-blue-500 font-bold' : 'text-gray-600'
}
>
Home
</NavLink>
</nav>
)
}
Programmatic navigation
import { useNavigate } from 'react-router'
function LoginForm() {
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await login()
navigate('/dashboard') // go to the dashboard after login
}
return <form onSubmit={handleSubmit}>...</form>
}
Using an <a href="..."> tag directly reloads the entire page and loses the SPA benefits. Always use Link or useNavigate for in-app navigation.
Try it: basic routing
Create three pages — Home, About, and Contact — and make them switchable with navigation.
Hint
- Map paths to components with
RoutesandRoute - Create navigation with
Link - Add a NotFound page with
path="*"
Answer and explanation
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)
// src/App.tsx
import { Routes, Route, Link } from 'react-router'
function Home() {
return <h1 className="text-2xl font-bold">Home page</h1>
}
function About() {
return <h1 className="text-2xl font-bold">About page</h1>
}
function Contact() {
return <h1 className="text-2xl font-bold">Contact page</h1>
}
function NotFound() {
return <h1 className="text-2xl font-bold text-red-500">404 - Page not found</h1>
}
function App() {
return (
<div className="p-8">
<nav className="mb-8 space-x-4">
<Link to="/" className="text-blue-500 hover:underline">Home</Link>
<Link to="/about" className="text-blue-500 hover:underline">About</Link>
<Link to="/contact" className="text-blue-500 hover:underline">Contact</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
)
}
export default App
Wrap the entire app with BrowserRouter and define Routes inside Routes. Using the Link component, you can navigate within the SPA without reloading the entire page. path="*" is the fallback when none of the other paths match.
Dynamic routes
Retrieving parameters
import { useParams } from 'react-router'
// Route definition
<Route path="/users/:userId" element={<UserDetail />} />
// Component
function UserDetail() {
const { userId } = useParams<{ userId: string }>()
return <h1>User ID: {userId}</h1>
}
Query parameters
import { useSearchParams } from 'react-router'
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams()
const page = searchParams.get('page') || '1'
const sort = searchParams.get('sort') || 'newest'
const updatePage = (newPage: number) => {
setSearchParams({ page: String(newPage), sort })
}
const handleNextPage = () => {
updatePage(Number(page) + 1)
}
return (
<div>
<p>Page: {page}, Sort: {sort}</p>
<button onClick={handleNextPage}>
Next page
</button>
</div>
)
}
Try it: dynamic routes
Create a /users/:userId route, retrieve the user ID with useParams, and display it.
Hint
- Specify a dynamic segment like
:userIdin the route definition - Retrieve its value with
useParams - Be type-safe with the type argument
<{ userId: string }>
Answer and explanation
// src/App.tsx
import { Routes, Route, Link, useParams } from 'react-router'
function UserList() {
const users = [
{ id: 1, name: 'Taro Tanaka' },
{ id: 2, name: 'Hanako Yamada' },
{ id: 3, name: 'Ichiro Suzuki' }
]
return (
<div>
<h1 className="text-2xl font-bold mb-4">User list</h1>
<ul className="space-y-2">
{users.map(user => (
<li key={user.id}>
<Link
to={`/users/${user.id}`}
className="text-blue-500 hover:underline"
>
{user.name}
</Link>
</li>
))}
</ul>
</div>
)
}
function UserDetail() {
const { userId } = useParams<{ userId: string }>()
// In a real app, fetch the user information from an API
const userNames: Record<string, string> = {
'1': 'Taro Tanaka',
'2': 'Hanako Yamada',
'3': 'Ichiro Suzuki'
}
const userName = userId ? userNames[userId] : 'Unknown'
return (
<div>
<h1 className="text-2xl font-bold mb-4">User detail</h1>
<p className="mb-2">User ID: {userId}</p>
<p className="mb-4">Name: {userName}</p>
<Link to="/users" className="text-blue-500 hover:underline">
← Back to list
</Link>
</div>
)
}
function App() {
return (
<div className="p-8">
<nav className="mb-8">
<Link to="/" className="text-blue-500 hover:underline mr-4">Home</Link>
<Link to="/users" className="text-blue-500 hover:underline">Users</Link>
</nav>
<Routes>
<Route path="/" element={<h1 className="text-2xl font-bold">Home</h1>} />
<Route path="/users" element={<UserList />} />
<Route path="/users/:userId" element={<UserDetail />} />
</Routes>
</div>
)
}
export default App
A segment starting with a colon, like :userId, is a dynamic parameter. The value retrieved with the useParams hook is always of string type, so when using it as a number, convert it with Number(userId).
Nested routes
// App.tsx
function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="users" element={<UsersLayout />}>
<Route index element={<UserList />} />
<Route path=":userId" element={<UserDetail />} />
</Route>
</Route>
</Routes>
)
}
// Layout.tsx
import { Outlet } from 'react-router'
function Layout() {
return (
<div>
<Header />
<main>
<Outlet /> {/* child routes are rendered here */}
</main>
<Footer />
</div>
)
}
Try it: nested routes
Create a Layout component with a shared layout (Header + Footer) and render child routes with Outlet.
Hint
- Set
<Layout />as theelementof the parent route - The child route's component is rendered at the position of
<Outlet /> - Use the
endprop ofNavLinkto be active only on an exact match
Answer and explanation
// src/components/Layout.tsx
import { Outlet, NavLink } from 'react-router'
export function Layout() {
const linkClass = ({ isActive }: { isActive: boolean }) =>
`px-4 py-2 rounded ${isActive ? 'bg-blue-500 text-white' : 'text-gray-600 hover:bg-gray-100'}`
return (
<div className="min-h-screen flex flex-col">
<header className="bg-white shadow">
<nav className="max-w-4xl mx-auto px-4 py-4 flex gap-2">
<NavLink to="/" className={linkClass} end>
Home
</NavLink>
<NavLink to="/about" className={linkClass}>
About
</NavLink>
<NavLink to="/contact" className={linkClass}>
Contact
</NavLink>
</nav>
</header>
<main className="flex-1 max-w-4xl mx-auto px-4 py-8 w-full">
<Outlet />
</main>
<footer className="bg-gray-100 py-4 text-center text-gray-600">
© {new Date().getFullYear()} My App
</footer>
</div>
)
}
// src/pages/Home.tsx
export function Home() {
return (
<div>
<h1 className="text-3xl font-bold mb-4">Welcome to the home page</h1>
<p className="text-gray-600">
This is a sample SPA built with React Router.
</p>
</div>
)
}
// src/pages/About.tsx
export function About() {
return (
<div>
<h1 className="text-3xl font-bold mb-4">About</h1>
<p className="text-gray-600">
This is a page describing this application.
</p>
</div>
)
}
// src/pages/Contact.tsx
export function Contact() {
return (
<div>
<h1 className="text-3xl font-bold mb-4">Contact</h1>
<p className="text-gray-600">
Please get in touch with us here.
</p>
</div>
)
}
// src/App.tsx
import { Routes, Route } from 'react-router'
import { Layout } from './components/Layout'
import { Home } from './pages/Home'
import { About } from './pages/About'
import { Contact } from './pages/Contact'
function NotFound() {
return (
<div>
<h1 className="text-3xl font-bold text-red-500 mb-4">404</h1>
<p>Page not found.</p>
</div>
)
}
function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="contact" element={<Contact />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
)
}
export default App
In nested routes, the child route's component is rendered at the position of the parent route's <Outlet />. The end prop of NavLink makes it active only on an exact match (so that / does not stay active on /about and the like). Consolidating the shared layout in one place avoids code duplication.
Protected routes
import { Navigate, useLocation } from 'react-router'
type ProtectedRouteProps = {
children: React.ReactNode
}
function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated } = useAuth()
const location = useLocation()
if (!isAuthenticated) {
// Redirect to the login page (saving the original URL)
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}
// Usage
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
Redirect after login
function LoginPage() {
const navigate = useNavigate()
const location = useLocation()
const from = location.state?.from?.pathname || '/'
const handleLogin = async () => {
await login()
navigate(from, { replace: true }) // return to the original page
}
return <button onClick={handleLogin}>Log in</button>
}
Data loaders (recommended pattern)
In Data mode (createBrowserRouter), you can set a loader on a route (introduced in v6.4, it became a core feature of Data mode in v7).
A data loader is a pattern for fetching data before a component is rendered. It can also be used in combination with TanStack Query (Chapter 20).
import { createBrowserRouter, useLoaderData } from 'react-router'
import type { LoaderFunctionArgs } from 'react-router'
// Only RouterProvider is imported from 'react-router/dom' (the react-dom integration version)
import { RouterProvider } from 'react-router/dom'
// Loader function
async function userLoader({ params }: LoaderFunctionArgs) {
const response = await fetch(`/api/users/${params.userId}`)
if (!response.ok) throw new Error('User not found')
return response.json()
}
// Router configuration
const router = createBrowserRouter([
{
path: '/users/:userId',
element: <UserDetail />,
loader: userLoader,
errorElement: <ErrorPage />
}
])
// Component
function UserDetail() {
const user = useLoaderData() as User
return <h1>{user.name}</h1>
}
// main.tsx
createRoot(document.getElementById('root')!).render(
<RouterProvider router={router} />
)
Loading state
useNavigation is a hook specific to Data mode (createBrowserRouter + RouterProvider). It cannot be used with the <BrowserRouter> setup from the first half.
import { useNavigation } from 'react-router'
function App() {
const navigation = useNavigation()
return (
<div>
{navigation.state === 'loading' && <LoadingBar />}
<Outlet />
</div>
)
}
Error handling
import { useRouteError, isRouteErrorResponse } from 'react-router'
function ErrorPage() {
const error = useRouteError()
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status}</h1>
<p>{error.statusText}</p>
</div>
)
}
return (
<div>
<h1>An error occurred</h1>
<p>{error instanceof Error ? error.message : 'Unknown error'}</p>
</div>
)
}
Managing the page title (React 19's Document Metadata)
In an SPA, the browser tab title does not change when the URL changes, so you need to update document.title per route. In React 19, this becomes concise because simply writing <title> / <meta> / <link> in JSX automatically hoists them into <head>.
// Just write it directly inside the page component, and it works
function AboutPage() {
return (
<>
<title>About us | MyApp</title>
<meta name="description" content="The company information page for MyApp" />
<h1>About us</h1>
<p>...</p>
</>
)
}
Before React 19, you used libraries such as react-helmet-async, but for a new SPA, React 19's built-in feature alone is sufficient.
A note on SSR (Next.js): Next.js has a dedicated metadata API (explained in Chapter 25), whose functionality overlaps with React 19's Document Metadata. Therefore, on the Next.js side, using metadata / generateMetadata is the default. The Next.js docs also recommend "use the metadata export for per-page metadata."
Summary
- React Router is the de facto standard for client-side routing
- Define routes with
RoutesandRoute - Navigate with
Link/NavLink - Retrieve URL parameters with
useParams - Manage query parameters with
useSearchParams - Render nested routes with
Outlet - Control authentication with protected routes
- Fetch data declaratively with loaders
- In React 19, just writing
<title>/<meta>directly in JSX reflects them into<head>
In the next chapter, you will learn about performance optimization.
What to read next
We move on to advanced topics. We cover advanced topics such as performance optimization, debugging, class components, and an introduction to Next.js.