DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our testimonials carry no structured data on purpose, and every card is a link to the source

The testimonial section on cogniprep.app is six quotes from real reviews. There are two decisions in it that I would defend to anyone building the same thing, and one CSS bug that is funnier than it is important.

Start with the thing that is not there.

No aggregateRating, and that is the point

The obvious move for a testimonial section is to mark it up. Product with an aggregateRating, a ratingValue, a reviewCount, and a row of golden stars in the search result. Plenty of tooling will generate it for you in a click.

Google's review snippet policy says reviews must not be "self-serving", and defines that as a review about an entity written or hosted by that entity. A site rating itself is exactly the case. The outcome is not a neutral one where the markup is simply ignored: the content is ineligible for the rich result, and sites that do it at scale collect manual actions.

So the homepage ships three JSON-LD blocks, Organization, WebSite and Product, and the Product block looks like this:

{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "CogniPrep Psychometric & Interview Practice Platform",
  "description": "...",
  "image": "https://cogniprep.app/logo-light.png",
  "brand": { "@type": "Brand", "name": "CogniPrep" },
  "offers": {
    "@type": "AggregateOffer",
    "lowPrice": "0",
    "priceCurrency": "GBP",
    "offerCount": "3",
    "availability": "https://schema.org/OnlineOnly"
  }
}
Enter fullscreen mode Exit fullscreen mode

Offers, no ratings. Open the page and search the source for aggregateRating, there are no matches. The quotes are ordinary page content for a human reader, which is what they are good at.

There is a second-order benefit I did not expect. Once the ratings are not structured data, nothing on the page has to agree with a number. An earlier version of this section printed an average score and a review count above the cards, and both were constants in a TypeScript file. Every new review made them stale, and nobody notices a stale average until a visitor compares it with the review site. Deleting the summary deleted a whole class of quiet inaccuracy.

Every card is a link to the review it quotes

The replacement for credibility-by-markup is credibility-by-citation. Each card is an <a>, not a <div>, and it points at the individual review on Trustpilot:

https://www.trustpilot.com/reviews/6aa80bfe3f318edf8fae108d
Enter fullscreen mode Exit fullscreen mode

Six cards, six review URLs, plus one link to the profile underneath. Click any of them and you land on the review being quoted, with the reviewer's name and date, on a site we do not control. That is a stronger claim than a star graphic, and unlike the star graphic it is falsifiable.

Three details that follow from treating a quote as a quote:

Copied verbatim, typos included. One of the live cards contains "succesfully". Fixing it would be editing somebody's words to make our own marketing read better, and a reader who clicks through would find the two versions differ. The data file says so in a comment, so the next person to touch it does not helpfully run a spellchecker.

The headline is optional. Trustpilot lets a reviewer write a title and most do not. When they do not, the site fills the slot with the first few words of the review followed by an ellipsis, so copying "the title" gives you a card whose bold first line is a truncated version of the sentence directly beneath it. The type has title?: string, and the cards without one just start with the body.

The footer has to line up. Six reviews of wildly different lengths in a three-column grid look sloppy if the "Read the review on Trustpilot" line floats at the bottom of each sentence instead of across a row. The card is a flex column, the text block is flex-1, and the grid is md:auto-rows-fr so every row is as tall as its tallest card. On phones the same markup is a snap-x snap-mandatory scroller of w-[85%] cards, so the next card peeks in from the edge and the section does not become six screens of stacked quotes.

The dots, and the last card that never reaches the left edge

The phone scroller has position dots, driven by onScroll. The naive implementation divides scrollLeft by the card pitch and rounds, which is correct for every card except the last one. Cards are 85% of the viewport, so the final card snaps as far left as the container's scroll width allows, which is not far enough to make the index round up. The last dot never lights.

if (scroller.scrollLeft + scroller.clientWidth >= scroller.scrollWidth - 2) {
  setActiveIndex(testimonials.length - 1);
  return;
}
const step = firstCard.offsetWidth + parseFloat(getComputedStyle(scroller).columnGap || '0');
setActiveIndex(Math.round(scroller.scrollLeft / step));
Enter fullscreen mode Exit fullscreen mode

Reaching the end of the row counts as showing the last card. The - 2 is there because scroll positions are fractional on a device pixel ratio that is not 1, and an exact equality check on a float is a dot that flickers. The dots are aria-hidden, because they are a position indicator for a scroller the screen reader is already announcing properly, not a control.

The separator that refused to draw

The divider above each card's link is this:

<div className="mt-6 h-px bg-white/10" />
Enter fullscreen mode Exit fullscreen mode

A one-pixel div, not a border-t. It is not a stylistic preference. Our globals.css contains a rule that matches the bare border-t class, which silently wins over the Tailwind utility because unlayered CSS beats layered CSS regardless of specificity, so a plain border-t on this site draws nothing at all. I wrote up that whole saga in Our globals.css beat 514 Tailwind utilities; this is the thousandth small consequence of it, and a div is cheaper than another round of cascade archaeology.

See it

Open cogniprep.app and scroll to the quotes. Click one and check that the review you read is the review you land on. Then view source and search for aggregateRating: the absence is the design decision, and the links are what replaced it.

If you are about to mark up your own ratings, read the review snippet policy first and ask what the markup would buy you that a link to the source does not.

Top comments (0)