Reviews and testimonials
Reviews — star picker, testimonials, moderation queue
For when the answer to “It shows reviews or testimonials from the public” is yes.
Rebuilt in ~16/20 projects. Takes text from strangers and puts it on your most-trusted page, so it is the easiest place on a small site to get XSS'd — and the easiest place to accidentally publish spam.
Built by hand again in 16 of 20 audited projects before this existed. Each rebuild was another chance to make the mistake below.
Skip the rebuild
You do not write this one. It arrives in your project as ordinary source you can read, change and keep, with its audit record and its caveats beside it.
- Download the Playground. It is free and runs on your own machine.
- Start a new app and tick “It shows reviews or testimonials from the public”.
- This block is written in, along with anything else you ticked.
The mistake it removes
The two failure modes are structural, not stylistic: rendering submitted text as HTML, and publishing on submit because 'pending' felt like day-two work.
What is already handled
Each of these was checked by running the code, not by reading it.
- 35 node assertions (counted here: test/ui-reviews.test.mjs) plus 19 browser assertions (counted in this block's own Local Workbench DD run of 2026-07-17, recorded at .workbench/dd/latest.json, not counted here), all passing
- 5 real XSS payloads (script, img onerror, svg onload, attribute-break, javascript: iframe) render as visible TEXT — a page tripwire confirms none executed and zero script/img/svg/iframe elements enter the DOM. MEASURED IN THIS BLOCK'S DD BROWSER RUN OF 2026-07-17 (.workbench/dd/latest.json), not in the node suite above: this repository has no DOM for the widget half, and vanilla/reviews.js is the file this block's own dd.json names as unexecuted. What IS re-read here on every run is the structural property that result rests on — the shipped file has no innerHTML, insertAdjacentHTML or document.write path in it at all.
- the element helper has no innerHTML path at all, so the safety is structural rather than a rule to remember
- submit() always writes status:'pending'; no code path sets 'approved' on submit
- omitting requireAdmin makes moderate() and listPending() return 503 — verified, so an unguarded queue is unreachable rather than merely discouraged
- a rejected admin mutates nothing (state compared before/after)
- listPublic() filters to approved AND projects through PUBLIC_FIELDS — submitter ip/status never serialize to the public
- rating coerced and range-checked: 99, 0, -3, NaN, Infinity and 'abc' all rejected; 4.6 rounds to 5
- rejected submissions do not burn rate-limit budget, so a typo can't lock out a real reviewer; the 4th VALID review from one IP is still limited and not stored
- createReviews().destroy() removes every listener it added (added/removed counted by proxy) and stops the timer — no leak across SPA re-mounts. THE PROXY COUNT WAS TAKEN IN THE DD BROWSER RUN OF 2026-07-17, not in the node suite above, because a Node gate has no DOM to count against; what is re-read here is that every addEventListener the shipped file makes still has its removal inside destroy(), and that the timer is still cleared.
- auto-rotation disabled under prefers-reduced-motion, pauses on hover and on focusin — THAT ROTATION REALLY STOPS WAS OBSERVED IN THE DD BROWSER RUN OF 2026-07-17 and not in the node suite above; what is re-read here is that the media query, the hover handler and the focusin handler are all still wired in the shipped file.
- star picker is a fieldset/legend radio group with a label per star — THAT A SCREEN READER ANNOUNCES IT AS ONE GROUP WAS JUDGED IN THE DD BROWSER RUN OF 2026-07-17, not here; what is re-read here is that the shipped file still builds the fieldset, the legend and one labeled radio input per star.
- internal errors log server-side but never reach the client or the page
What the audit found
Named rather than summarized. The reasoning behind each one ships inside the block, so it travels with the code instead of living on a page you have to trust.
- Testimonials render client-side, so crawlers never see them minor · SEO & metadata
- Submitter IP is stored, which is personal data under GDPR minor · Privacy & data
- Rate limiting is in-memory, so serverless coverage is per-instance minor · API cost & abuse safety
- The block caps and validates but does not detect spam content minor · Correctness
What you still have to do
A copied file cannot own your secrets, your host or your legal obligations. This is the part the block deliberately does not claim.
- Supply requireAdmin (admin-kit's, or your own) and serve over HTTPS. Without requireAdmin, moderate() and listPending() return 503 — the queue is unreachable rather than unguarded, which is safe but also unusable, so an install that skips it has no moderation at all.
- A keyboard-and-screen-reader pass on YOUR page. The block ships the wiring it can ship — aria-label on the rating controls and an aria-live region for the submission result — but color contrast, heading order and the focus order across everything around it are properties of the page you drop this into, and no test here can see them. Run one manual keyboard pass and one screen-reader pass before you launch.
- Set a retention policy for the stored submitter IP and disclose it in your privacy page. Under GDPR an IP is personal data, and the block keeps it for moderation without expiring it.
- A rate limiter backed by Redis or by your edge if you become a spam target. The one shipped here is in-memory and best-effort; on serverless it covers one warm instance.
- You supply the store, so its durability and backups are yours. The block is storage-agnostic and guarantees only that it writes through your put().
- Reviews render client-side, so crawlers won't see them in the HTML. If testimonials matter for SEO, server-render them or emit schema.org JSON-LD.
- The block validates and caps; it does not detect spam content. A human still approves each review — deliberately.
What lands in your project
vanilla
- vanilla/reviews.js
createReviews({ mount, load, limit }) rotates testimonials and returns { refresh, stop, destroy, count } — call destroy() when unmounting. createStarPicker({ mount, onChange }) is a radio group. Call installReviewStyles() or bring your own CSS.
node
- node/reviews-api.js
createReviewsApi({ store, requireAdmin }) returns { submit, listPublic, listPending, moderate }. The admin routes guard themselves — omit requireAdmin and they answer 503 rather than opening up.
What you supply
- store — { all(): Promise<Review[]>, put(review): Promise<void> } — the block is storage-agnostic
- requireAdmin — admin-kit's requireAdmin(req,res). Required for listPending/moderate to function at all.
- publicLimit — max approved reviews returned publicly (default 100)
- limit — max cards rendered by createReviews (default 50)
Licensed MIT. It is a starting point, not a finished product.
Get it
Download the Playground See the other blocks
Nothing here is locked. The files are yours, in your folder, under a permissive license.
Questions
- Is Reviews and testimonials audited?
- Yes. A full due-diligence audit was run on 2026-07-17 and the verdict was a pass.
- What do I still have to do myself?
- Supply requireAdmin (admin-kit's, or your own) and serve over HTTPS. Without requireAdmin, moderate() and listPending() return 503 — the queue is unreachable rather than unguarded, which is safe but also unusable, so an install that skips it has no moderation at all.
- How do I get this code?
- Download the Playground, start a new app, and tick “It shows reviews or testimonials from the public”. The block is written into your project as ordinary source you can read and edit.
All pre-built code blocks · Learn to build from zero · The coding guide