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.

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

TypeScript
const settings = await lynkow.reviews.settings()

ReviewSettings response

TypeScript
{
  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:

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

TypeScript
const { data: reviews, meta } = await lynkow.reviews.list({
  page: 1,
  limit: 10,
  sort: 'created_at',
  order: 'desc',
})

Review shape

TypeScript
{
  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

TypeScript
{
  total: number
  perPage: number
  currentPage: number
  lastPage: number
}

Filters

Parameter

Type

Description

page

number

Page number (starts at 1)

limit

number

Reviews per page

minRating

number

Minimum rating (1-5)

maxRating

number

Maximum rating (1-5)

sort

string

Sort field (e.g. 'created_at', 'rating')

order

'asc' | 'desc'

Sort direction


3. Rating summary

Compute an average rating and a distribution breakdown to show at the top of your reviews section.

tsx
// 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.total field 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).

TypeScript
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

TypeScript
{
  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:

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

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

tsx
// 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():

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

tsx
// 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>
  )
}
CSS
.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.

tsx
// 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>
  )
}
CSS
.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

tsx
// 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)

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

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

tsx
// 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:

tsx
// 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:

TypeScript
await lynkow.reviews.submit(reviewData, {
  recaptchaToken: 'token-from-recaptcha-v3',
})

Cache behavior

Method

SDK internal TTL

reviews.list()

5 minutes

reviews.settings()

10 minutes

reviews.submit()

No cache (clears existing review cache)

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