cf.completefrontend
My progress
SYSTEM DESIGN · #54

Design product ratings and reviews

The reviews section of a product page and the form for writing one: a rating summary with a histogram, a sortable and filterable list, helpful votes, photos, and seller replies. It looks like a list with stars, but the hard parts are honest numbers (averages that don't lie with 2 reviews), a ranking that surfaces useful reviews, state that lives in the URL, search engines reading it, and trust rules that now carry legal weight.

The question

We run a large online store. Every product page has a reviews section: an average rating, how many people rated it, a breakdown by stars, and a list of reviews that shoppers can sort, filter, and mark as helpful. Customers who bought the product can write a review with stars, text, and photos.

Design the frontend of the reviews feature: the summary, the list, helpful votes, and the review form. How do you keep the numbers honest, show the most useful reviews first, keep it fast on a long product page, and make sure search engines and screen readers get it right?

Real-life examples: Amazon customer reviews, Etsy shop and item reviews, Best Buy ratings and reviews, Airbnb listing reviews, App Store and Google Play ratings

Requirements, and what makes reviews hard

A review is one customer's opinion of one product: a star rating from 1 to 5, usually a title and some text, sometimes photos. A rating can also come alone, with no text. The reviews section is the part of the product page that sums these up and lists them. Shoppers read it more than almost anything else on the page, because it's the only part the seller didn't write.

Several pieces already have their own guide here. The stars themselves (the input and the read-only display) are the star rating guide. The rest of the product page, gallery, price, and buy box, is the Amazon guide. Threaded replies are the comments thread guide, and photo uploads are the file uploader guide.

This guide covers the reviews feature as a whole: how the summary, the list, the votes, and the form fit together, and the decisions that make it honest, fast, and findable.

Start with questions. Each answer below changes something in the design. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
How much traffic?20 million product page views a day, peaks of 1,500 per second during sales. About 40% of visitors scroll to the reviews.The summary must come from a cache or the server render, not a fresh database count per view.
How many reviews per product?Very skewed. Half of all products have 5 reviews or fewer; a bestseller has 85,000.Averages from 2 reviews need care, and lists of 85,000 need cursor pagination and server-side sorting.
Who can write a review?Signed-in customers. Verified buyers get a badge; others may review some categories.An eligibility check before the form, and a badge that the server decides, never the client.
What goes in a review?Stars (required), title, text (up to 5,000 characters), up to 6 photos or 1 short video, optional pros and cons, and the variant bought (size, color).A multi-field form, an upload flow, and variant filters on the list.
Is there moderation?Yes. Every review is checked by filters, some by people, before anyone else sees it.A pending state that only the author sees, and edits that go back through checks.
Sort and filter?Sort by most helpful, newest, highest, lowest. Filter by stars, verified purchase, with photos, variant, and a keyword.List state with several dimensions that must live in the URL.
Many languages?Store in 15 locales. Reviews are shown translated, with a link to the original.Translation on the server, lang attributes, and a per-review toggle.
Can a shopper vote on reviews?Yes, a Helpful button only, no Not helpful.A per-user toggle that must be idempotent, and a helpful sort that works with one-sided votes.
Does SEO matter?A lot. Stars in search results raise click rates, and review text is fresh content.Server rendering of the summary and first page, and structured data.
Wireframe of the reviews section. On the left, the summary: 4.6 out of 5 from 9,214 ratings, then five histogram bars for 5 to 1 stars at 72, 14, 6, 3 and 5 percent; the 1-star bar is selected. A note says the bars are links like ?stars=1. Below is a Write a review button. On the right, a filter bar with a Most helpful sort, a pressed Verified toggle, and a Photos toggle, then the line 87 reviews: 1 star, verified. Two review cards follow: a 1-star review titled Strap broke in a month by a verified buyer of the black color, with Read more and a Helpful button showing 52; and a 2-star review translated from German with a Show original link and a response from the brand. A Load more button sits at the bottom.Product page: Customer reviews4.6 out of 59,214 ratings5 star72%4 star14%3 star6%2 star3%1 star5%bars are links?stars=1#reviewsWrite a reviewMost helpfulVerifiedPhotos87 reviews: 1 star, verified1 star: Strap broke in a monthMaya R., Verified purchase, BlackBand cracked in week 4Read moreHelpful 522 stars: Loud but heavyTranslated from German. Show originalResponse from AriaLoad more
Two columns: the summary on the left is server-rendered and links into the list; the list on the right is driven by the URL. Every card carries its own badges, Read more, and Helpful.

Functional requirements

  • Summary: average rating to one decimal, total count, and a 5-row histogram whose bars are links that filter the list.
  • List: 10 reviews per page, sort by 4 orders, filter by stars, verified, photos, variant, and keyword, with a count of matching reviews.
  • Review card: stars, title, author, date, variant, badges, text with Read more, photos in a lightbox, a seller reply, translation toggle, helpful button, and report link.
  • Helpful votes: one per signed-in user per review, can be undone, shown as a count.
  • Write a review: eligibility check, stars, title, text, photos, pros and cons, a saved draft, guidelines, then a pending state until approved. Authors can edit and delete.

Non-functional requirements

  • Fast product page: the reviews must not hurt Largest Contentful Paint (LCP, good is 2.5 seconds or less) and must not shift the layout (Cumulative Layout Shift, CLS, 0.1 or less).
  • Fast interactions: a filter or sort change shows new results in under 500 ms at p75 on a mid-range phone; a helpful vote feels instant (Interaction to Next Paint, INP, at most 200 ms).
  • Honest: the numbers shown match the reviews behind them, nothing is hidden because it is negative, and paid or gifted reviews are labelled.
  • Findable: search engines see the rating and the first reviews in the HTML.
  • Accessible: every part works with a keyboard and a screen reader, including the histogram and filters.

Out of scope

Questions and answers about the product (a separate feature with its own threading), seller ratings, the moderation tools staff use, and the machine learning models that score reviews. We design the shopper-facing parts and the contract with those systems.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design the most (select all that apply)? Select all that apply.

What makes reviews hard

A reviews section looks like the easiest part of a product page: some stars and a list. Five properties make it harder than it looks, and the later chapters answer them one by one.

  1. The numbers are small and skewed. Most products have a handful of reviews, and ratings bunch up at 5 stars. An average of 5.0 from 2 reviews and 4.6 from 9,000 reviews are not the same kind of fact, yet a naive UI shows them the same way.
  2. Order decides what people read. Shoppers read the first 3 to 5 reviews and stop. Whatever sort sits on top shapes the buying decision more than the other 84,995 reviews. A good default sort is a ranking problem, not an ORDER BY date.
  3. The list has many dimensions. 4 sorts, 5 star filters, verified, photos, 30 variants, and a keyword make thousands of possible views. Each must be shareable, survive Back, and load fast. That is search-page state on a product page.
  4. It sits low on a heavy page. Reviews are below the fold of a page that already fights for LCP with a big product image. Yet the rating and the first reviews must be in the HTML for search engines. Fast and findable pull in opposite directions.
  5. Trust is the product. Fake reviews, paid reviews, and hidden negative reviews destroy the value of the whole section. Since October 2024 the US Federal Trade Commission has a rule that makes several of these practices illegal, and parts of it show up as UI requirements: labels, disclosure, and what you may not hide.

Notice what is not on the list: real-time updates. A new review on a product with 9,000 reviews changes nothing a shopper would notice in the next minute. Reviews are read-heavy and slow-changing, so caching and server rendering do most of the work, and we can spend our effort on ranking, state, and trust.

PropertyWhat it drivesChapter
Small, skewed numbersShowing the count, rounding the histogram, Bayesian average for rankingThe rating summary; Ranking products fairly
Order decides what is readHelpful score with time decay, stable sortingSorting the list
Many list dimensionsURL state, cursor pagination, counts per filterFilters, URL and pagination
Low on a heavy pageServer-render (SSR) the summary and first page, load the rest on approachSEO, performance, and accessibility
TrustModeration states, badges, disclosures, no sentiment-based hidingWriting a review; Moderation and trust

Think first

An interviewer asks: should a new review appear live on everyone's open product page, the way comments do on a live stream? What do you answer?

The rating summary: average, count, and histogram

The summary is the block at the top of the reviews: a big number like 4.6, stars that show it, the number of ratings, and five bars from 5 stars down to 1. It's small, but it's what most shoppers read instead of the reviews, and it shows up in search results. Three decisions hide in it.

Always show the count next to the average

The naive summary is an average and stars: 4.8. Compare two products: A has 4.8 from 5 ratings, and B has 4.6 from 9,214 ratings. Without counts, A looks better. With counts, most shoppers trust B, and they are right to: one angry 1-star buyer would pull A down to 4.2, while B would still show 4.6.

So the count is part of the rating, not decoration. Show it right next to the stars (4.6 out of 5, 9,214 ratings), format it for the locale with Intl.NumberFormat, and say "ratings" if star-only ratings are counted, "reviews" if only written reviews are. Mixing the two words is a common bug: the summary says 9,214 and the list says 1,207 reviews, and shoppers think reviews are being hidden. Label both: "9,214 ratings, 1,207 with text".

Perfect scores are not as strong as they look either. A 2017 report by Northwestern University's Spiegel Research Center found that purchase likelihood peaked for average ratings between about 4.0 and 4.7 and fell as ratings got closer to 5.0; the authors' reading is that shoppers see a perfect score as too good to be true. That's another reason to show the distribution, not only the average.

Display the average to one decimal, computed on the server from exact sums (sum of stars / count), never from the rounded percentages. The stars beside it use a partial fill for the fraction; the star rating guide covers that display and its accessible name.

Making the histogram add up to 100%

Each bar shows a share of ratings. Round each share on its own and the total can be 99% or 101%. Take a new product with 7 ratings: five 5-star, one 4-star, one 3-star. The true shares are 71.43%, 14.29%, and 14.29%, which round to 71 + 14 + 14 = 99%.

It can overshoot too. With 200 ratings split 101, 49, 49, 0, 1, the shares are 50.5%, 24.5%, 24.5%, 0%, and 0.5%, and rounding half up gives 51 + 25 + 25 + 0 + 1 = 102%. Shoppers notice, and support gets tickets asking where the missing review went.

Decision How should the histogram show its numbers?

Round each percentage on its own

Math.round(count / total * 100) per bar.

Pros

  • One line of code.
  • Each label is the closest whole number to its true share.

Cons

  • Totals of 98% to 102% on small products.
  • Looks like a bug to careful shoppers.

Largest remainder rounding

Floor every share, then give the leftover points one by one to the bars with the biggest fractional parts.

Pros

  • Always sums to exactly 100%.
  • Each label is off by less than 1 point.
  • Cheap: 5 numbers.

Cons

  • A bar can show 72% when its true share is 71.4%.
  • Needs a fixed tie-break rule so the output is stable.

Show counts instead of percentages

Bars sized by share, labels show raw counts: 412, 98, 41...

Pros

  • No rounding at all.
  • Small samples are obvious: 5, 1, 1, 0, 0.

Cons

  • Hard to compare across products with very different totals.
  • Big numbers like 61,204 crowd a narrow bar row on mobile.

What we pick: Size the bars from the exact share, label them with largest remainder percentages so they add to 100%, and put the count in the accessible name and tooltip ("5 stars, 72% of ratings, 5 ratings"). The poll widget guide uses the same method for its result bars and the star rating guide for its histogram, so all three features agree.

ts
// counts[0] is 5 stars ... counts[4] is 1 star
export function percentages(counts: number[]): number[] {
  const total = counts.reduce((a, b) => a + b, 0);
  if (total === 0) return counts.map(() => 0);
  const result = counts.map((c) => Math.floor((c * 100) / total));
  const remainder = counts.map((c) => (c * 100) % total);
  let left = 100 - result.reduce((a, b) => a + b, 0);
  const order = counts
    .map((_, i) => i)
    .sort((a, b) => remainder[b] - remainder[a] || a - b);
  for (const i of order) {
    if (left === 0) break;
    result[i] += 1;
    left -= 1;
  }
  return result;
}
// percentages([5, 1, 1, 0, 0]) -> [72, 14, 14, 0, 0]
// percentages([1, 1, 0, 0, 1]) -> [34, 33, 0, 0, 33]
Largest remainder rounding for the 5 histogram rows, in integer math so equal remainders tie exactly; ties go to the higher star.
Rounding a histogram of 7 ratings: five 5-star, one 4-star, one 3-star. Exact shares are 71.43, 14.29, 14.29, 0 and 0 percent, adding to 100. Rounding each on its own gives 71, 14, 14, 0, 0, which adds to 99 percent. Largest remainder gives 72, 14, 14, 0, 0, which adds to 100 percent: the leftover point went to the 5-star row, which had the largest fraction.exact shareround eachlargest remainder5 stars71.43%71%72%4 stars14.29%14%14%3 stars14.29%14%14%2 stars0%0%0%1 star0%0%0%total100%99%100%+1 to the biggest fraction (0.43)
Rounding each bar on its own loses a point here; largest remainder hands the leftover point to the row with the biggest fraction, so the labels always add to 100%.

Shoppers click the 1-star bar to read the complaints. Make each bar a real link, <a href="?stars=1#reviews">, not a div with a click handler. A link works before JavaScript loads, can be opened in a new tab, has a URL to share, and is announced as a link. With JavaScript loaded, the click is intercepted and the list refetches in place (the filters chapter).

  • A bar with 0 ratings is plain text, not a link: a filter that returns nothing is a dead end.
  • The selected bar shows a pressed look and a "Clear" link next to it, so the filter is visible and reversible.
  • Filtered URLs carry a noindex robots meta tag, so search engines don't index five copies of the page. Google's pagination guide recommends exactly this for filter and sort variations of a list.

Check your understanding · Exercise 2

A product has 3 ratings: one 5-star, one 4-star, one 1-star. Which statements about its summary are true (select all that apply)? Select all that apply.

Ranking products fairly: raw average vs Bayesian average

The summary shows one product's rating. But the same numbers also rank products: "Sort by customer rating" on a category page, a "Top rated" carousel, the order of search results. The reviews feature owns the score these use, so the frontend engineer should know how it's made and what the UI may claim about it.

The naive ranking sorts by raw average. Here is what that does to five headphones in one category:

Raw average puts a product with 2 ratings on top. The Bayesian average pulls small samples toward the category mean.
ProductRatingsRaw averageRaw rankBayesian (m = 4.1, C = 20)Bayesian rank
A25.014.185
D104.924.373
C404.834.572
B9004.644.591
E3,0004.354.304

Product A is first because two people (maybe the seller's friends) gave 5 stars. That's not a ranking, it's a lottery that rewards small samples, and it invites fake reviews: two or three fake 5-star ratings beat 3,000 honest ones.

Decision How should products be ranked by rating?

Raw average

ORDER BY sum / count DESC.

Pros

  • Simple, easy to explain.

Cons

  • Products with 1 or 2 ratings fill the top.
  • Cheap to game with a few fake reviews.

Raw average with a minimum count

Only rank products with at least N ratings (say 20); the rest go after them or are hidden.

Pros

  • Simple, removes the worst cases.

Cons

  • A hard cliff: 19 ratings is invisible, 20 is top.
  • New products can never climb, so they never get the ratings to qualify.

Bayesian average

Pretend every product starts with C ratings at the category mean m, then add its real ratings: (C * m + sum) / (C + n).

Pros

  • Smooth: each new rating moves the score a little.
  • Small samples stay near the mean until they earn their place.
  • One extra stored number per category.

Cons

  • Needs tuning of C and m per category.
  • The score is not what customers said, so it can't be displayed as "the rating".

Wilson lower bound on positive share

Treat 4 and 5 stars as positive, the rest as negative, and sort by the lower bound of the positive share.

Pros

  • Statistically principled for yes/no data; the comments thread guide explains it.

Cons

  • Throws away the difference between 4 and 5 stars and between 1 and 3.
  • Harder to explain to merchants.

What we pick: Rank by the Bayesian average, computed on the server. Display the raw average and count on every card, because that's what customers actually said. Never show the Bayesian score as a star rating: a product with two 5-star reviews must not appear as "4.2 stars".

Some stores go further and display a weighted rating. Amazon, for example, says its star ratings come from models that weigh factors such as how recent a review is and whether the reviewer bought the product, not a simple average. That is a valid product choice, but it needs a clear explanation on the page. In an interview, displaying the raw average and ranking by a separate score is simpler to build, explain, and audit.

How the Bayesian average works

Think of C as the number of "imaginary average reviews" every product starts with. Product A has 2 real ratings of 5 and 20 imaginary ratings of 4.1: (20 * 4.1 + 10) / 22 = 92 / 22 = 4.18. Product B has 900 real ratings averaging 4.6, which swamp the 20 imaginary ones: (82 + 4,140) / 920 = 4.59. With few ratings, the prior dominates; with many, the data does.

  • m is the mean rating of the category (headphones may average 4.1, printers 3.6). Using a site-wide mean would punish categories that are rated harshly.
  • C sets how skeptical you are. Common starting points are the 25th percentile of rating counts in the category (Algolia's guide uses it) or the mean count. Half our products have 5 or fewer ratings, so the 25th percentile would barely smooth anything; we start at C = 20 and tune it by checking where new and popular products land. Larger C means new products need more ratings to climb.
  • Store count and sum per product, not the average, so every new rating is an O(1) update and both scores can be computed exactly.
ts
type RatingStats = { count: number; sum: number };

export function bayesianScore(
  { count, sum }: RatingStats,
  categoryMean: number, // m, for example 4.1
  priorWeight: number, // C, for example 20
): number {
  return (priorWeight * categoryMean + sum) / (priorWeight + count);
}

// Display uses the raw numbers:
// rawAverage = sum / count, shown with the count
The ranking score. The client never computes it; it only sends sort=rating.
Category mean m

C = 20 and m = 4.1: product B leads, and A with 2 ratings falls to the bottom.

Products ranked by Bayesian average with C = 20 and m = 4.1: 1. B, 900 ratings, raw 4.60, score 4.59, raw rank 4; 2. C, 40 ratings, raw 4.80, score 4.57, raw rank 3; 3. D, 10 ratings, raw 4.90, score 4.37, raw rank 2; 4. E, 3000 ratings, raw 4.30, score 4.30, raw rank 5; 5. A, 2 ratings, raw 5.00, score 4.18, raw rank 1.rankratingsraw averageBayesianraw rank1. B9004.604.5942. C404.804.5733. D104.904.3724. E3,0004.304.3055. A25.004.181
C = 20 and m = 4.1: product B leads, and A with 2 ratings falls to the bottom.

Check your understanding · Exercise 3

With m = 4.1 and C = 20, which statements are true (select all that apply)? Select all that apply.

Sorting the list: what "most helpful" means

Shoppers read the first few reviews and stop, so the default sort is the most important ranking on the page. We offer four sorts: Most helpful (the default), Newest, Highest rating, and Lowest rating. The last three are plain columns with a tie-breaker: ORDER BY created_at DESC, id DESC or ORDER BY stars DESC, helpful_score DESC, id DESC. The id at the end makes the order total, which cursor pagination needs (next chapter).

Most helpful is the interesting one. The naive version is ORDER BY helpful_votes DESC. It has two failures that grow with time.

  • Rich get richer. The first reviews to reach the top get read, so they get votes, so they stay on top. A review with 400 votes from launch week can't be beaten by a better one written later, because nobody scrolls far enough to vote for it.
  • Stale advice. A 3-year-old review says "the battery lasts 2 days", but the maker changed the battery last spring. The product changed; the top of the list didn't.

Decision How should "Most helpful" rank reviews?

Raw helpful count

Sort by the number of Helpful votes.

Pros

  • Simple and easy to explain.

Cons

  • Rich get richer; new reviews can't climb.
  • Old reviews about an old version stay on top.

Wilson lower bound

Collect Helpful and Not helpful votes, sort by the lower bound of the helpful share.

Pros

  • Fair to reviews with few votes; well understood (see the comments thread guide).
  • A review with 9 of 10 helpful beats one with 50 of 100.

Cons

  • Needs a Not helpful button, which some large stores, Amazon among them, no longer show.
  • Ignores age.

Votes with time decay

(helpful + 1) * 0.5 ** (ageDays / halfLife): a review's weight halves every halfLife days.

Pros

  • New good reviews can overtake old ones.
  • Works with a Helpful-only button.
  • One knob to tune.

Cons

  • Very old classics sink even when still true.
  • Scores change daily, so the order shifts under pagination.

Learned ranking

A model scores reviews from votes, age, length, photos, verified status, and reading behavior.

Pros

  • Can use many signals at once.

Cons

  • Needs data, training, and monitoring.
  • Hard to explain or debug; overkill for an interview answer.

What we pick: Use votes with time decay, with a half-life of about a year, and small multipliers for quality signals (verified purchase, 200+ characters, photos). If the product collects Not helpful votes, replace the vote count with the Wilson lower bound and keep the decay. The server computes it in a batch job each hour and stores it; the client only sends sort=helpful.

A worked example with a one-year half-life

Decay doesn't erase votes, it discounts them. R1 still ranks high, but no longer locks the top spot.
ReviewAgeHelpful votesScore (votes + 1) * 0.5 ** (age / 365)Raw rankDecayed rank
R1: launch week classic3 years400401 x 0.125 = 50.112
R4: detailed one-year review400 days120121 x 0.468 = 56.621
R2: notes the new battery60 days2526 x 0.892 = 23.233
R3: brand new5 days23 x 0.991 = 3.044

This formula discounts each review by its own age, so new votes on an old review also count for little. A variant decays each vote from the moment it was cast, the exponential decay Evan Miller describes for hot lists: an old review that keeps earning votes stays high, while launch-week votes fade. It needs a timestamp per vote, which the votes table has anyway, and the same hourly job.

Two details make either version work in practice. The + 1 gives every review a small starting score so zero-vote reviews can still be ordered by age. And the score is recomputed in bulk, not on every vote, so one vote doesn't make reviews jump while someone is reading. Try the half-life yourself:

Half-life

One-year half-life: R4 edges out R1, whose 400 votes are 3 years old.

Reviews sorted by helpful score with a 365-day half-life: 1. R4, 400 days old, 120 votes, score 56.6; 2. R1, 1095 days old, 400 votes, score 50.1; 3. R2, 60 days old, 25 votes, score 23.2; 4. R5, 30 days old, 9 votes, score 9.4; 5. R3, 5 days old, 2 votes, score 3.0. Top: R4.rankage (days)votesscore1. R440012056.62. R1109540050.13. R2602523.24. R53099.45. R3523.0(v + 1)x decay
One-year half-life: R4 edges out R1, whose 400 votes are 3 years old.

Balance: the top positive and the top critical review

Ratings bunch at 5 stars, so the most helpful reviews are mostly positive. Some stores add a small block above the list: the most helpful positive review (4 or 5 stars) next to the most helpful critical one (1 to 3 stars). Amazon has shown a block like this, labelled Top positive review and Top critical review.

It costs one extra query, served from the same cache as the summary, and it answers the shopper's real question: "what do unhappy buyers say?" It also shows that critical reviews are not buried.

Check your understanding · Exercise 4

Which statements about the helpful sort are true (select all that apply)? Select all that apply.

Filters, the URL, and pagination

A shopper with a question filters: "1-star reviews from verified buyers of the black one that mention battery". That is five dimensions (stars, verified, photos, variant, keyword) plus a sort. The search results page guide covers facets in depth; here we need a small version of the same machine inside a product page.

Where the list state lives

Decision Where should sort and filter state live?

Component state

useState in the reviews section.

Pros

  • Simplest to write.

Cons

  • Lost on reload and on Back from a review photo page.
  • Can't be shared: "look at the 1-star reviews" sends the wrong view.
  • Histogram links need a URL anyway.

A global client store

A store slice holds filters; components subscribe.

Pros

  • Any component can read it, such as a sticky "filtered by" bar.

Cons

  • Same reload and sharing problems.
  • Two sources of truth once the histogram links write the URL.

The URL query string

?stars=1&verified=1&variant=black&q=battery&sort=recent; components read it, controls write it.

Pros

  • Shareable, bookmarkable, survives reload and Back.
  • The server can render the filtered list directly.
  • Histogram bars become plain links.

Cons

  • Must parse and validate untrusted input.
  • Too many history entries if every keystroke pushes one.

What we pick: The URL is the source of truth (the url-as-state lesson explains the pattern). A server state cache keyed by the parsed params holds the results, so going back to a previous filter is instant. Clicks on filters push a history entry; typing in the keyword box replaces the entry after a 300 ms pause, so Back doesn't replay every letter.

ts
const SORTS = ["helpful", "recent", "high", "low"] as const;
type Sort = (typeof SORTS)[number];

export type ReviewQuery = {
  sort: Sort; stars: 1 | 2 | 3 | 4 | 5 | null;
  verified: boolean; media: boolean;
  variant: string | null; q: string;
};

export function parseQuery(p: URLSearchParams): ReviewQuery {
  const sort = SORTS.find((s) => s === p.get("sort")) ?? "helpful";
  const n = Number(p.get("stars"));
  const stars = n >= 1 && n <= 5 && Number.isInteger(n) ? (n as 1 | 2 | 3 | 4 | 5) : null;
  return {
    sort, stars,
    verified: p.get("verified") === "1",
    media: p.get("media") === "1",
    variant: p.get("variant")?.slice(0, 40) || null,
    q: (p.get("q") ?? "").trim().slice(0, 100),
  };
}
Parse untrusted params into a typed, normalized state; defaults are left out of the URL.

Normalizing matters for caching too: ?sort=helpful&stars= and ? are the same view, so they must produce the same cache key. Serialize back in a fixed key order and drop defaults.

Pages, Load more, or infinite scroll

A bestseller has 85,000 reviews; nobody reads them all, but some shoppers read 50. Offset pagination (?page=37) gets slow on deep pages and shows duplicates when new reviews arrive between requests. Cursor pagination (the pagination lesson) returns an opaque nextCursor that encodes the last row's sort key and id, so each page continues exactly where the last one stopped.

For the interaction, infinite scroll is a poor fit: the reviews sit above related products and the footer, which would keep running away. Use a Load more button that appends 10 reviews with a cursor. For search engines and no-JavaScript visitors, the server also renders plain numbered review pages at stable URLs (the SEO chapter).

Counts that match the filters

Show what the filters did: "87 reviews match: 1 star, verified, 'battery'". Star counts come free from the summary. Combined counts (1 star and verified and with photos) come from the search index that serves the list, which returns them with the results, the same way facet counts work on a search page.

For very large keyword matches, show "1,000+" instead of paying for an exact count. And when a filter combination returns 0, say so and offer a one-click "Clear filters", never an empty box.

What happens when a filter changes

  1. The click updates the URL with history.pushState (through the router).
  2. The list reads the new params; if the cache has that key, it shows it at once.
  3. Otherwise it aborts any request still in flight with AbortController, so a slow old response can't overwrite a new one, and starts a new one.
  4. The old results stay on screen, dimmed and marked aria-busy="true", instead of a skeleton that collapses the height and shifts the page.
  5. Results arrive; the list swaps, and a polite live region announces "87 reviews". Focus stays on the control the user pressed.
State machine of the review list. It starts in Ready with the server-rendered first page. A filter or sort change moves it to Refetching, where the old list is dimmed; a newer change while refetching aborts the old request and restarts. Results return it to Ready. A failure moves it to Error, which keeps the old list with a Retry button; retry goes back to Refetching. From Ready, Load more moves to Loading more; the page arrives and it returns to Ready; a failure moves to Load more failed with an inline Retry, which goes back to Loading more.ReadySSR first pageRefetchingold list dimmedErrorold list + RetryLoading morebutton busyMore failedinline Retryfilter or sortresultsfails / retryLoad morepage arrivesfails / retrynew filter: abort, restart
The list never goes blank: refetching dims the old results, and a failed Load more keeps every loaded review with an inline Retry. A new filter during a refetch aborts the old request.

The lab below puts the pieces together: histogram links, filter toggles, sorting, the URL they write, and helpful votes (the helpful votes chapter covers them in depth). Turn on "Fail next vote" to watch a rollback.

8 reviews. Press a histogram bar, a filter, or Helpful. The order uses the stored score, so your votes don't reorder the list.

Reviews list at /p/aria-headphones#reviews. 8 reviews shown: r1, 5 stars, Best noise cancelling, verified, photos, 120 helpful; r2, 1 stars, Strap broke in a month, verified, 52 helpful; r5, 2 stars, Loud but uncomfortable, verified, photos, 30 helpful; r8, 5 stars, Crisp sound on calls, verified, photos, 11 helpful; r3, 4 stars, Great, a bit heavy, not verified, photos, 8 helpful; r7, 3 stars, Fine for the price, verified, 4 helpful; r4, 5 stars, Battery lasts all week, verified, 2 helpful; r6, 5 stars, Gift for my dad, not verified, 15 helpful./p/aria-headphones#reviews5 starr1: Best noise cancellingverified, photos1201 starr2: Strap broke in a monthverified522 starr5: Loud but uncomfortableverified, photos305 starr8: Crisp sound on callsverified, photos114 starr3: Great, a bit heavyphotos83 starr7: Fine for the priceverified45 starr4: Battery lasts all weekverified25 starr6: Gift for my dad15
8 reviews. Press a histogram bar, a filter, or Helpful. The order uses the stored score, so your votes don't reorder the list.

Check your understanding · Exercise 5

A shopper filters to 1-star reviews, opens a photo, and presses Back. Which choices make that work well (select all that apply)? Select all that apply.

The review card: long text, photos, translations, and replies

One review packs a lot: stars and a title, the author and date, the variant they bought ("Color: Black"), badges, the text, pros and cons, photos, maybe a translation note and a reply from the brand, then Helpful and Report. Each piece has a decision behind it.

Anatomy of one review card, top to bottom: stars and title, 1 star: Strap broke in a month; author, date, Verified badge and variant Black; the review text clamped to 6 lines with white-space pre-line; a Read more button; photo thumbnails; an indented response from the brand Aria; and Helpful 52 with Report. Notes on the right: the server sets badges; CSS clamp means no layout shift; thumbnails are lazy and sized; Helpful is a PUT applied optimistically.1 star: Strap broke in a monthMaya R., Sep 14, Verified, BlackReview text, clamped to 6 lineswhite-space: pre-lineRead morePhoto thumbnails (3)Response from AriaHelpful 52, Reportserver sets badgesCSS clamp, no shiftlazy, sized thumbsPUT, optimistic
Most of the card is static server HTML. Only Read more, the translation toggle, and Helpful need JavaScript, and each has its decision: server-set badges, CSS clamping, sized lazy images, and an idempotent vote.

Read more without shifting the page

Reviews range from 3 words to 5,000 characters. Ten long reviews would make the list endless, so long ones are cut to about 6 lines with a Read more button. The naive way measures the text in JavaScript after render, then cuts it and adds the button. On a product page that means every long review renders full height, then jumps shorter after hydration: a layout shift on every card, counted against CLS.

Decision How do we clamp long reviews?

Measure and cut in JavaScript

Render full text, measure scrollHeight, cut and add a button.

Pros

  • Exact: the button only appears when needed.

Cons

  • Layout shift after hydration.
  • Work on the main thread for every card.

Server cuts the string

Send the first 300 characters plus an ellipsis; fetch the rest on click.

Pros

  • Stable layout from the first paint.
  • Smaller HTML.

Cons

  • 300 characters is 3 lines on desktop, 8 on a phone.
  • The full text is not in the HTML for search engines or Find in page.
  • A network trip to read more.

CSS line clamp, server decides the button

Full text in the HTML, clamped with -webkit-line-clamp: 6; the server adds a Read more button when the text is over about 400 characters or has several line breaks.

Pros

  • Clamped from the first paint, no shift.
  • Full text in the HTML for SEO.
  • Expand is instant, no request.

Cons

  • A few short reviews get a button that reveals one more line.
  • Needs the -webkit- prefixed form, which all current browsers support.

What we pick: CSS line clamp with a server-decided button. The button is a real <button aria-expanded="false"> that removes the clamp. Expanding pushes the rest of the list down, but a shift within 500 ms of a user input is excluded from CLS, so it doesn't count. On "Read less", if the card's top is now above the viewport, scroll it back into view so the reader doesn't lose their place.

css
.review-body {
  white-space: pre-line;
  overflow-wrap: anywhere;
}
.review-body[data-clamped="true"] {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 6;
  overflow: hidden;
}
The clamp. white-space keeps the author's line breaks without rendering any HTML they typed.

Review text is plain text, always. Render it as a text node, never as HTML, keep line breaks with white-space: pre-line, and don't turn URLs into links (spammers love review links). The xss lesson covers why dangerouslySetInnerHTML on user content is the classic hole.

Photos and videos

  • Thumbnails are small (72 by 72 CSS pixels), with width and height set so they reserve space, loading="lazy", and a srcset for 1x and 2x screens. The image-optimization lesson covers formats.
  • Each thumbnail is a button that opens a lightbox dialog with the full image, the review's other photos, and a link back to the review text. The image carousel guide covers the swipe, keyboard, and preloading inside it.
  • Above the list, a strip of "Photos from customers" collects media across all reviews: shoppers often want to see the product on real people before reading. It is its own paginated endpoint.
  • Videos show a poster image and use preload="none", so nothing downloads until the shopper presses play. Never autoplay with sound.
  • Alt text: shoppers rarely write it, so default to "Photo 2 of 4 from Maya's review" rather than an empty or file-name alt.

Translated reviews with Show original

A Spanish shopper sees reviews from Germany, Japan, and the US. The server translates reviews into the viewer's locale with a machine translation service, caches each translation by review id and language, and sends it in the list response with the original.

The card shows the translation with a line: "Translated from German. Show original". The toggle swaps text locally, with no request.

  • Set lang on the review body to the language actually shown (lang="de" for the original), so screen readers switch voice and pronunciation and the browser hyphenates the text correctly. Google ignores lang and detects language from the visible text, so this attribute is for people, not for ranking.
  • Keep keyword search working in both: index the original and the translations, so "batería" finds a German review about the battery.
  • The translation-architecture lesson covers the interface strings; user content is different, because it can't be translated ahead of time by people.

Seller and brand responses

A brand can reply once to a review: "Sorry about the strap. Contact us for a free replacement." Show it under the review, indented, with a clear label ("Response from Sony, 3 days later") and a different background, so nobody mistakes it for the reviewer. It's one reply, not a thread: open discussion is the questions feature, and nested replies are the comments thread guide. Replies go through moderation too, and a seller can't edit or hide the review they reply to.

Badges

Badges are claims, and the server makes them. Verified purchase means the order system shows this account bought this product. Received free product or Incentivized review marks reviews written in exchange for something; disclosure is required (the trust chapter). Employee marks reviews by staff. The client renders badges from a list of codes in the response and never infers them, because a badge that is wrong is worse than no badge.

Check your understanding · Exercise 6

Which choices for the review card are sound (select all that apply)? Select all that apply.

Helpful votes: instant, idempotent, one per person

Under each review: "52 people found this helpful" and a Helpful button. It's the input to the helpful sort, so it must be fair, and it's tapped a lot, so it must feel instant. The poll widget guide solved the same shape of problem for poll votes; this chapter reuses its rules (a retry never counts twice, an unknown result is retried, a clear failure rolls back) with a simpler request.

The naive version is POST /reviews/r_81/helpful, which adds one to a counter. A double tap adds two. A retry after a timeout adds another. Undo needs a second endpoint, and the server can't tell "vote" from "vote again".

Decision What should the vote request look like?

POST that increments

POST /reviews/{id}/helpful adds 1; another endpoint removes it.

Pros

  • Obvious to write.

Cons

  • Not idempotent: retries and double taps count twice.
  • The client must know which endpoint matches the current state.

PUT the desired state

PUT /reviews/{id}/my-vote with { "helpful": true } or false. The server stores one row per (user, review).

Pros

  • Idempotent: sending the same body twice has the same effect as once.
  • Safe to retry; toggling is just a different body.
  • One vote per user is a unique key in the database.

Cons

  • The response must carry the new count, since the client can't compute it from deltas.

POST with an Idempotency-Key

Each vote attempt sends a random Idempotency-Key header; the server stores the response per key and replays it for a retry. This is the poll widget guide's approach.

Pros

  • Works when the action is an event, or when voters are anonymous and have no per-user row to overwrite.
  • The same retry safety as PUT.

Cons

  • The server must store keys and responses for a while.
  • Toggling needs extra care: a new choice gets a new key, so ordering between attempts is the client's job.

What we pick: PUT the desired state. The client says what it wants ("my vote is helpful"), not what to do ("add one"), so retries, double taps, and two open tabs all end in the same place. The server answers with { "helpful": true, "helpfulCount": 53 } and the client takes that as truth. The poll widget uses an idempotency key because its voters are often anonymous, known only by a cookie; here every voter is signed in, so the vote is a plain yes-or-no value per (user, review).

The optimistic toggle and its rollback

Waiting 300 ms for the server before the button changes feels broken. So the button flips and the count moves at once, then the request confirms it (the optimistic-updates lesson). If it fails, the button and count go back to the last state the server confirmed, and a short message says "Couldn't save your vote".

Fast toggles need one more rule: only the latest intent matters. Keep at most one request in flight per review; if the user toggles again meanwhile, remember the latest desired state and send it when the first returns, if it differs. Sending one at a time also means a slow request for an old state can't land after a newer one.

Failures are not all alike, as the poll widget guide explains. A network error or a 5xx is unknown: the vote may have been saved. Because PUT is idempotent, the client can safely resend the same request two or three times with backoff before rolling back. A 429 (rate limited) or another 4xx is a clear answer, so the client rolls back at once.

ts
type Saved = { helpful: boolean; helpfulCount: number };
const lastSaved = new Map<string, Saved>(); // the server's latest answer per review
const wanted = new Map<string, boolean>(); // the shopper's latest intent
const inFlight = new Set<string>();

// The guess: the last saved count, plus or minus one if the intent differs.
const guess = (s: Saved, want: boolean): Saved => ({
  helpful: want,
  helpfulCount: s.helpfulCount + (want === s.helpful ? 0 : want ? 1 : -1),
});

export async function setHelpful(id: string, want: boolean) {
  wanted.set(id, want);
  render(id, guess(lastSaved.get(id)!, want)); // instant
  if (inFlight.has(id)) return; // the running loop picks up the new intent
  inFlight.add(id);
  try {
    let saved = lastSaved.get(id)!;
    while (wanted.get(id) !== saved.helpful) {
      saved = await putVote(id, wanted.get(id)!); // retries network errors and 5xx
      lastSaved.set(id, saved);
    }
    render(id, saved);
  } catch {
    render(id, lastSaved.get(id)!); // roll back to the last confirmed state
    toast("Couldn't save your vote. Try again.");
  } finally {
    inFlight.delete(id);
    wanted.delete(id);
  }
}
Optimistic vote with the latest intent: at most one PUT in flight per review, and the server's answer always wins over the guess.
Sequence diagram of a helpful vote between the Shopper, the Card state, and the Votes API. The shopper taps Helpful; the card shows pressed and 53 at once. The card sends PUT my-vote with helpful true; the API answers 200 with helpful true and count 53, which the card keeps. Failure path, instead of the 200: the same PUT gets 429 Too Many Requests; the card rolls back to 52, not pressed, and shows a message.ShopperCard stateVotes APItap Helpfulpressed, 53 (instant)PUT /my-vote helpful=true200 helpful, count 53failure path: no 200PUT /my-vote helpful=true429 Too Many Requestsrollback to 52 + message
The guess is shown at once, and the server's answer always replaces it. A 429 rolls back at once to the last confirmed state and says so; a network error or 5xx is first retried, which is safe because PUT is idempotent.

One vote per person, and abuse

  • Signed-in only. A signed-out tap opens sign-in and then sends the vote, so intent isn't lost. The server enforces one row per (user, review); the client can't.
  • No votes on your own review. Hide the button for the author, and reject it on the server too.
  • Rate limits. Over about 30 votes a minute from one account gets 429 Too Many Requests; the client rolls back and shows the message, without retrying in a loop.
  • Counted is not the same as ranked. Votes from brand new accounts, or from accounts linked to the seller, can be shown in the count but given little or no weight in the helpful score. The score is computed server-side, so the client needs no change for this.

Check your understanding · Exercise 7

A shopper taps Helpful three times fast on a slow network: on, off, on. Which designs end correctly with one vote (select all that apply)? Select all that apply.

Writing a review: eligibility, the form, drafts, and pending

Many reviews start from a follow-up email after delivery ("How are your headphones?"), a few from the product page. Both open the same form. Its job is to collect a good review with little friction, never lose what the customer typed, and be clear about what happens after Submit.

Eligibility first

Before showing the form, the client asks GET /products/{id}/review-eligibility. The answer is one of: eligible (with the variant they bought, to prefill), already reviewed (with the review id, so we open it for editing instead), not purchased (allowed in some categories, without the Verified badge), or blocked (account restrictions). Don't hide the Write a review button when someone isn't eligible; show it and explain why on press. A missing button looks like a bug.

The form

  • Stars, required, first. The star rating guide covers the radio-group input. Pressing a star from the email link can preselect it via ?stars=4.
  • Title up to 120 characters, text up to 5,000 with a live counter, and optional pros and cons as short lines.
  • Photos and video, uploaded as soon as they are picked, so Submit doesn't wait on them. The file uploader guide covers pre-signed uploads and progress. The server strips location data (EXIF GPS) from photos, because a review photo taken at home can reveal an address.
  • Guidelines as 4 short lines beside the text box ("Talk about the product, not the delivery", "No personal details"), with a link to the full policy. A wall of rules above the form gets skipped.
  • Validation on the client for speed and on the server for truth (the input-validation lesson). A gentle client hint when the text looks like an email address or phone number saves a rejection later.

Decision Where should an unfinished review be saved?

Nowhere

The form lives in component state only.

Pros

  • Nothing to build.

Cons

  • A tab crash, a sign-in redirect, or an accidental Back loses 400 careful words.

localStorage on this device

Save { stars, title, body, uploadIds } under review-draft:{userId}:{productId}, debounced by 1 second.

Pros

  • Survives reload, crash, and the sign-in round trip.
  • No server work; works offline.

Cons

  • Doesn't follow the user to another device.
  • Must be cleared on submit and on sign-out (shared computers).

Server drafts

PUT /review-drafts/{productId} every few seconds.

Pros

  • Follows the user across devices.

Cons

  • A lot of writes for a feature most people finish in one sitting.
  • Another state to moderate and expire.

What we pick: localStorage, keyed by user and product, cleared on submit and sign-out. On open, show "We restored your draft" with a Discard button. Photos are already on the server as uploads; the draft only keeps their ids, which expire after 7 days. The multi-step form guide covers draft versions and restore prompts in more depth.

Submit, then pending

Submit sends POST /reviews with an Idempotency-Key header (a random id made when the form opened), so a double click or a retry creates one review, not two. The server answers 202 Accepted with { id, status: "pending" }: accepted, not yet public. Automated checks run in seconds; some reviews wait hours or a few days for a person.

The author must see their review right away, or they'll write it again. The client inserts it at the top of their own view with a clear badge: "Pending: only you can see this. We'll email you when it's published." Other shoppers don't see it, and the summary doesn't count it. When moderation finishes, the author gets an email and an in-app notification, and the next load shows it published, or rejected with the reason and an Edit button.

State machine of a review. Draft, saved in localStorage, goes to Submitting on submit, with an idempotency key; a failure returns it to Draft. The server answers 202 and the review is Pending, visible only to the author. If approved it becomes Published; if rejected it becomes Rejected, with a reason and an Edit button, and editing sends it back to Pending. Editing a published review creates Edit pending, while the old version stays live; when approved it returns to Published. A note says a review can be deleted from any saved state: the author sees it gone at once and the summary updates within minutes.submit202approvedDraftlocalStorageSubmittingIdempotency-KeyPendingauthor onlyPublishedpublicfailsRejectedreason + EditEdit pendingold version liverejectedediteditokDelete: from any saved stateauthor sees it gone at oncesummary updates in minutes
Only Published is public. Pending, Rejected, and Edit pending are visible to the author alone, and an edit never replaces the live version until it passes the same checks.
Sequence diagram of submitting a review between the Author, the Form, the Reviews API, and Moderation. The author presses Submit; the form sends POST reviews with an idempotency key; the API queues moderation checks and answers 202 pending; the form shows the review to the author marked Pending, only you can see this. Failure path: when the first reply is lost, the form retries with the same key and the API answers 202 with the same review, so no duplicate is made. Later, moderation emails the author that the review is published.AuthorFormReviews APIModerationpress SubmitPOST /reviewsqueue checks202 pendingPending: only youfailure: reply lostretry, same key202, same reviewemail: published
The Idempotency-Key is made when the form opens, so every retry of the same submit maps to one review. The author sees Pending at once; everyone else sees it only after moderation.

Editing and deleting

Edits go back through moderation, otherwise anyone could post a clean review and edit in spam later. While the edit is pending, the last published version stays public and the author sees both, labelled. Changes to the stars alone go through the fast automated checks.

Deleting asks for confirmation, removes the review from the author's view at once, and the summary catches up when its cache refreshes (minutes). Helpful votes on a deleted review are dropped with it.

Check your understanding · Exercise 8

Which choices for the review form are sound (select all that apply)? Select all that apply.

Moderation and trust: what the UI must and must not do

A reviews section is worth exactly as much as shoppers trust it. Moderation is mostly a backend system, but it decides states the frontend shows, and the law now shapes some UI choices directly. A frontend engineer who knows these rules avoids building something illegal by accident.

The moderation pipeline, from the client's view

  1. Automated filters run within seconds: profanity and abuse, personal data (phone numbers, emails), links, duplicate text across accounts, and a language check.
  2. Risk signals score how likely the review is fake. Common ones in industry write-ups include a burst of 5-star reviews in a short window, new accounts with no other activity, many reviews from one device or network, reviews that copy each other, and links between the reviewer and the seller. No single signal proves anything, so they feed a score, not an automatic verdict.
  3. Human review handles the uncertain middle and every report from shoppers.
  4. Outcome: published, or rejected with a reason the author can act on. Reports from shoppers can send a published review back for review.

For the client this means three states it must render (pending, published, rejected) and one action (Report, which opens a short dialog with reasons and sends POST /reviews/{id}/reports). Reported reviews stay visible until a person decides: hiding on the first report would let a competitor erase honest reviews.

The FTC rule on fake reviews

In August 2024 the US Federal Trade Commission (FTC) finalized its Trade Regulation Rule on the Use of Consumer Reviews and Testimonials (16 CFR Part 465). It took effect on October 21, 2024, and lets the FTC seek civil penalties for each violation. Among other things, it bans fake reviews (from people who don't exist or had no real experience with the product), buying reviews conditioned on a positive or negative sentiment, undisclosed reviews by a company's officers and managers, and review suppression.

Suppression has a precise meaning in the rule: implying that the reviews on display are all or most of the reviews submitted while some are withheld because of their rating or negative sentiment. Withholding reviews for reasons applied to every rating alike, such as abuse, another person's personal information, clearly false content, or a review the seller reasonably believes is fake, is allowed. Several parts turn into frontend requirements:

RequirementWhat it means for the UI
No suppression based on rating or sentiment (Part 465)Never filter out or delay reviews because they are negative. Moderation may hide content for reasons like abuse, personal information, or clearly false claims, applied the same to all ratings.
No distorted presentation (Endorsement Guides)The FTC's Endorsement Guides also warn against boosting or organizing reviews in a way that distorts what customers think. The default sort must not quietly bury low ratings.
No incentives conditioned on sentiment (Part 465)A review request may offer nothing, or the same thing for any review. Never "5 stars gets you a coupon".
Insider reviews need disclosure (Part 465)An Employee badge, set by the server from account data.
Incentivized reviews must be disclosed (Endorsement Guides)A visible "Received free product" label on the card. Google's review snippet guidelines also exclude undisclosed incentivized reviews from rich results.

Review gating is the trick of asking "Did you enjoy your purchase?" first and only sending happy customers to the review form, while unhappy ones go to a private feedback box. It skews the ratings without hiding a single review.

The FTC's Endorsement Guides, revised in 2023, say advertisers should not procure, suppress, boost, or organize reviews in a way that distorts what consumers think, and Google's policy for business reviews forbids selectively asking happy customers for reviews. So the post-purchase email links every customer to the same review form, and the form never branches on the stars before submit.

Check your understanding · Exercise 9

Which of these product decisions would raise problems under the FTC rule or trust guidelines (select all that apply)? Select all that apply.

Architecture, data model, and API

With the decisions made, we can draw the parts. One idea shapes the whole architecture: split public data from personal data.

The summary and the review list are the same for every shopper with the same filters, so a content delivery network (CDN) can cache them. "Did I vote on this?" and "my pending review" differ per user and must never be cached publicly. Mixing them in one response would make the whole section uncacheable, at 1,500 page views a second.

Architecture of the reviews feature. In the browser: ReviewsSummary, rendered as server HTML; ReviewFilters, which writes the URL; the URL with the query, which ReviewList reads; ReviewList, which reads from the Query cache keyed by normalized query; a Personal overlay holding my votes and my review; and an API client using fetch with AbortController, which both the cache and the overlay call. On the edge and server: a CDN for public data cached 5 minutes, the Reviews BFF that translates and shapes responses, a Search index that returns lists and counts, and a Votes service. Public requests go through the CDN to the BFF; private requests go straight to the BFF.BrowserReviewsSummaryserver HTMLReviewFilterswrites URLReviewListreads URLURL?stars=1&sort=Query cacheby normalized queryPersonal overlaymy votes, my reviewAPI client: fetch + abortEdge and serverCDNpublic, 5 minReviews BFFtranslate, shapeSearch indexlists, countsVotes service
Filters only write the URL and the list only reads it, so they can't disagree. Public data goes through the CDN; the personal overlay and every write go straight to the BFF.
PartRuns whereResponsibility
ReviewsSummaryServer renderAverage, count, histogram links, and the JSON-LD (structured data in a script tag) for search engines. No client JavaScript beyond the links.
ReviewFiltersClient islandSort select, star, verified, photos, variant toggles, and keyword box. Reads and writes the URL, nothing else.
ReviewListServer render, then clientFirst page rendered on the server; later pages and filter changes fetched on the client through the query cache.
ReviewCardServer HTML with small islandsStatic markup, plus three small interactive parts: Read more, translation toggle, and Helpful button.
MediaLightboxLazy chunkLoaded on the first thumbnail press, so it costs nothing until used.
Query cacheClient memoryResults keyed by product and normalized query; dedupes requests, keeps pages for Back.
Personal overlayClientFetches myVote for visible ids and the shopper's own review, then merges them into the cards.
ReviewForm routeSeparate page and chunkEligibility, fields, uploads, draft store in localStorage, submit with idempotency key.
Reviews BFFServerA backend for frontend (BFF): shapes responses for the page, applies translation, and sets cache headers; talks to the search index, votes, and moderation services.

Why these boundaries? The summary is server-only because it must be in the HTML for search engines and it never changes on the client. Filters only touch the URL, so they can't drift from it. The form is its own route because it's large (uploads, validation, rich input) and only a few percent of visitors open it; shipping it with every product page would be waste.

Data flow. Read path, left to right: the Index sends rows and counts to the BFF, which sends a JSON page to the CDN, which sends cached JSON to the Query cache, which passes props to ReviewList. ReviewList renders ReviewCards. Write path, right to left: a ReviewCard toggle goes to the API client, which sends PUT my-vote to the BFF, which upserts a row in Votes; an hourly batch Score job reads votes and writes new scores back to the Index. A note says that on the first visit the server renders the summary and page 1 into HTML, and the client fetches page 2 and filter changes through the CDN.IndexBFFCDNQuery cacheReviewListrows, countsJSON pagecached JSONpropsread pathReviewCardAPI clientBFFVotesScore jobtogglePUT my-voteupsert rowhourly batchnew scoresrendersFirst visit: the server renders summary and page 1 into HTML,then page 2 and filter changes are fetched through the CDN.
Reads flow through caches; writes skip them. Votes reach the ranking only through the hourly score job, so one vote never reorders the list under a reader.

Caching follows the split. The summary and anonymous list pages go out with Cache-Control: public, s-maxage=300, stale-while-revalidate=600, so the CDN serves them and refreshes in the background. A new review shows up in the summary within about 5 minutes, which is fine for a slow-changing feature.

Personal responses use Cache-Control: private, no-store. Writes (votes, reviews, reports) go straight to the origin and never through a cache.

Check your understanding · Exercise 10

Why fetch the shopper's own votes in a separate request instead of including myVote in every list response (select all that apply)? Select all that apply.

Data model and API

The client holds five kinds of data, and each lives in a different place. Getting this right avoids the most common bugs: a filter that the URL and the list disagree on, a vote that shows pressed for the wrong user, a draft that leaks between accounts.

DataLives inWhy there
Sort and filtersURL query stringShareable, survives reload and Back, readable by the server render.
Summary, review pagesQuery cache (memory), keyed by product and normalized queryServer data that many components read; reused on Back.
My votes, my reviewQuery cache under a private keyPer user; dropped on sign-out.
Draft reviewlocalStorage per user and productSurvives crashes and redirects.
Read more, show original, lightbox openComponent statePurely visual and short-lived.
ts
type ReviewSummary = {
  productId: string;
  ratingCount: number; // all ratings, with or without text
  reviewCount: number; // only the ones with text
  ratingSum: number; // average = ratingSum / ratingCount
  histogram: [number, number, number, number, number]; // 5 stars first
};

type Badge = "verified_purchase" | "free_product" | "employee";

type Review = {
  id: string; productId: string;
  stars: 1 | 2 | 3 | 4 | 5; title: string; body: string;
  pros: string[]; cons: string[];
  author: { displayName: string; avatarUrl: string | null };
  createdAt: string; editedAt: string | null;
  variant: Record<string, string> | null; // { color: "Black" }
  badges: Badge[];
  media: { id: string; kind: "image" | "video"; thumbUrl: string; fullUrl: string; width: number; height: number }[];
  lang: string; original: { title: string; body: string; lang: string } | null;
  sellerResponse: { body: string; createdAt: string; sellerName: string } | null;
  helpfulCount: number;
};

type MyReviewState = { myVotes: Record<string, boolean>; myReview: (Review & { status: "pending" | "published" | "rejected"; rejectReason?: string }) | null };
Client-side shapes. The server decides badges, counts, and status; the client only renders them.
Where client data lives. The URL holds ReviewQuery: sort, stars, verified, and keyword; it is the key for the query cache. The query cache holds Review pages and the Summary. A private cache holds MyReviewState: my votes and my review, merged into cards by id. localStorage holds ReviewDraft: stars, text, and upload ids. Component state holds Read more, Show original, and Lightbox open.URLReviewQuerysort, stars, verified, qQuery cache (public)Review pagesSummaryPrivate cacheMyReviewStatemyVotes, myReviewlocalStorageReviewDraftstars, text, uploadIdsComponent stateRead moreShow originalLightbox
Five homes, each chosen for one reason: the URL for what must be shared, the caches for server data, localStorage for what must survive a crash, and component state for what is purely visual.

Endpoints

Method and pathPurposeCache
GET /products/{id}/reviews/summaryAverage inputs, counts, histogram, top positive and critical idsPublic, 5 minutes
GET /products/{id}/reviews?sort=&stars=&verified=&media=&variant=&q=&cursor=&limit=10One page of reviews with nextCursor and matchCountPublic, 60 seconds
GET /products/{id}/reviews/me?ids=r_1,r_2My votes for these ids and my own review with statusPrivate, no store
PUT /reviews/{id}/my-voteSet my helpful vote to true or falseNever
GET /products/{id}/review-eligibilityCan I review, and with which variantPrivate
POST /reviews, PATCH /reviews/{id}, DELETE /reviews/{id}Create (with Idempotency-Key), edit, deleteNever
POST /reviews/{id}/reportsReport with a reasonNever
json
{
  "reviews": [{ "id": "r_81", "stars": 1, "title": "Strap broke in a month", "helpfulCount": 52, "...": "..." }],
  "nextCursor": "eyJzIjoxMi40LCJpZCI6InJfODEiLCJ2IjoxNzYxMDE2MDAwfQ",
  "matchCount": 87,
  "matchCountCapped": false,
  "facets": { "verified": 61, "media": 23, "variant": { "Black": 54, "White": 33 } }
}
A list response. matchCount is capped and flagged, so the UI can show 1,000+ honestly.

Errors use one shape, { "error": { "code": "RATE_LIMITED", "message": "...", "retryAfter": 30 } }, so the client can map codes to messages: NOT_ELIGIBLE explains why on the form, RATE_LIMITED rolls back a vote, VALIDATION points at the field, and an expired cursor (CURSOR_EXPIRED) makes the list reload page one with the same filters.

Check your understanding · Exercise 11

Which data placements are right (select all that apply)? Select all that apply.

SEO, performance, and accessibility

Reviews pull in two directions. Search engines want the rating and review text in the HTML, and stars in search results bring clicks. But the reviews sit far below the fold of a page whose LCP is the product photo, and every byte and script they add competes with it. The answer is to decide, part by part, what the server sends and what waits.

Decision How should the reviews section be rendered?

Client-render everything

The page ships an empty container; JavaScript fetches and renders reviews after load.

Pros

  • Smallest initial HTML.
  • Simple caching.

Cons

  • Search engines may not see ratings or text, or see them late.
  • The summary pops in and shifts the layout.

Server-render everything loaded

Summary, highlights, and 30 reviews with all photos in the HTML, fully hydrated.

Pros

  • Everything is crawlable and visible without scripts.

Cons

  • Bigger HTML and slower time to first byte on a page that is already heavy.
  • Hydrating 30 cards blocks the main thread when shoppers are trying to tap Add to cart.

Server-render summary and first page, hydrate on approach

Summary, JSON-LD, and 10 reviews are in the HTML as static markup.

The interactive islands and the personal overlay load when the section gets near the viewport.

Pros

  • Crawlable and stable layout.
  • No reviews JavaScript runs during the critical first seconds.

Cons

  • A shopper who jumps straight to reviews may tap before islands are ready; links and forms must work without them.

What we pick: Server-render the summary and the first 10 reviews, and hydrate on approach. Histogram bars and Load more are real links that work before JavaScript. Helpful is a button inside a small form that posts to a fallback endpoint (HTML forms can only send GET and POST), so an early tap still does something sensible.

Structured data for review snippets

json
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Aria Wireless Headphones",
  "aggregateRating": { "@type": "AggregateRating", "ratingValue": 4.6, "reviewCount": 1207, "ratingCount": 9214 },
  "review": [{
    "@type": "Review",
    "author": { "@type": "Person", "name": "Maya R." },
    "datePublished": "2026-09-14",
    "reviewRating": { "@type": "Rating", "ratingValue": 5, "bestRating": 5 },
    "name": "Best noise cancelling I've owned",
    "reviewBody": "Wore them on a 12 hour flight..."
  }]
}
Product JSON-LD in the server HTML. Every value must match what is visible on the page.
  • A product snippet needs the product's name and at least one of review, aggregateRating, or offers. An AggregateRating needs ratingValue and at least one of ratingCount or reviewCount.
  • Google's review snippet guidelines ask that marked-up ratings and reviews be visible on the page, about one specific product, not aggregated from other websites, and not fake or undisclosed incentivized reviews. If you mark up individual reviews, also mark up the aggregate rating.
  • The self-serving rule (a business marking up reviews about itself) applies to LocalBusiness and Organization pages. A store marking up shoppers' reviews of a product it sells uses Product, which that rule does not cover.
  • Rich results are never guaranteed; valid markup only makes the page eligible. Check it with the Rich Results Test.

Paginated review URLs

Load more serves people. For crawlers, the server also renders /p/aria-headphones/reviews?page=2 and so on: plain pages with 10 reviews each, linked with ordinary <a> tags at the bottom of the list and in Load more's href. Each page is its own canonical URL, as Google's pagination guide asks.

Google said in 2019 that it no longer uses rel="next" and rel="prev", so the links themselves are what matter. Filtered and sorted views carry a noindex robots meta tag, which is Google's recommendation for variations of the same list; a canonical pointing at the unfiltered page is only a hint, and a weak one when the content differs.

Loading the interactive parts on approach

ts
const section = document.getElementById("reviews")!;
const io = new IntersectionObserver(
  (entries) => {
    if (!entries.some((e) => e.isIntersecting)) return;
    io.disconnect();
    void import("./reviews-islands").then((m) => m.hydrate(section));
    void loadMyReviewState(productId, visibleReviewIds(section));
  },
  { rootMargin: "1600px 0px" },
);
io.observe(section);
// A deep link to #reviews or ?stars= starts this at once instead.
Load the islands and the personal overlay about two screens before the reviews scroll into view.
  • Images: thumbnails use loading="lazy" with width and height, so they never compete with the hero image for LCP and never shift layout.
  • Rendering: review cards get content-visibility: auto with contain-intrinsic-size: auto 240px, so the browser skips layout and paint for cards far off screen.
  • Interaction: a filter change updates the pressed state at once and wraps the list swap in startTransition, so the tap paints within the INP budget of 200 ms even when 10 new cards render.
  • Weight budget: about 12 KB compressed of JavaScript for the islands, loaded on approach; the lightbox and the form are separate chunks loaded on use.

Check your understanding · Exercise 12

Which choices help both SEO and performance (select all that apply)? Select all that apply.

Accessibility: a histogram you can hear

A sighted shopper reads the summary in one glance: tall 5-star bar, small 1-star bar. A screen reader user gets whatever text we wrote. If the bars are empty divs with widths, they get nothing. The screen-readers lesson covers the general rules; here is how they apply to each part.

  • The summary reads as one phrase: "Rated 4.6 out of 5, based on 9,214 ratings". The star icons are hidden with aria-hidden="true" because the text already says it.
  • Histogram bars are links with the full meaning in their name: "5 stars, 72% of ratings, 6,634 ratings. Show these reviews." The visual bar is decoration. The selected bar adds aria-current="true", and zero bars are plain text.
  • Filters that toggle in place (Verified, With photos) are <button aria-pressed> elements, grouped with a visible label. The sort is a native <select> with a label. Both keep focus where it was after the list updates.
  • Result counts go to a polite live region (role="status"): "87 reviews, filtered by 1 star". Announce once, after results arrive, not on every keystroke in the keyword box.
  • Load more moves focus to the first newly loaded review's heading, so keyboard users continue reading where the new content starts instead of landing back on the button.
  • Each review is an <article> with its title as a heading, so screen reader users can jump review to review with heading navigation. The stars inside are text ("Rated 2 out of 5").
  • Helpful is a <button aria-pressed> whose name includes the count: "Helpful, 52 people found this helpful". After a rollback, the error message goes to the same status region.
  • Readable text: review body at the page's base size (at least 16 CSS pixels), line length under about 75 characters, and enough contrast for the gray meta text (4.5 to 1 for normal text).

The lightbox is a modal dialog: focus moves in, Escape closes it, focus returns to the thumbnail that opened it, and arrow keys move between photos. The modal dialog and image carousel guides cover these in depth. The review form uses visible labels, ties errors to fields with aria-describedby, and moves focus to the first invalid field on submit.

Check your understanding · Exercise 13

Which make the reviews section work with a screen reader (select all that apply)? Select all that apply.

Running it in the interview

This question rewards breadth with a few sharp deep dives. Interviewers want to see that you know reviews are a ranking and trust problem, not a list with stars. Here is one way to spend 45 minutes.

MinutesWhat to doWhat to say out loud
0 to 5Ask about scale, review counts, moderation, languages, SEO."Most products have few reviews, so I'll be careful with averages."
5 to 10Draw the wireframe: summary, filters, list, card, form entry."The histogram bars are filter links, and all list state goes in the URL."
10 to 18Architecture and data flow, with the public/personal split."Public data is CDN-cached; my votes come in a private overlay."
18 to 30Two deep dives: ranking (Bayesian for products, decayed helpful for reviews) and helpful votes (idempotent PUT, optimistic, rollback)."Display the raw average with its count; rank by the Bayesian score."
30 to 38Writing a review: eligibility, drafts, pending state, edits re-moderated."Only the author sees a pending review."
38 to 45SEO, performance, accessibility, trust rules."Server-render summary and first page; hydrate on approach; never suppress by sentiment."

Draw the wireframe first. It anchors every later decision in something visible, and it shows the interviewer you think from the shopper's side. Then pick deep dives that show judgment, ranking and votes, rather than spending ten minutes on CSS for stars.

Follow-up questions to expect

Think first

"The summary says 9,214 ratings but the list says 1,207 reviews. A shopper says we're hiding reviews. What happened, and what do you change?"

Think first

"A product has 30 color variants that share reviews. A shopper looks at the red one. What do you show?"

Think first

"A product suddenly gets 500 one-star reviews in an hour after a viral post. What does the frontend do?"

Summary

  • Show the count next to every average, compute the average from exact sums, and round histogram labels with largest remainder so they add to 100%.
  • Rank products by a Bayesian average, (C * m + sum) / (C + n), but display the raw average with its count: the stars must show what customers said.
  • Make "Most helpful" a server-computed score with time decay (and the Wilson lower bound when you have Not helpful votes), with id as the final tie-breaker for stable cursors.
  • Keep sort and filters in the URL, results in a query cache keyed by the normalized query, and use cursor pagination with Load more, plus crawlable numbered pages.
  • Split public data (CDN-cached summary and list) from personal data (my votes, my pending review) fetched in a private overlay.
  • Helpful votes are an idempotent PUT of the desired state, applied optimistically and rolled back to the server's answer on failure.
  • A submitted review is pending and visible only to its author; edits go back through moderation while the published version stays live.
  • Never hide, delay, or solicit reviews by sentiment, and label incentivized and insider reviews: the FTC rule in force since October 2024, together with the FTC's Endorsement Guides, turns these into legal requirements in the US.
  • Server-render the summary, the first page, and matching JSON-LD; hydrate the interactive parts as the section approaches the viewport.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next