LearnAI ToolsCareerPractice BuildsPlayContact
React JSIntermediate~2.5 hours

Multi-Page Blog

Build a blog with routing between a post list and detail pages.

React RouterComponentsProps

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

What You'll Build
  • 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.jsx
import { 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.js
export 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.jsx
import { 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.jsx
import { 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('/')}>
&larr; 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.js
export 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.jsx
import { 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.jsx
import { 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('/')}>
&larr; 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.jsx
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;

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

Live Preview

Sample Run

Sample Interaction

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.