Overview
Every React app so far in this course has been a single view. A blog is the natural project for introducing multiple pages, because it has an obvious two-level structure: a list page showing every post, and a detail page showing one post in full — and clicking a post has to change the URL, not just swap out some local state, so the browser's back button, refresh, and shareable links all keep working correctly.
This tutorial uses `react-router-dom`, the standard routing library for React, to handle that. `<BrowserRouter>` watches the address bar, `<Routes>`/`<Route>` decide which page component to render for the current URL, `<Link>` changes the URL without a full page reload, `useParams` reads dynamic segments like a post's id out of the URL, and `useNavigate` triggers a route change from code (for a "Back" button, for example).
- A `PostList` page that renders every post as a preview card linking to its own URL.
- A `PostDetail` page reached at `/posts/:postId`, showing one post's full content.
- Real client-side navigation with `<Link>` — no full-page reloads between the list and a post.
- `useParams` to read which post to display straight out of the current URL.
- A "Back to all posts" button built with `useNavigate` for imperative navigation.
- A route configuration in `App` mapping each URL pattern to its page component.
Prerequisites
- JSX, function components, and props — covered in the Todo App and Shopping Cart projects above.
- Array methods — `find` and `map`, used to look up and list posts.
- Basic familiarity with URLs — paths, and dynamic segments like `:postId`.
- React Router itself is new here — every hook and component it provides is introduced from scratch in this tutorial.
Project Structure
Post data lives in its own file as a plain array, since — like the product catalog in the Shopping Cart project — it does not change while the app runs. `PostList` and `PostDetail` are two separate page components, each rendered by a `<Route>` in `App`, and each receiving the data it needs by looking it up from the shared `POSTS` array rather than through props passed down a component tree.
src/ posts.js // Static array of blog posts PostList.jsx // "/" — every post as a preview card, each linking to its detail page PostDetail.jsx // "/posts/:postId" — one post's full content, looked up by the URL param App.jsx // BrowserRouter + Routes mapping URLs to the two page components index.css // Shared dark-theme styling* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; background: #1a1d23; color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px;}
.blog-app { width: 100%; max-width: 560px; }.blog-app h1 { margin-bottom: 20px; font-size: 1.6rem; }
.post-preview { background: #23262e; border-radius: 8px; padding: 16px 18px; margin-bottom: 14px;}
.post-preview h2 { font-size: 1.1rem; margin-bottom: 6px; }.post-preview a { color: #e8e8e8; text-decoration: none; }.post-preview a:hover { color: #4f8cff; }.post-author { color: #9aa4b2; font-size: 0.85rem; margin-top: 6px; }
.post-detail { background: #23262e; border-radius: 8px; padding: 20px; }.post-detail h1 { font-size: 1.4rem; margin-bottom: 8px; }.post-detail p { line-height: 1.6; margin-top: 10px; }
.back-btn { background: transparent; border: 1px solid #3a3f4b; color: #9aa4b2; border-radius: 6px; padding: 6px 12px; cursor: pointer; margin-bottom: 16px;}Step 1: Install and Set Up React Router
`react-router-dom` is a separate package from React itself, installed the normal way with npm. `<BrowserRouter>` has to wrap everything that needs routing — it is what listens to the browser's address bar and makes `<Link>`, `useParams`, and `useNavigate` work anywhere inside it.
npm install react-router-dom// App.jsximport { BrowserRouter, Routes, Route } from 'react-router-dom';
function App() { return ( // BrowserRouter must wrap anything that uses routing features — it's // what listens to the browser's URL and re-renders the matching Route <BrowserRouter> <div className="blog-app"> <Routes> {/* individual <Route> entries are added in Step 6, once PostList and PostDetail exist to point them at */} </Routes> </div> </BrowserRouter> );}
export default App;Step 2: Define the Posts Data and Route Plan
Each post gets a URL-friendly `id` string (not just a numeric index) so links stay meaningful and stable even if posts are reordered or added later. Sketching the two routes out in a comment before writing any component code is worth doing deliberately — it is what decides what each page component needs to read from the URL.
// posts.jsexport const POSTS = [ { id: 'react-hooks', title: 'Understanding React Hooks', excerpt: 'A practical tour of useState and useEffect, and when to reach for each one.', body: 'Hooks let function components hold state and run side effects without ever writing a class. useState gives a component memory between renders, and useEffect lets it synchronize with anything outside React itself, from a timer to a network request.', author: 'Dana Lee', }, { id: 'css-grid', title: 'CSS Grid in 10 Minutes', excerpt: 'The handful of Grid properties that cover almost every real layout.', body: 'CSS Grid turns layout into a two-dimensional problem you describe once on a container, instead of a chain of floats or flex wrappers. grid-template-columns and gap alone solve most everyday page layouts.', author: 'Sam Ortiz', }, { id: 'js-async', title: 'Async JavaScript Explained', excerpt: 'How Promises, async/await, and the event loop actually fit together.', body: 'JavaScript runs on a single thread, so async/await is really just readable syntax sitting on top of Promises and the event loop — the mental model of "callbacks queued for later" never goes away, async/await just makes it look sequential.', author: 'Priya Nair', },];
/*Route plan: "/" -> PostList (every post's title + excerpt, each linking out) "/posts/:postId" -> PostDetail (one post's full body, looked up by the :postId URL segment)*/Step 3: Build the Post List Page
`<Link to="...">` renders an `<a>` under the hood, but intercepts the click to swap the page's content through React Router instead of asking the browser to fully reload — that is what keeps the app feeling instant while still producing a real, bookmarkable URL for every post.
// PostList.jsximport { Link } from 'react-router-dom';import { POSTS } from './posts';
function PostList() { return ( <div className="post-list"> <h1>Blog</h1> {POSTS.map((post) => ( <article className="post-preview" key={post.id}> <h2> {/* Link swaps the rendered page without a full browser reload — a plain <a href="..."> would re-download the whole app */} <Link to={'/posts/' + post.id}>{post.title}</Link> </h2> <p>{post.excerpt}</p> <p className="post-author">By {post.author}</p> </article> ))} </div> );}
export default PostList;Step 4: Build the Post Detail Page
`useParams()` reads the dynamic part of the current URL. For a route declared as `/posts/:postId`, visiting `/posts/css-grid` makes `useParams()` return `{ postId: 'css-grid' }` — that value is then used to look the matching post up out of the same shared `POSTS` array `PostList` already uses.
// PostDetail.jsximport { useParams } from 'react-router-dom';import { POSTS } from './posts';
function PostDetail() { const { postId } = useParams(); // pulls the :postId segment straight out of the current URL const post = POSTS.find((p) => p.id === postId);
if (!post) { // Handles a mistyped or stale URL gracefully instead of crashing on post.title below return <p>Post not found.</p>; }
return ( <article className="post-detail"> <h1>{post.title}</h1> <p className="post-author">By {post.author}</p> <p>{post.body}</p> </article> );}
export default PostDetail;Step 5: Navigate Back with useNavigate
`<Link>` is the right tool when the user clicks something that looks and behaves like a link. `useNavigate` is for triggering a route change from inside a function — here, a plain `<button>` that calls `navigate('/')` when clicked, which is a more natural fit than styling a `<Link>` to look like a button.
// PostDetail.jsx (updated)import { useParams, useNavigate } from 'react-router-dom';import { POSTS } from './posts';
function PostDetail() { const { postId } = useParams(); const navigate = useNavigate(); // imperative navigation — call it from code, not by rendering a <Link> const post = POSTS.find((p) => p.id === postId);
if (!post) { return <p>Post not found.</p>; }
return ( <article className="post-detail"> <button type="button" className="back-btn" onClick={() => navigate('/')}> ← Back to all posts </button> <h1>{post.title}</h1> <p className="post-author">By {post.author}</p> <p>{post.body}</p> </article> );}
export default PostDetail;Step 6: Wire Up the Routes in App
Each `<Route>` pairs a URL pattern with the component to render for it. React Router matches the current browser URL against every `path` here and renders the matching `element` — `:postId` in the second route is a placeholder that matches any value in that URL segment, which is exactly what `useParams` reads back out inside `PostDetail`.
// App.jsx (final)import { BrowserRouter, Routes, Route } from 'react-router-dom';import PostList from './PostList';import PostDetail from './PostDetail';
function App() { return ( <BrowserRouter> <div className="blog-app"> <Routes> <Route path="/" element={<PostList />} /> <Route path="/posts/:postId" element={<PostDetail />} /> </Routes> </div> </BrowserRouter> );}
export default App;Complete Code
Here is the complete project — every file, fully assembled from the steps above, using real `react-router-dom` exactly as you would install and use it in a bundled app.
// posts.jsexport const POSTS = [ { id: 'react-hooks', title: 'Understanding React Hooks', excerpt: 'A practical tour of useState and useEffect, and when to reach for each one.', body: 'Hooks let function components hold state and run side effects without ever writing a class.', author: 'Dana Lee', }, { id: 'css-grid', title: 'CSS Grid in 10 Minutes', excerpt: 'The handful of Grid properties that cover almost every real layout.', body: 'CSS Grid turns layout into a two-dimensional problem you describe once on a container.', author: 'Sam Ortiz', }, { id: 'js-async', title: 'Async JavaScript Explained', excerpt: 'How Promises, async/await, and the event loop actually fit together.', body: 'JavaScript runs on a single thread, so async/await is readable syntax sitting on top of Promises.', author: 'Priya Nair', },];// PostList.jsximport { Link } from 'react-router-dom';import { POSTS } from './posts';
function PostList() { return ( <div className="post-list"> <h1>Blog</h1> {POSTS.map((post) => ( <article className="post-preview" key={post.id}> <h2> <Link to={'/posts/' + post.id}>{post.title}</Link> </h2> <p>{post.excerpt}</p> <p className="post-author">By {post.author}</p> </article> ))} </div> );}
export default PostList;// PostDetail.jsximport { useParams, useNavigate } from 'react-router-dom';import { POSTS } from './posts';
function PostDetail() { const { postId } = useParams(); const navigate = useNavigate(); const post = POSTS.find((p) => p.id === postId);
if (!post) { return <p>Post not found.</p>; }
return ( <article className="post-detail"> <button type="button" className="back-btn" onClick={() => navigate('/')}> ← Back to all posts </button> <h1>{post.title}</h1> <p className="post-author">By {post.author}</p> <p>{post.body}</p> </article> );}
export default PostDetail;// App.jsximport { BrowserRouter, Routes, Route } from 'react-router-dom';import PostList from './PostList';import PostDetail from './PostDetail';
function App() { return ( <BrowserRouter> <div className="blog-app"> <Routes> <Route path="/" element={<PostList />} /> <Route path="/posts/:postId" element={<PostDetail />} /> </Routes> </div> </BrowserRouter> );}
export default App;`react-router-dom`'s `<BrowserRouter>` needs the browser's real History API, which is awkward to get working inside a sandboxed preview iframe. The live preview below reimplements just the "current page" switching with a `useState` holding the current view and post id, driven by the exact same `POSTS` data and the exact same page components' logic — everything about how `PostList` and `PostDetail` actually render is unchanged; only the mechanism that decides which one is showing is simplified, standing in for what `<Routes>`/`<Route>`/`useNavigate` would do in the real, bundled app above.
Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a catch-all `<Route path="*" element={<NotFound />} />` for any URL that does not match the other routes.
- Add a category filter using a query string (e.g. `/?category=react`) read with `useSearchParams`.
- Wrap `PostList` and `PostDetail` in a shared layout (header/footer) using a parent route and React Router's `<Outlet />`.
- Add a simple "New Post" form at `/new` that calls `navigate('/posts/' + newPost.id)` after submitting.
- Highlight the currently active link in a nav bar using `<NavLink>` instead of `<Link>`, which adds an "active" class automatically.
Summary
You built a multi-page React app using `react-router-dom` to map URLs to page components, `<Link>` for real, no-reload navigation, `useParams` to read a post's id out of the current URL, and `useNavigate` for navigation triggered from code. This routing pattern — a list page linking into detail pages identified by a URL param — is the backbone of nearly every content-driven React app you'll build beyond this course, from blogs to e-commerce product pages to documentation sites.