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.
| Question | Answer we assume | What 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. |
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.
- 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.
- 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. - 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.
- 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.
- 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.
| Property | What it drives | Chapter |
|---|---|---|
| Small, skewed numbers | Showing the count, rounding the histogram, Bayesian average for ranking | The rating summary; Ranking products fairly |
| Order decides what is read | Helpful score with time decay, stable sorting | Sorting the list |
| Many list dimensions | URL state, cursor pagination, counts per filter | Filters, URL and pagination |
| Low on a heavy page | Server-render (SSR) the summary and first page, load the rest on approach | SEO, performance, and accessibility |
| Trust | Moderation states, badges, disclosures, no sentiment-based hiding | Writing 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?
No. New reviews go through moderation, which takes minutes to days, so there is nothing to push in real time. Even after approval, one new review barely moves the summary of a popular product.
The author is the one person who must see their own review at once, and that is a local state on their client, not a broadcast. A cache with a short lifetime (say 5 minutes for the summary) is enough for everyone else.
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?
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.
// 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]
Bars are filter links
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
noindexrobots 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:
| Product | Ratings | Raw average | Raw rank | Bayesian (m = 4.1, C = 20) | Bayesian rank |
|---|---|---|---|---|---|
| A | 2 | 5.0 | 1 | 4.18 | 5 |
| D | 10 | 4.9 | 2 | 4.37 | 3 |
| C | 40 | 4.8 | 3 | 4.57 | 2 |
| B | 900 | 4.6 | 4 | 4.59 | 1 |
| E | 3,000 | 4.3 | 5 | 4.30 | 4 |
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?
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
countandsumper product, not the average, so every new rating is anO(1)update and both scores can be computed exactly.
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
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?
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
| Review | Age | Helpful votes | Score (votes + 1) * 0.5 ** (age / 365) | Raw rank | Decayed rank |
|---|---|---|---|---|---|
| R1: launch week classic | 3 years | 400 | 401 x 0.125 = 50.1 | 1 | 2 |
| R4: detailed one-year review | 400 days | 120 | 121 x 0.468 = 56.6 | 2 | 1 |
| R2: notes the new battery | 60 days | 25 | 26 x 0.892 = 23.2 | 3 | 3 |
| R3: brand new | 5 days | 2 | 3 x 0.991 = 3.0 | 4 | 4 |
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:
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?
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.
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),
};
}
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
- The click updates the URL with
history.pushState(through the router). - The list reads the new params; if the cache has that key, it shows it at once.
- 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. - The old results stay on screen, dimmed and marked
aria-busy="true", instead of a skeleton that collapses the height and shifts the page. - Results arrive; the list swaps, and a polite live region announces "87 reviews". Focus stays on the control the user pressed.
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.
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.
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?
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.
.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;
}
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
widthandheightset so they reserve space,loading="lazy", and asrcsetfor 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
langon 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 ignoreslangand 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?
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.
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);
}
}
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?
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.
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
- Automated filters run within seconds: profanity and abuse, personal data (phone numbers, emails), links, duplicate text across accounts, and a language check.
- 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.
- Human review handles the uncertain middle and every report from shoppers.
- 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:
| Requirement | What 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.
| Part | Runs where | Responsibility |
|---|---|---|
ReviewsSummary | Server render | Average, count, histogram links, and the JSON-LD (structured data in a script tag) for search engines. No client JavaScript beyond the links. |
ReviewFilters | Client island | Sort select, star, verified, photos, variant toggles, and keyword box. Reads and writes the URL, nothing else. |
ReviewList | Server render, then client | First page rendered on the server; later pages and filter changes fetched on the client through the query cache. |
ReviewCard | Server HTML with small islands | Static markup, plus three small interactive parts: Read more, translation toggle, and Helpful button. |
MediaLightbox | Lazy chunk | Loaded on the first thumbnail press, so it costs nothing until used. |
| Query cache | Client memory | Results keyed by product and normalized query; dedupes requests, keeps pages for Back. |
| Personal overlay | Client | Fetches myVote for visible ids and the shopper's own review, then merges them into the cards. |
ReviewForm route | Separate page and chunk | Eligibility, fields, uploads, draft store in localStorage, submit with idempotency key. |
| Reviews BFF | Server | A 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.
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.
| Data | Lives in | Why there |
|---|---|---|
| Sort and filters | URL query string | Shareable, survives reload and Back, readable by the server render. |
| Summary, review pages | Query cache (memory), keyed by product and normalized query | Server data that many components read; reused on Back. |
| My votes, my review | Query cache under a private key | Per user; dropped on sign-out. |
| Draft review | localStorage per user and product | Survives crashes and redirects. |
| Read more, show original, lightbox open | Component state | Purely visual and short-lived. |
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 };
Endpoints
| Method and path | Purpose | Cache |
|---|---|---|
GET /products/{id}/reviews/summary | Average inputs, counts, histogram, top positive and critical ids | Public, 5 minutes |
GET /products/{id}/reviews?sort=&stars=&verified=&media=&variant=&q=&cursor=&limit=10 | One page of reviews with nextCursor and matchCount | Public, 60 seconds |
GET /products/{id}/reviews/me?ids=r_1,r_2 | My votes for these ids and my own review with status | Private, no store |
PUT /reviews/{id}/my-vote | Set my helpful vote to true or false | Never |
GET /products/{id}/review-eligibility | Can I review, and with which variant | Private |
POST /reviews, PATCH /reviews/{id}, DELETE /reviews/{id} | Create (with Idempotency-Key), edit, delete | Never |
POST /reviews/{id}/reports | Report with a reason | Never |
{
"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 } }
}
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?
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
{
"@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..."
}]
}
- A product snippet needs the product's
nameand at least one ofreview,aggregateRating, oroffers. AnAggregateRatingneedsratingValueand at least one ofratingCountorreviewCount. - 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
LocalBusinessandOrganizationpages. A store marking up shoppers' reviews of a product it sells usesProduct, 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
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.
- Images: thumbnails use
loading="lazy"withwidthandheight, so they never compete with the hero image for LCP and never shift layout. - Rendering: review cards get
content-visibility: autowithcontain-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.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask about scale, review counts, moderation, languages, SEO. | "Most products have few reviews, so I'll be careful with averages." |
| 5 to 10 | Draw the wireframe: summary, filters, list, card, form entry. | "The histogram bars are filter links, and all list state goes in the URL." |
| 10 to 18 | Architecture and data flow, with the public/personal split. | "Public data is CDN-cached; my votes come in a private overlay." |
| 18 to 30 | Two 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 38 | Writing a review: eligibility, drafts, pending state, edits re-moderated. | "Only the author sees a pending review." |
| 38 to 45 | SEO, 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?"
Star-only ratings are counted in the summary but have no text to list. Label both numbers in the summary ("9,214 ratings, 1,207 with text") and use the same words in the list header.
Then check that no moderation rule withholds reviews by rating, because that would be real suppression, which the FTC rule prohibits.
Think first
"A product has 30 color variants that share reviews. A shopper looks at the red one. What do you show?"
Show reviews of all variants by default, since quality is usually shared, with the variant on each card and a variant filter preselected to nothing. Offer "Show reviews for Red only" as a filter in the URL.
If variants differ in what matters (shoe sizes, fit), add a summary line like "Fit: 68% say true to size" computed from a structured field on the form.
Think first
"A product suddenly gets 500 one-star reviews in an hour after a viral post. What does the frontend do?"
Very little directly: moderation detects the burst and holds new reviews for human review, regardless of rating. The summary cache means the numbers don't swing minute by minute.
The UI can show a notice like "We're reviewing unusual activity on this product" and pause new submissions, applied to all ratings alike so it isn't sentiment-based suppression.
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
idas 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.
References
- How Not To Sort By Average Rating (Evan Miller) Why raw averages and up-minus-down mislead, and the Wilson lower bound.
- Review snippet structured data (Google Search Central) AggregateRating and Review markup, and the guidelines on self-serving and incentivized reviews.
- Bayesian averages in custom ranking (Algolia) The Bayesian average for ranking by rating, and one way to pick the prior weight C.
- Rank Hotness With Newton's Law of Cooling (Evan Miller) Exponential decay with a half-life, the idea behind the time-decayed helpful score.
- Product snippet structured data (Google Search Central) Required and recommended Product properties for review stars in results.
- 16 CFR Part 255: Guides Concerning the Use of Endorsements and Testimonials (eCFR) Disclosure of material connections and the rule against distorting how reviews are procured, boosted, or organized.
- Pagination and incremental page loading (Google Search Central) How to make Load more and paginated content crawlable.
- 16 CFR Part 465: Rule on the Use of Consumer Reviews and Testimonials (eCFR) The text of the FTC rule: fake reviews, sentiment-based incentives, insider reviews, and review suppression.
- Intersection Observer API (MDN) Loading the reviews islands as the section approaches.
- line-clamp (MDN) Clamping long review text without JavaScript measurement.
- Cumulative Layout Shift (web.dev) Why user-initiated expands don't count and late-arriving content does.
- content-visibility (web.dev) Skipping rendering work for off-screen review cards.
- Button pattern (WAI-ARIA APG) Toggle buttons with aria-pressed for filters and Helpful.
Concepts behind it
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Validating and sanitizing inputChecks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Translating an appHow an app speaks many languages: text in per-locale message catalogs, ICU messages so plurals and gender work everywhere, catalogs loaded per locale and route, clear fallbacks, and a safe workflow with translators.