Skip to content

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.

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

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)
src/pages/PostDetail.tsx
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>
)
}
// 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.

Open src/pages/PostDetail.tsx in your Bulletin project.

  1. Add the Comment interface, PostDetail interface, and getPost function to src/api/postsApi.ts.
  2. Create PostDetail.tsx using useParams to get the ID, useEffect with [id] to fetch, and three-state handling.
  3. Render the post title, author, body, and a list of comments.
  4. Register the /posts/:id route in App.tsx.
  5. 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.
  • useParams returns URL segments as strings — parse with Number() before using as an ID.
  • Include the param in the useEffect dependency array so data re-fetches on navigation.
  • PostDetail extends Post with a comments array — one request returns everything the page needs.
  • The detail route is public; no ProtectedRoute needed.