Post Detail Page
The post detail page is the center of Bulletin’s content — it shows the full post, who wrote it, when, and all the comments below it. You fetch both the post and its comments in one request.
The getPost API call
Section titled “The getPost API call”// src/api/postsApi.ts (add to existing file)export interface Comment { id: number body: string userId: number username: string createdAt: string}
export interface PostDetail extends Post { comments: Comment[]}
export async function getPost(id: number): Promise<PostDetail> { const res = await apiClient.get<PostDetail>(`/posts/${id}`) return res.data}PostDetail extends Post and adds a comments array — the same shape your Express endpoint returns.
Reading the route param
Section titled “Reading the route param”React Router’s useParams hook pulls the :id out of the URL:
import { useParams } from 'react-router-dom'
const { id } = useParams<{ id: string }>()The param is always a string even though your database uses numbers. Parse it before passing it to the API:
const postId = Number(id)The PostDetail page
Section titled “The PostDetail page”import { useEffect, useState } from 'react'import { useParams } from 'react-router-dom'import { getPost, PostDetail as PostDetailType } from '../api/postsApi'
export default function PostDetail() { const { id } = useParams<{ id: string }>() const [post, setPost] = useState<PostDetailType | null>(null) const [loading, setLoading] = useState(true) const [error, setError] = useState<string | null>(null)
useEffect(() => { if (!id) return getPost(Number(id)) .then(setPost) .catch(() => setError('Post not found.')) .finally(() => setLoading(false)) }, [id])
if (loading) return <p>Loading...</p> if (error || !post) return <p>{error ?? 'Post not found.'}</p>
return ( <main> <h1>{post.title}</h1> <p>by {post.username} · {post.upvotes} votes</p> <p>{post.body}</p>
<section> <h2>Comments ({post.comments.length})</h2> {post.comments.length === 0 && <p>No comments yet.</p>} <ul> {post.comments.map(comment => ( <li key={comment.id}> <strong>{comment.username}</strong>: {comment.body} </li> ))} </ul> </section> </main> )}Register the route
Section titled “Register the route”// src/App.tsx (routes excerpt)<Route path="/posts/:id" element={<PostDetail />} />This route is public — anyone can read posts and comments without logging in.
Why [id] in the useEffect dependency array
Section titled “Why [id] in the useEffect dependency array”useEffect(() => { getPost(Number(id)) ...}, [id])React re-runs this effect whenever id changes. Without id in the array, navigating from /posts/1 to /posts/2 would not re-fetch — the old post would stay on screen. Adding it as a dependency ensures the data always matches the URL.
Exercise
Section titled “Exercise”Open src/pages/PostDetail.tsx in your Bulletin project.
- Add the
Commentinterface,PostDetailinterface, andgetPostfunction tosrc/api/postsApi.ts. - Create
PostDetail.tsxusinguseParamsto get the ID,useEffectwith[id]to fetch, and three-state handling. - Render the post title, author, body, and a list of comments.
- Register the
/posts/:idroute inApp.tsx. - Click a post from the feed and confirm the detail page loads with the correct content. Navigate between two different post URLs and confirm the content updates each time.
useParamsreturns URL segments as strings — parse withNumber()before using as an ID.- Include the param in the
useEffectdependency array so data re-fetches on navigation. PostDetailextendsPostwith acommentsarray — one request returns everything the page needs.- The detail route is public; no
ProtectedRouteneeded.