So far every React app has been a single screen. Real applications have many: a home page, a list, a detail view, a settings page, a 404. You could swap components with a page state variable — until the back button breaks, URLs stop being shareable, and nothing is bookmarkable.
Client-side routing fixes all of that. The URL becomes the state: a route renders the matching component, the browser's back button works, and every screen has an address you can send to a friend.
Setup
npm install react-routerWrap your app once, in main.jsx:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)BrowserRouter listens to the History API and re-renders your app when the URL changes — no page reloads.
Routes, links, and layout
Define the route table in App.jsx:
import { Routes, Route } from 'react-router'
import Layout from './Layout.jsx'
import Home from './pages/Home.jsx'
import Posts from './pages/Posts.jsx'
import PostDetail from './pages/PostDetail.jsx'
import NotFound from './pages/NotFound.jsx'
export default function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<Home />} />
<Route path="posts" element={<Posts />} />
<Route path="posts/:id" element={<PostDetail />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
)
}indexmatches the parent path itself (/).path="posts/:id"declares a dynamic segment namedid.path="*"catches everything unmatched — your 404.- Roads that share a layout are nested under a parent
<Route element={<Layout />}>.
Layout.jsx renders the shared chrome and an Outlet, where the matched child route appears:
import { NavLink, Outlet } from 'react-router'
export default function Layout() {
return (
<div className="app">
<header>
<nav>
<NavLink to="/" end>Home</NavLink>
<NavLink to="/posts">Posts</NavLink>
</nav>
</header>
<main>
<Outlet />
</main>
</div>
)
}Navigation uses Link or NavLink — never a plain <a href>, which would trigger a full page reload:
import { Link, NavLink } from 'react-router'
<Link to="/posts/42">Read post 42</Link>
<NavLink
to="/posts"
className={({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')}
>
Posts
</NavLink>NavLink gives you the isActive flag so current-page styling needs no extra state.
Dynamic route params
useParams reads the values from the URL:
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router'
export default function PostDetail() {
const { id } = useParams()
const [post, setPost] = useState(null)
const [status, setStatus] = useState('loading')
useEffect(() => {
setStatus('loading')
const controller = new AbortController()
async function load() {
try {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
signal: controller.signal,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
setPost(await response.json())
setStatus('ready')
} catch (error) {
if (error.name !== 'AbortError') setStatus('error')
}
}
load()
return () => controller.abort()
}, [id]) // re-fetch when the URL id changes
if (status === 'loading') return <p>Loading…</p>
if (status === 'error') return <p>Could not load this post.</p>
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<Link to="/posts">Back to all posts</Link>
</article>
)
}The id in the dependency array is the whole point: navigate from /posts/1 to /posts/2 and the effect re-runs, because React Router re-renders the same component with a new param.
Query strings as state
useSearchParams reads and writes the query string — perfect for filters, tabs, and pagination, because the state lives in the URL and survives reloads and sharing:
import { useSearchParams } from 'react-router'
export default function Posts() {
const [searchParams, setSearchParams] = useSearchParams()
const page = Number(searchParams.get('page') ?? 1)
const query = searchParams.get('q') ?? ''
function setQuery(next) {
setSearchParams((current) => {
const params = new URLSearchParams(current)
if (next) params.set('q', next)
else params.delete('q')
params.set('page', '1') // changing a filter resets pagination
return params
})
}
return (
<section>
<input value={query} onChange={(event) => setQuery(event.target.value)} />
<p>Page {page}</p>
<button onClick={() => setSearchParams({ page: String(page + 1) })}>
Next page
</button>
</section>
)
}Put state in the URL when the user would expect to share it, bookmark it, or reload into it: search terms, filters, sorting, pagination, selected tabs. Keep UI-only state (an open dropdown) in useState.
Redirecting
useNavigate navigates from code — after a successful login or form submit:
import { useNavigate } from 'react-router'
export default function NewPostForm() {
const navigate = useNavigate()
async function handleSubmit(event) {
event.preventDefault()
// ...create the post...
navigate('/posts/42') // push a new entry
// navigate('/posts', { replace: true }) // replace the current entry
}
return <form onSubmit={handleSubmit}>{/* ... */}</form>
}The Navigate component redirects during render, which is how protected routes work.
Protected routes
A protected route checks auth before rendering the page and redirects to login if needed. First, an auth context (the next lesson covers context in depth):
// AuthContext.jsx
import { createContext, useContext, useMemo, useState } from 'react'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const value = useMemo(
() => ({
user,
login: (name) => setUser({ name }),
logout: () => setUser(null),
}),
[user]
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth must be used inside AuthProvider')
return context
}Then a wrapper that remembers where the user was headed:
import { Navigate, useLocation } from 'react-router'
import { useAuth } from './AuthContext.jsx'
export function RequireAuth({ children }) {
const { user } = useAuth()
const location = useLocation()
if (!user) {
// Remember the attempted URL so login can send the user back
return <Navigate to="/login" state={{ from: location }} replace />
}
return children
}<Route
path="dashboard"
element={
<RequireAuth>
<Dashboard />
</RequireAuth>
}
/>The login page can read location.state?.from and navigate back after a successful sign-in. Using replace on the redirect keeps the protected URL out of the back-button history, so “back” from login does not immediately bounce the user forward again.
Data routers: loaders and actions
React Router also ships a “data router” mode where a route declares how to load and mutate its data, and the router handles pending and error states:
import { createBrowserRouter, RouterProvider, useLoaderData } from 'react-router'
async function postsLoader() {
const response = await fetch('/api/posts')
if (!response.ok) throw new Error('Failed to load posts')
return response.json()
}
function Posts() {
const posts = useLoaderData()
return <ul>{posts.map((post) => <li key={post.id}>{post.title}</li>)}</ul>
}
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Home /> },
{ path: 'posts', element: <Posts />, loader: postsLoader },
],
},
])
createRoot(document.getElementById('root')).render(<RouterProvider router={router} />)Loaders fetch before rendering, so components do not need loading states for the initial data. TanStack Query (a later lesson) solves the same problem with caching and background refetches; both are worth knowing. For this curriculum, start with the component-level pattern above, then adopt a data layer as your apps grow.
Compare the pieces
| Tool | Use it for |
|---|---|
Link / NavLink | Navigation in markup; never <a href> for internal links |
useParams | Read dynamic segments like /posts/:id |
useSearchParams | Read/write filters, pagination, and shareable state |
useNavigate | Navigate after an event (login, form submit) |
Navigate | Redirect during render (guards) |
Outlet | Render the matched child route inside a layout |
useLocation | Read the current location, including state |
Try it
Scaffold a Vite app, install react-router, replace main.jsx and App.jsx with this pattern, and click through the links.
// App.jsx
import { Routes, Route, NavLink, Outlet } from 'react-router'
function Layout() {
return (
<div style={{ fontFamily: 'system-ui', padding: 24 }}>
<nav style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<NavLink to="/" end>Home</NavLink>
<NavLink to="/about">About</NavLink>
</nav>
<Outlet />
</div>
)
}
export default function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<h1>Welcome home</h1>} />
<Route path="about" element={<p>This app has real URLs now.</p>} />
<Route path="*" element={<h1>404 — page not found</h1>} />
</Route>
</Routes>
)
}Check your understanding
A product page lives at /products/:id. How does the component read the id?
What does the Outlet component do in a layout route?
Build a routed blog reader
Create a Vite app with React Router that reads from the free JSONPlaceholder API.
- Set up
BrowserRouterand aLayoutwith a header, nav links (Home, Posts), and anOutlet. Style active links withNavLink'sisActive. - Pages:
/— a home page with a short intro and a link to the posts./posts— fetcheshttps://jsonplaceholder.typicode.com/posts, renders a list of links to/posts/:id./posts/:id— fetches one post, showstitleandbody, and links back to the list.
- Add a search field on
/poststhat stores the query in the URL withuseSearchParams, filters the list during render, and resets to page 1 when the query changes. Reloading the page must preserve the filter. - Add
/dashboardwrapped in aRequireAuthguard that redirects to/loginwhen no user is signed in. The login page has a button that signs in (fake it withuseStatein context) and sends the user back to the page they came from. - Add a catch-all 404 page with a link home.
- Bonus: add a "Next page" button on
/poststhat storespagein the URL and slices the fetched list. - Bonus: convert the posts routes to
createBrowserRouterwith a loader so/postsdata is fetched before render, and add anerrorElement.
Summary
- Client-side routing gives your SPA real URLs with working back/forward, bookmarks, and deep links.
BrowserRouter+Routes/Routemap URLs to components;Link/NavLinknavigate without reloading.- Nested routes with a layout and
Outletkeep shared chrome mounted across pages. useParamsreads dynamic segments; re-fetch when they change by putting them in the effect's dependencies.useSearchParamsmakes filters and pagination shareable and reload-safe.useNavigatefor navigation after events;Navigatefor redirects during render.- Protected routes improve UX; the server remains the real security boundary.
- Data routers with loaders fetch before render — a signpost toward TanStack Query.
Next: state management — deciding where shared state lives, from Context to Zustand to Redux Toolkit.