This guide covers how to display customer reviews, build a star rating component, accept new review submissions, handle moderation, and filter by rating -- everything you need to add social proof to your Next.js site with Lynkow.
Prerequisites
You should have the Lynkow SDK installed and a client initialized. If not, see Guide 1: Installation.
// lib/lynkow.ts
import { createClient } from 'lynkow'
export const lynkow = createClient({
siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!,
fetchOptions: {
next: { revalidate: 60 },
},
})1. Check settings
Before building any review UI, fetch the review settings to know which fields to show, whether anonymous reviews are allowed, and if moderation is enabled.
const settings = await lynkow.reviews.settings()ReviewSettings response
{
fields: {
email: { required: boolean; visible: boolean } | 'required' | 'visible' | 'optional'
title: { required: boolean; visible: boolean } | 'required' | 'visible' | 'optional'
}
requireApproval: boolean // true = reviews go through moderation
allowAnonymous: boolean // true = authorName is optional
}Use these settings to conditionally render form fields:
// Should we show the email field?
const showEmail = settings.fields.email !== 'optional'
|| (typeof settings.fields.email === 'object' && settings.fields.email.visible)
// Is email required?
const emailRequired = settings.fields.email === 'required'
|| (typeof settings.fields.email === 'object' && settings.fields.email.required)2. Display reviews
Fetch approved reviews with reviews.list(). Only reviews with status 'approved' are returned by the public API.
const { data: reviews, meta } = await lynkow.reviews.list({
page: 1,
limit: 10,
sort: 'created_at',
order: 'desc',
})Review shape
{
id: string
authorName: string
rating: number // 1 to 5
title: string | null
content: string
status: 'approved'
createdAt: string // ISO 8601 date
// Owner's reply (if any)
response: {
content: string
respondedAt: string
} | null
}PaginationMeta
{
total: number
perPage: number
currentPage: number
lastPage: number
}Filters
Parameter | Type | Description |
|---|---|---|
| number | Page number (starts at 1) |
| number | Reviews per page |
| number | Minimum rating (1-5) |
| number | Maximum rating (1-5) |
| string | Sort field (e.g. |
|
| Sort direction |
3. Rating summary
Compute an average rating and a distribution breakdown to show at the top of your reviews section.
// components/rating-summary.tsx
interface Review {
rating: number
}
interface RatingDistribution {
stars: number
count: number
percentage: number
}
function computeRatingSummary(reviews: Review[]): {
average: number
total: number
distribution: RatingDistribution[]
} {
const total = reviews.length
if (total === 0) {
return {
average: 0,
total: 0,
distribution: [5, 4, 3, 2, 1].map((stars) => ({
stars,
count: 0,
percentage: 0,
})),
}
}
const sum = reviews.reduce((acc, r) => acc + r.rating, 0)
const average = Math.round((sum / total) * 10) / 10
const distribution: RatingDistribution[] = [5, 4, 3, 2, 1].map(
(stars) => {
const count = reviews.filter((r) => r.rating === stars).length
return {
stars,
count,
percentage: Math.round((count / total) * 100),
}
}
)
return { average, total, distribution }
}
export function RatingSummary({ reviews }: { reviews: Review[] }) {
const { average, total, distribution } = computeRatingSummary(reviews)
return (
<div className="rating-summary">
<div className="rating-summary__average">
<span className="rating-number">{average}</span>
<StarRating rating={average} />
<span className="rating-count">
{total} review{total !== 1 ? 's' : ''}
</span>
</div>
<div className="rating-summary__distribution">
{distribution.map(({ stars, count, percentage }) => (
<div key={stars} className="distribution-row">
<span className="distribution-label">{stars} stars</span>
<div className="distribution-bar">
<div
className="distribution-fill"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="distribution-count">{count}</span>
</div>
))}
</div>
</div>
)
}Note: For a site-wide average across all reviews (not just the current page), you may want to fetch all reviews by paginating through them, or compute the average server-side. The
meta.totalfield tells you the total count across all pages.
4. Submit form
Use reviews.submit() to send a new review. Like forms, the SDK automatically injects anti-spam fields (honeypot + timestamp).
const result = await lynkow.reviews.submit({
authorName: 'Alice',
rating: 5,
content: 'Excellent product, highly recommend!',
// Optional fields (depending on settings):
authorEmail: '[email protected]',
title: 'Love it!',
})Response
{
message: string // "Review submitted"
status: 'success' | 'pending' // 'pending' if requireApproval is true
reviewId?: string // ID of the created review
}With reCAPTCHA
If your site uses reCAPTCHA for reviews:
const result = await lynkow.reviews.submit(
{
authorName: 'Alice',
rating: 5,
content: 'Great service!',
},
{ recaptchaToken: 'token-from-recaptcha-v3' }
)5. Handle moderation
When settings.requireApproval is true, submitted reviews go into a moderation queue and are not immediately visible. Show a clear message to the user.
function SubmissionConfirmation({
status,
requireApproval,
}: {
status: 'success' | 'pending'
requireApproval: boolean
}) {
if (status === 'pending' || requireApproval) {
return (
<div className="review-submitted review-submitted--pending">
<h3>Thank you for your review!</h3>
<p>
Your review has been submitted and is pending approval.
It will be published once our team has reviewed it.
</p>
</div>
)
}
return (
<div className="review-submitted review-submitted--success">
<h3>Thank you!</h3>
<p>Your review has been published.</p>
</div>
)
}6. Filter by rating
Let users filter reviews by star rating using the minRating and maxRating parameters.
// components/review-filters.tsx
'use client'
interface ReviewFiltersProps {
activeRating: number | null
onFilter: (rating: number | null) => void
}
export function ReviewFilters({
activeRating,
onFilter,
}: ReviewFiltersProps) {
const ratings = [5, 4, 3, 2, 1]
return (
<div className="review-filters">
<button
className={activeRating === null ? 'active' : ''}
onClick={() => onFilter(null)}
>
All ratings
</button>
{ratings.map((rating) => (
<button
key={rating}
className={activeRating === rating ? 'active' : ''}
onClick={() => onFilter(rating)}
>
{rating} {'★'.repeat(rating)}
</button>
))}
</div>
)
}Use with reviews.list():
// Fetch only 4-star and 5-star reviews
const { data: topReviews } = await lynkow.reviews.list({
minRating: 4,
})
// Fetch only 1-star reviews
const { data: criticalReviews } = await lynkow.reviews.list({
minRating: 1,
maxRating: 1,
})
// Fetch a specific rating
const { data: threeStarReviews } = await lynkow.reviews.list({
minRating: 3,
maxRating: 3,
})7. Complete components
StarRating (display)
A reusable component to render filled, half, and empty stars.
// components/star-rating.tsx
interface StarRatingProps {
rating: number // 0 to 5, supports decimals (e.g. 4.3)
maxRating?: number // Default: 5
size?: number // Star size in px, default: 20
}
export function StarRating({
rating,
maxRating = 5,
size = 20,
}: StarRatingProps) {
return (
<div
className="star-rating"
role="img"
aria-label={`${rating} out of ${maxRating} stars`}
>
{Array.from({ length: maxRating }, (_, i) => {
const starValue = i + 1
let fill: 'full' | 'half' | 'empty'
if (rating >= starValue) {
fill = 'full'
} else if (rating >= starValue - 0.5) {
fill = 'half'
} else {
fill = 'empty'
}
return (
<span
key={i}
className={`star star--${fill}`}
style={{ fontSize: size }}
>
{fill === 'full' ? '★' : fill === 'half' ? '★' : '☆'}
</span>
)
})}
</div>
)
}.star-rating {
display: inline-flex;
gap: 2px;
}
.star--full {
color: #f59e0b;
}
.star--half {
color: #f59e0b;
opacity: 0.6;
}
.star--empty {
color: #d1d5db;
}StarRatingInput (interactive)
An interactive star selector for the review form.
// components/star-rating-input.tsx
'use client'
import { useState } from 'react'
interface StarRatingInputProps {
value: number
onChange: (rating: number) => void
maxRating?: number
size?: number
}
export function StarRatingInput({
value,
onChange,
maxRating = 5,
size = 28,
}: StarRatingInputProps) {
const [hovered, setHovered] = useState<number | null>(null)
const displayRating = hovered ?? value
return (
<div
className="star-rating-input"
onMouseLeave={() => setHovered(null)}
role="radiogroup"
aria-label="Rating"
>
{Array.from({ length: maxRating }, (_, i) => {
const starValue = i + 1
return (
<button
key={i}
type="button"
className={`star-input ${
starValue <= displayRating ? 'star-input--active' : ''
}`}
style={{ fontSize: size }}
onMouseEnter={() => setHovered(starValue)}
onClick={() => onChange(starValue)}
aria-label={`${starValue} star${starValue > 1 ? 's' : ''}`}
aria-checked={starValue === value}
role="radio"
>
{starValue <= displayRating ? '★' : '☆'}
</button>
)
})}
</div>
)
}.star-rating-input {
display: inline-flex;
gap: 2px;
}
.star-input {
background: none;
border: none;
cursor: pointer;
padding: 0;
color: #d1d5db;
transition: color 0.15s, transform 0.15s;
}
.star-input--active {
color: #f59e0b;
}
.star-input:hover {
transform: scale(1.1);
}ReviewCard
// components/review-card.tsx
import { StarRating } from './star-rating'
interface Review {
id: string
authorName: string
rating: number
title: string | null
content: string
createdAt: string
response: {
content: string
respondedAt: string
} | null
}
export function ReviewCard({ review }: { review: Review }) {
return (
<article className="review-card">
<header className="review-card__header">
<StarRating rating={review.rating} />
<time dateTime={review.createdAt}>
{new Date(review.createdAt).toLocaleDateString()}
</time>
</header>
{review.title && (
<h3 className="review-card__title">{review.title}</h3>
)}
<p className="review-card__content">{review.content}</p>
<footer className="review-card__author">
<strong>{review.authorName}</strong>
</footer>
{review.response && (
<div className="review-card__response">
<p>
<strong>Response from the team:</strong>
</p>
<p>{review.response.content}</p>
<time dateTime={review.response.respondedAt}>
{new Date(review.response.respondedAt).toLocaleDateString()}
</time>
</div>
)}
</article>
)
}ReviewList (server component with pagination)
// components/review-list.tsx
import { lynkow } from '@/lib/lynkow'
import { ReviewCard } from './review-card'
import { RatingSummary } from './rating-summary'
import Link from 'next/link'
interface ReviewListProps {
page?: number
limit?: number
minRating?: number
}
export async function ReviewList({
page = 1,
limit = 10,
minRating,
}: ReviewListProps) {
const { data: reviews, meta } = await lynkow.reviews.list({
page,
limit,
minRating,
sort: 'created_at',
order: 'desc',
})
if (reviews.length === 0) {
return (
<div className="review-list--empty">
<p>No reviews yet. Be the first to share your experience!</p>
</div>
)
}
return (
<div className="review-list">
<RatingSummary reviews={reviews} />
<div className="review-list__items">
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</div>
{/* Pagination */}
{meta.lastPage > 1 && (
<nav className="review-pagination" aria-label="Review pages">
{page > 1 && (
<Link href={`?page=${page - 1}`}>Previous</Link>
)}
<span>
Page {meta.currentPage} of {meta.lastPage}
</span>
{page < meta.lastPage && (
<Link href={`?page=${page + 1}`}>Next</Link>
)}
</nav>
)}
</div>
)
}ReviewForm (client component)
A complete form that adapts to the site's review settings.
// components/review-form.tsx
'use client'
import { useEffect, useState, useCallback } from 'react'
import { lynkow } from '@/lib/lynkow'
import { isLynkowError } from 'lynkow'
import { StarRatingInput } from './star-rating-input'
import type { ReviewSettings } from 'lynkow'
export function ReviewForm() {
const [settings, setSettings] = useState<ReviewSettings | null>(null)
const [rating, setRating] = useState(5)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{
status: 'success' | 'pending'
} | null>(null)
const [error, setError] = useState<string | null>(null)
const [fieldErrors, setFieldErrors] = useState<
Record<string, string>
>({})
useEffect(() => {
lynkow.reviews.settings().then(setSettings)
}, [])
const isFieldRequired = useCallback(
(fieldName: 'email' | 'title'): boolean => {
if (!settings) return false
const config = settings.fields[fieldName]
if (typeof config === 'string') return config === 'required'
return config.required
},
[settings]
)
const isFieldVisible = useCallback(
(fieldName: 'email' | 'title'): boolean => {
if (!settings) return false
const config = settings.fields[fieldName]
if (typeof config === 'string') return config !== 'optional'
return config.visible
},
[settings]
)
const handleSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!settings) return
setError(null)
setFieldErrors({})
setLoading(true)
const formData = new FormData(e.currentTarget)
try {
const submitResult = await lynkow.reviews.submit({
authorName: (formData.get('authorName') as string) || '',
authorEmail:
(formData.get('authorEmail') as string) || undefined,
title: (formData.get('title') as string) || undefined,
content: (formData.get('content') as string) || '',
rating,
})
setResult(submitResult)
} catch (err) {
if (
isLynkowError(err) &&
err.code === 'VALIDATION_ERROR'
) {
const errors: Record<string, string> = {}
err.details?.forEach((detail) => {
if (detail.field) {
errors[detail.field] = detail.message
}
})
setFieldErrors(errors)
} else if (
isLynkowError(err) &&
err.code === 'TOO_MANY_REQUESTS'
) {
setError('Too many submissions. Please try again later.')
} else {
setError('Something went wrong. Please try again.')
}
} finally {
setLoading(false)
}
},
[settings, rating]
)
// Not loaded yet
if (!settings) return null
// Submission result
if (result) {
return (
<div
className={`review-result review-result--${result.status}`}
>
<h3>Thank you for your review!</h3>
{result.status === 'pending' || settings.requireApproval ? (
<p>
Your review has been submitted and is pending approval. It
will appear on the site once our team has reviewed it.
</p>
) : (
<p>Your review has been published.</p>
)}
</div>
)
}
return (
<form onSubmit={handleSubmit} className="review-form" noValidate>
{/* Star rating */}
<div className="review-form__field">
<label>Your rating *</label>
<StarRatingInput value={rating} onChange={setRating} />
{fieldErrors.rating && (
<span className="field-error" role="alert">
{fieldErrors.rating}
</span>
)}
</div>
{/* Author name */}
{!settings.allowAnonymous && (
<div className="review-form__field">
<label htmlFor="authorName">Your name *</label>
<input
id="authorName"
name="authorName"
type="text"
required
maxLength={100}
/>
{fieldErrors.authorName && (
<span className="field-error" role="alert">
{fieldErrors.authorName}
</span>
)}
</div>
)}
{settings.allowAnonymous && (
<div className="review-form__field">
<label htmlFor="authorName">Your name (optional)</label>
<input
id="authorName"
name="authorName"
type="text"
maxLength={100}
/>
</div>
)}
{/* Email */}
{isFieldVisible('email') && (
<div className="review-form__field">
<label htmlFor="authorEmail">
Email{isFieldRequired('email') ? ' *' : ' (optional)'}
</label>
<input
id="authorEmail"
name="authorEmail"
type="email"
required={isFieldRequired('email')}
/>
{fieldErrors.authorEmail && (
<span className="field-error" role="alert">
{fieldErrors.authorEmail}
</span>
)}
</div>
)}
{/* Title */}
{isFieldVisible('title') && (
<div className="review-form__field">
<label htmlFor="title">
Title{isFieldRequired('title') ? ' *' : ' (optional)'}
</label>
<input
id="title"
name="title"
type="text"
required={isFieldRequired('title')}
maxLength={200}
/>
{fieldErrors.title && (
<span className="field-error" role="alert">
{fieldErrors.title}
</span>
)}
</div>
)}
{/* Content */}
<div className="review-form__field">
<label htmlFor="content">Your review *</label>
<textarea
id="content"
name="content"
required
rows={5}
maxLength={5000}
placeholder="Share your experience..."
/>
{fieldErrors.content && (
<span className="field-error" role="alert">
{fieldErrors.content}
</span>
)}
</div>
{/* Global error */}
{error && (
<div className="review-form__error" role="alert">
{error}
</div>
)}
<button type="submit" disabled={loading}>
{loading ? 'Submitting...' : 'Submit review'}
</button>
</form>
)
}Full reviews page
Bring everything together in a single page.
// app/reviews/page.tsx
import { lynkow } from '@/lib/lynkow'
import { ReviewList } from '@/components/review-list'
import { ReviewForm } from '@/components/review-form'
import { ReviewFiltersWrapper } from '@/components/review-filters-wrapper'
export const metadata = {
title: 'Customer Reviews',
description: 'See what our customers have to say.',
}
export default async function ReviewsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; rating?: string }>
}) {
const params = await searchParams
const page = Number(params.page) || 1
const minRating = params.rating ? Number(params.rating) : undefined
return (
<div className="reviews-page">
<h1>Customer Reviews</h1>
<ReviewList
page={page}
limit={10}
minRating={minRating}
/>
<section className="reviews-page__submit">
<h2>Leave a Review</h2>
<ReviewForm />
</section>
</div>
)
}Client-side filtering wrapper
For client-side filtering without full page reloads:
// components/review-filters-wrapper.tsx
'use client'
import { useState, useEffect } from 'react'
import { lynkow } from '@/lib/lynkow'
import { ReviewCard } from './review-card'
import { ReviewFilters } from './review-filters'
interface Review {
id: string
authorName: string
rating: number
title: string | null
content: string
createdAt: string
response: { content: string; respondedAt: string } | null
}
export function ReviewFiltersWrapper() {
const [reviews, setReviews] = useState<Review[]>([])
const [activeRating, setActiveRating] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
const filters: Record<string, unknown> = {
limit: 20,
sort: 'created_at',
order: 'desc',
}
if (activeRating !== null) {
filters.minRating = activeRating
filters.maxRating = activeRating
}
lynkow.reviews
.list(filters)
.then(({ data }) => setReviews(data))
.finally(() => setLoading(false))
}, [activeRating])
return (
<div>
<ReviewFilters
activeRating={activeRating}
onFilter={setActiveRating}
/>
{loading ? (
<p>Loading reviews...</p>
) : reviews.length === 0 ? (
<p>No reviews match this filter.</p>
) : (
<div className="review-list__items">
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</div>
)}
</div>
)
}Anti-spam
Like forms, the SDK automatically handles anti-spam for review submissions. It injects hidden honeypot fields (_hp and _ts) behind the scenes. You do not need to add any hidden inputs to your form.
If your site also uses reCAPTCHA, pass the token as the second argument:
await lynkow.reviews.submit(reviewData, {
recaptchaToken: 'token-from-recaptcha-v3',
})Cache behavior
Method | SDK internal TTL |
|---|---|
| 5 minutes |
| 10 minutes |
| No cache (clears existing review cache) |
lynkow.reviews.clearCache()After a successful reviews.submit(), the SDK automatically invalidates the cached review list so the next reviews.list() call fetches fresh data.