Review display components
Good UI components are reusable, type-safe, and handle edge cases. You'll build components that work across your app and gracefully handle missing data like avatars or malformed dates.
Outcome
Create Review and FiveStarRating components that display customer reviews with stars, avatars, timestamps, and proper styling.
Fast Track
- Run
pnpm add ms && pnpm add -D @types/ms, createcomponents/five-star-rating.tsxusing lucide-react Star icons - Create
components/review.tsxas a Client Component with Avatar, FiveStarRating, and relative time usingms - Create
components/reviews.tsxto map reviews with Separators, wrap product cards in Links on homepage
Hands-on Exercise 1.3
Build UI components for displaying product reviews:
Requirements:
- Create a
FiveStarRatingcomponent that displays 1-5 filled stars - Create a
Reviewcomponent that shows reviewer avatar, name, rating, date, and review text - Format dates as relative time ("2 days ago", "3 weeks ago")
- Use shadcn/ui Avatar component with fallback initials
- Make all components type-safe with the Review type from Lesson 1.2
Implementation hints:
- Star rating: Use lucide-react icons (Star, StarHalf)
- Timestamps: Install and use the
mslibrary for relative time - Avatars: Extract initials from reviewer name for fallback
- The Review component should be a Client Component (uses Date.now())
- Use Separator component between reviews
Step 1: Install Dependencies
pnpm add ms
pnpm add -D @types/msThe ms library converts milliseconds to human-readable strings.
Step 2: Create FiveStarRating Component
Create components/five-star-rating.tsx:
import { Star } from "lucide-react";
export function FiveStarRating({ rating }: { rating: number }) {
return (
<div className="flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={`h-4 w-4 ${
i < rating
? "fill-yellow-400 text-yellow-400"
: "fill-gray-200 text-gray-200"
}`}
/>
))}
</div>
);
}What this does:
- Creates 5 star icons
- Fills stars based on rating (1-5)
- Uses Tailwind for colors (yellow for filled, gray for empty)
Step 3: Create Review Component
Create components/review.tsx:
"use client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Review as ReviewType } from "@/lib/types";
import ms from "ms";
import { FiveStarRating } from "./five-star-rating";
export function Review({ review }: { review: ReviewType }) {
const date = new Date(review.date);
return (
<div className="flex gap-4">
<Avatar>
<AvatarFallback>{getInitials(review.reviewer)}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">{review.reviewer}</p>
<div className="flex items-center gap-2 mt-1">
<FiveStarRating rating={review.stars} />
<time className="text-xs text-muted-foreground" suppressHydrationWarning>
{timeAgo(date)}
</time>
</div>
</div>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">
{review.review}
</p>
</div>
</div>
);
}
function getInitials(name: string): string {
return name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
function timeAgo(date: Date, suffix = true): string {
const now = Date.now();
const diff = now - date.getTime();
if (diff < 1000) {
return "Just now";
}
return `${ms(diff, { long: true })}${suffix ? " ago" : ""}`;
}Key features:
"use client"directive (needed forDate.now()andsuppressHydrationWarning)- Avatar with fallback initials
- Five-star rating display
- Relative timestamp ("2 days ago")
- Flexible layout with Flexbox
Why suppressHydrationWarning?
Server-rendered timestamps differ from client-rendered ones (server time vs client time). This prop tells React to expect mismatches on first render.
Step 4: Create Reviews Container Component
Create components/reviews.tsx:
import { Product } from "@/lib/types";
import { Review } from "./review";
import { Separator } from "./ui/separator";
export function Reviews({ product }: { product: Product }) {
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Customer Reviews</h2>
<div className="space-y-6">
{product.reviews.map((review, index) => (
<div key={index}>
<Review review={review} />
{index < product.reviews.length - 1 && (
<Separator className="mt-6" />
)}
</div>
))}
</div>
</div>
);
}What this does:
- Maps over product reviews
- Renders Review component for each
- Adds Separator between reviews (but not after the last one)
Step 5: Update Homepage
Update app/page.tsx to display star ratings and link to individual products:
import Link from "next/link";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { FiveStarRating } from "@/components/five-star-rating";
import { getProducts } from "@/lib/sample-data";
export default function Home() {
const products = getProducts();
function averageRating(reviews: { stars: number }[]) {
if (reviews.length === 0) return 0;
return reviews.reduce((sum, r) => sum + r.stars, 0) / reviews.length;
}
return (
<main className="min-h-screen p-8">
<div className="max-w-4xl mx-auto space-y-8">
<h1 className="text-4xl font-bold">Product Reviews</h1>
<div className="grid gap-4">
{products.map((product) => (
<Link key={product.slug} href={`/${product.slug}`}>
<Card className="hover:border-primary transition-colors cursor-pointer">
<CardHeader>
<CardTitle>{product.name}</CardTitle>
<div className="flex items-center gap-2">
<FiveStarRating rating={Math.round(averageRating(product.reviews))} />
<span className="text-sm text-muted-foreground">
{product.reviews.length} reviews
</span>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{product.description}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
</main>
);
}Changes:
- Imported
FiveStarRatingcomponent - Added
averageRatinghelper function - Display star rating with review count in each card
- Wrapped cards in
Linkcomponent with hover effect - Links to
/{product.slug}(we'll create these pages in the next lesson)
Try It
-
Visit the homepage at http://localhost:3000
-
You should see:
- 3 product cards with names and descriptions
- Star ratings showing average rating for each product
- Review count next to the stars
- Hover effect on cards (border color change)
-
Click a product card — it will 404 for now (we'll create product pages in Lesson 1.4)
Understanding Client Components
Why is Review a Client Component?
The timeAgo function uses Date.now(), which is a dynamic value that changes every millisecond. Next.js can't statically generate or cache this because it's time-dependent.
"use client"; // Required because of Date.now()
function timeAgo(date: Date): string {
const diff = Date.now() - date.getTime(); // Date.now() changes constantly
// ...
}Server vs Client Components:
| Feature | Server Component | Client Component |
|---|---|---|
Can use Date.now() | ❌ No | ✅ Yes |
| Can use React hooks | ❌ No | ✅ Yes |
| Can use event handlers | ❌ No | ✅ Yes |
| Bundle sent to client | ❌ No (smaller) | ✅ Yes (larger) |
| Default in Next.js 16 | ✅ Yes | Opt-in with "use client" |
Best practice: Use Server Components by default, Client Components only when needed.
Component Architecture
Your component tree:
Reviews (Server Component)
└── Review (Client Component) ← Uses Date.now()
├── Avatar
├── FiveStarRating
└── Timestamp
Only the Review component needs to be a Client Component. Everything else stays as Server Components for better performance.
Done-When
- FiveStarRating component displays 1-5 stars
- Review component shows avatar, name, rating, date, and text
- Timestamps format as relative time ("2 days ago")
- Components are fully type-safe
- Homepage shows star ratings with average for each product
- Homepage links to product pages (ready for next lesson)
What's Next
Your review UI is built and ready to display. In the next lesson, you'll create dynamic routes for individual product pages using Next.js App Router. You'll use generateStaticParams to pre-render all product pages at build time.
Sources:
Was this helpful?