DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our FAQPage markup and our FAQ markup are one array, because the price inside it changes per visitor

Munchable's homepage has an FAQ section and FAQPage structured data describing it. Five questions. One of the answers quotes a price. The price is different depending on who is reading the page.

That last sentence is the entire problem, and it is why the FAQ is a function instead of a component with the copy inside it.

What Google actually objects to

The rule for FAQPage is not "have the questions in JSON-LD". It is that the marked-up question and answer must be visible on the page, in full, to the user who loads it. Structured data describing content the page does not show is the kind of thing that earns a manual action, and the appeal process for one of those is not a thing you want on your week.

The naive failure is easy to picture: a <details> block in JSX and a separate JSON-LD literal in a lib/seo file, written on the same afternoon and edited on different ones. Six weeks later the rendered answer says five scans and the JSON-LD says three. Nothing breaks, no test fails, and the mismatch is invisible until it is not.

The less obvious failure is the one we actually had to design around. Munchable's price is localised: a visitor in the eurozone is quoted euros, a visitor in the UK is quoted pounds, and crawlers are deliberately resolved to GBP because GBP is the currency the subscription is charged in. So even with a single source of copy, there are two chances to get it wrong: the markup and the JSON-LD could resolve the currency separately and land on different answers for the same request.

One array, currency as an argument

export function faqs(currency: DisplayCurrency): FaqEntry[] {
  const premium = formatPrice(PREMIUM_PRICE.unitAmount, currency);

  return [
    {
      q: 'Is Munchable medical advice?',
      a: 'No. It is a wellness tool that helps you follow the eating pattern you have chosen...',
    },
    // ...
    {
      q: 'What does it cost?',
      a: `Free for five scans a month across all seven conditions. Premium is ${premium} a month for unlimited scanning, VAT included, managed here on the web.`,
    },
  ];
}
Enter fullscreen mode Exit fullscreen mode

The important thing is the parameter. faqs does not read the currency from React context, and it does not call the resolver itself. It is handed one.

The page resolves the currency once and feeds the same array to both consumers:

const questions = faqs(currency);

return (
  <>
    <JsonLd data={jsonLdGraph(softwareApplicationLd(), faqLd(questions))} />
    ...
    <Faq items={questions} />
  </>
);
Enter fullscreen mode Exit fullscreen mode

faqLd is a pure shape transform over whatever it is given:

export function faqLd(faqs: ReadonlyArray<{ q: string; a: string }>) {
  return {
    '@type': 'FAQPage',
    mainEntity: faqs.map((f) => ({
      '@type': 'Question',
      name: f.q,
      acceptedAnswer: { '@type': 'Answer', text: f.a },
    })),
  };
}
Enter fullscreen mode Exit fullscreen mode

There is no second read of anything. questions is one value in one render, used twice. Divergence is not something a test has to catch, because there is nowhere for it to happen.

You can check this from outside. Fetch the homepage as an ordinary browser and the answer in the HTML and the answer in the FAQPage block both say €12.99. Fetch it with Googlebot's user agent and both say £10. Same request, same array, two consumers, one number.

The rendering is five <details> elements

{items.map((f) => (
  <details className="faq-item" key={f.q}>
    <summary>
      <h3>{f.q}</h3>
    </summary>
    <p>{f.a}</p>
  </details>
))}
Enter fullscreen mode Exit fullscreen mode

Native disclosure, closed by default, one tap to open, no JavaScript. Keyboard behaviour and screen reader semantics arrive for free, which is reason enough on its own.

It also happens to be the only accordion pattern that is safe here. A JavaScript accordion that mounts an answer on click does not have the answer in the initial HTML, and now the FAQPage block is describing text that is not on the page until somebody interacts with it. details keeps all five answers in the markup at all times; open is a presentation detail the browser owns. Collapsed is not hidden in the sense Google cares about, and that distinction is doing real work for us.

Escaping, because some of this text is not ours

One more thing, in the serialiser rather than the FAQ:

const json = JSON.stringify(data)
  .replace(/</g, '\\u003c')
  .replace(/>/g, '\\u003e')
  .replace(/&/g, '\\u0026');
Enter fullscreen mode Exit fullscreen mode

The FAQ copy is written by us and could skip this. Other pages on the site put product names and ingredient text into structured data, and that text came out of a database or off a photographed label. JSON.stringify makes a valid JSON string, but a <script> block is not parsed as JSON first and HTML second: the HTML parser gets there first, and a value containing the characters </script> closes the tag early. Everything after it runs as markup.

So the component escapes all three characters to their JSON unicode escapes before the string ever reaches dangerouslySetInnerHTML. < is the same string value to a JSON parser and an inert sequence to an HTML parser. One function, used by every structured data block on the site, including the ones whose input nobody on the team typed. The recipe pages and the ingredient answer pages go through the same component for exactly that reason.

The short version

Generate structured data from the value you rendered, not from a copy of it. If the rendered value depends on the request, pass the dependency in as an argument so there is only one resolution per request. And escape your JSON-LD even when the content is yours today, because the file does not know what the next page will put through it.

Top comments (0)