Reviews
List and submit product reviews using the Storefront SDK
Overview
The reviews resource fetches a product's approved reviews (with the average
rating and star distribution) and submits new reviews. Submitting works for both
anonymous visitors and logged-in customers — passing a session token unlocks the
verified-purchase check and the optional review reward code.
Reviews must be enabled for the store (reviewsEnabled feature flag). When they
are off, the list returns nothing and submitting fails with REVIEWS_DISABLED.
Methods
reviews.list(slug, options?)
Fetch the approved reviews for a product, plus the aggregate rating and the 1–5
star distribution. Only APPROVED reviews are returned, newest first (up to 100).
const { averageRating, reviewCount, distribution, reviews } =
await storefront.reviews.list('gold-ring');Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | Product URL slug |
Returns: Promise<ReviewListResponse>
Throws: NotFoundError if the product does not exist or is not visible in the store
reviews.submit(params, sessionId?, options?)
Submit a review for a product.
// Anonymous review
await storefront.reviews.submit({
slug: 'gold-ring',
rating: 5,
body: 'Loistava tuote, nopea toimitus!',
authorName: 'Matti',
});
// Logged-in review — pass the session token to enable the
// verified-purchase check and the reward code
const { review, reward } = await storefront.reviews.submit(
{ slug: 'gold-ring', rating: 5, body: 'Loistava tuote!' },
sessionId
);Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
params | SubmitReviewParams | Yes | The review payload (see below) |
sessionId | string | No | Customer session token. When valid, the review is submitted as the logged-in customer (enables verified-purchase + reward). Omit for anonymous reviews. |
SubmitReviewParams:
| Field | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | Product slug (max 200 chars) |
rating | number | Yes | Star rating, 1–5 |
body | string | Yes | Review text (max 5000 chars) |
title | string | No | Optional short title (max 200 chars) |
authorName | string | No | Display name for anonymous reviews (max 100 chars). Ignored when sessionId is passed. |
Returns: Promise<SubmitReviewResponse>
Throws: ValidationError (invalid rating / empty body / missing slug), RateLimitError (anonymous per-IP daily limit), or StorefrontError for REVIEWS_DISABLED (403) and ALREADY_REVIEWED (409) — check error.code.
Most reviews publish immediately
Reviews are not all held for moderation. Logged-in reviews and clean
anonymous reviews are published (APPROVED) right away. The only ones held as
PENDING are anonymous reviews that contain a link. Read review.status in the
response to know which happened, and show your "published" vs. "awaiting review"
confirmation accordingly.
Fetch Options
Both methods accept an optional trailing options object:
| Option | Type | Description |
|---|---|---|
signal | AbortSignal | Cancel the request |
cache | RequestCache | Fetch cache mode |
next.revalidate | number | false | Next.js ISR revalidation time in seconds |
next.tags | string[] | Next.js cache tags for on-demand revalidation |
Don't over-cache the review list
reviews.list() reflects newly published reviews. If you cache it with ISR, use
a short revalidate (or a cache tag you revalidate after a submit) so a customer
who just posted a review sees it without a long delay.
Response Types
ReviewStatus
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED';Review
A single approved review returned by reviews.list().
interface Review {
id: string;
rating: number; // 1–5
title: string | null; // null when omitted or hidden by a moderator
body: string; // "" when hidden by a moderator
contentHidden: boolean; // true = text redacted, rating still counts
verifiedPurchase: boolean; // false if the store hides the verified badge
merchantReply: string | null;
createdAt: string; // ISO 8601 timestamp
reviewerName: string; // first name, anonymous name, or "Asiakas"
}ReviewListResponse
Response from reviews.list().
interface ReviewListResponse {
averageRating: number | null; // null when there are no reviews
reviewCount: number; // total approved reviews
distribution: Record<string, number>; // counts per star: { "1": 0, ..., "5": 12 }
reviews: Review[]; // up to 100, newest first
}SubmitReviewParams
interface SubmitReviewParams {
slug: string;
rating: number; // 1–5
body: string;
title?: string;
authorName?: string; // anonymous only — ignored when a session is passed
}SubmitReviewResponse
Response from reviews.submit().
interface SubmitReviewResponse {
success: boolean;
review: {
id: string;
status: ReviewStatus; // "APPROVED" (published) or "PENDING" (held)
verifiedPurchase: boolean;
};
reward: SubmitReviewReward | null;
}
interface SubmitReviewReward {
code: string; // e.g. "KIITOS10"
discountType: string; // "PERCENTAGE" | "FIXED_AMOUNT"
discountValue: number; // percentage, or fixed amount in cents
}When is `reward` non-null?
The reward code is returned only for a logged-in, verified-purchase review,
and only when the store has review rewards enabled and the linked discount code
is still valid. For anonymous submissions it is always null. Reveal it in the
order-history review flow — never on the public product page.
Examples
Product Detail — Rating Summary and List
import { storefront } from '@/lib/storefront';
export default async function ProductReviews({ slug }: { slug: string }) {
const { averageRating, reviewCount, distribution, reviews } =
await storefront.reviews.list(slug, { next: { revalidate: 300 } });
if (reviewCount === 0) {
return <p>Ei vielä arvosteluja.</p>;
}
return (
<section>
<h2>
{averageRating?.toFixed(1)} · {reviewCount} arvostelua
</h2>
<ul>
{[5, 4, 3, 2, 1].map((star) => (
<li key={star}>
{star} ★ — {distribution[String(star)] ?? 0}
</li>
))}
</ul>
<ul>
{reviews.map((review) => (
<li key={review.id}>
<strong>{review.reviewerName}</strong> — {review.rating} ★
{review.verifiedPurchase && <span> ✓ Vahvistettu ostaja</span>}
{review.title && <p>{review.title}</p>}
<p>{review.contentHidden ? 'Sisältö piilotettu' : review.body}</p>
{review.merchantReply && <blockquote>{review.merchantReply}</blockquote>}
</li>
))}
</ul>
</section>
);
}Submitting a Review
'use server';
import { storefront, ValidationError, StorefrontError } from '@/lib/storefront';
export async function submitReview(formData: FormData, sessionId?: string) {
try {
const { review, reward } = await storefront.reviews.submit(
{
slug: String(formData.get('slug')),
rating: Number(formData.get('rating')),
body: String(formData.get('body')),
title: String(formData.get('title') || '') || undefined,
authorName: String(formData.get('authorName') || '') || undefined,
},
sessionId
);
return {
published: review.status === 'APPROVED',
// Show the code only in the order-history flow, never on the product page
rewardCode: reward?.code ?? null,
};
} catch (error) {
if (error instanceof ValidationError) {
return { error: 'Tarkista arvosana ja arvostelun teksti.' };
}
if (error instanceof StorefrontError && error.code === 'ALREADY_REVIEWED') {
return { error: 'Olet jo arvostellut tämän tuotteen.' };
}
throw error;
}
}Revealing the Reward Code (logged-in order-history flow)
const { reward } = await storefront.reviews.submit(
{ slug, rating, body },
sessionId
);
if (reward) {
// e.g. "Käytä koodia KIITOS10 seuraavaan tilaukseesi"
console.log(`Käytä koodia ${reward.code} seuraavaan tilaukseesi`);
}Error Handling
import {
NotFoundError,
ValidationError,
RateLimitError,
StorefrontError,
} from '@putiikkipalvelu/storefront-sdk';
try {
await storefront.reviews.submit({ slug, rating, body }, sessionId);
} catch (error) {
if (error instanceof ValidationError) {
// 400 — invalid rating, empty body, or missing slug
}
if (error instanceof RateLimitError) {
// 429 — too many anonymous reviews from this IP today
}
if (error instanceof StorefrontError) {
// Check error.code for REVIEWS_DISABLED (403) or ALREADY_REVIEWED (409)
console.error(`${error.message} (${error.code})`);
}
throw error;
}