Skip to main content

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
info

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
info

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.

ModeCharacteristicSuited for
DeclarativeWrite <BrowserRouter> + <Routes> + <Route> in JSX. Almost the same notation as v6 and earlierSmall SPAs, learning purposes, migration from an existing project
DatacreateBrowserRouter + loader / action move data fetching and updates to the router sideA full-fledged SPA that involves data fetching (most production projects)
FrameworkSSR / SSG / file-based routing / type generation enabled via a Vite pluginWhen you want to build a full-stack app as an alternative to Next.js
info

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.

warning

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.

RouterURL formatUse
BrowserRouter/aboutA general web app (recommended)
HashRouter/#/aboutAn environment where you cannot configure the server
MemoryRouterNoneFor 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>
)
}
import { Link } from 'react-router'

function Navigation() {
return (
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/users">Users</Link>
</nav>
)
}
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>
}
warning

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
  1. Map paths to components with Routes and Route
  2. Create navigation with Link
  3. 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
  1. Specify a dynamic segment like :userId in the route definition
  2. Retrieve its value with useParams
  3. 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
  1. Set <Layout /> as the element of the parent route
  2. The child route's component is rendered at the position of <Outlet />
  3. Use the end prop of NavLink to 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>
}

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).

info

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

info

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.

info

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 Routes and Route
  • 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.

We move on to advanced topics. We cover advanced topics such as performance optimization, debugging, class components, and an introduction to Next.js.