DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Three of our six FAQ answers disagree with the code, and the same array ships them to Google

The FAQ on notifio.app/help is six questions in a 26-line file:

export const FAQ_ITEMS = [
  {
    q: "Do I need to keep Notifio open on screen?",
    a: "No, Notifio runs in the background. As long as the app is running (you can minimise it or let it run in the menu bar) and your Mac or PC is on, monitoring continues.",
  },
  {
    q: "How often does Notifio check for new listings?",
    a: "By default every 60 seconds per monitor. You can adjust the interval in the monitor settings.",
  },
  // ...four more
] as const;
Enter fullscreen mode Exit fullscreen mode

Two things read it. The page builds structured data:

const faqSchema = {
  "@context": "https://schema.org",
  "@type": "FAQPage",
  mainEntity: FAQ_ITEMS.map(({ q, a }) => ({
    "@type": "Question",
    name: q,
    acceptedAnswer: { "@type": "Answer", text: a },
  })),
};
Enter fullscreen mode Exit fullscreen mode

And a client component renders the accordion:

{FAQ_ITEMS.map(({ q, a }) => <FaqItem key={q} q={q} a={a} />)}
Enter fullscreen mode Exit fullscreen mode

One source, two consumers, no duplication. I was fairly pleased with this file. Then I sat down to write a post about it and found three problems, in ascending order of how much they bother me.

Problem 1: the answers are not on the page

Here is FaqItem, trimmed:

function FaqItem({ q, a }: { q: string; a: string }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen((o) => !o)} aria-expanded={open}>
        <span>{q}</span>
        <span style={{ transform: open ? "rotate(45deg)" : "rotate(0deg)" }} aria-hidden>+</span>
      </button>
      {open && <p>{a}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

{open && <p>{a}</p>} is the standard way to write a collapsible section in React, and it means the answer does not exist in the document until somebody clicks. Not hidden. Absent.

You can check this against the live page. Fetch it and count the occurrences of an answer string, and you get two, both inside script tags: one in the JSON-LD block and one in the serialised payload React uses to hydrate. Zero in the rendered markup. Open it in a browser with devtools and the paragraph appears only after the click, while aria-expanded flips from false to true.

So the page currently tells a machine six answers and tells a reader six questions. Every one of those answers is a claim submitted as structured data about content that is not there.

The fix is a one-line change in the other direction from the usual advice: render the paragraph always and hide it with CSS, so the text is in the DOM, inside an element whose visibility is controlled rather than whose existence is.

The accessibility side is the same shape. aria-expanded on the button is correct and does the right thing in a screen reader, but a + glyph with a CSS rotation as the only open-state affordance is doing visual work that no other cue backs up. Honest assessment: the keyboard and screen reader path works, and the visual path is thinner than it should be.

Problem 2: Google stopped showing the thing this node is for

While checking what the FAQPage markup actually buys, I went to Google's documentation page for it. It redirects to an entry in the Search Central changelog:

Removing documentation for the FAQ rich result feature
What: Removed documentation for the FAQ rich result feature.
Why: The FAQ rich result feature is no longer shown in Google Search results, as announced in the changelog entry in May 2026.

Source: Google Search Central, latest documentation updates.

So the expandable FAQ panel in the search result, which is the reason anybody adds FAQPage markup, is gone. Not deprioritised, not restricted to certain sites. Not shown, and the documentation deleted.

That does not make the markup worthless, and I am leaving it in place. Valid schema.org on a page is read by more than one crawler, and a question and answer pair is a format that models and other parsers can use directly. But it does change what it is for, and it changes what a wrong answer in there costs. A wrong answer is no longer a wrong rich result nobody will see. It is a machine-readable statement, in a format designed for being quoted, that will be quoted by something.

Which leads to the part I would rather not write.

Problem 3: three of the six answers are wrong

"By default every 60 seconds per monitor. You can adjust the interval in the monitor settings."

The app:

const POLL_INTERVAL_MS = 30_000;
Enter fullscreen mode Exit fullscreen mode

One constant, in one module, applied to the whole cycle rather than per search, measured start to start. Two things in one sentence are wrong: the number is double the real interval, and "per monitor" describes a design the app does not have.

Then "you can adjust the interval in the monitor settings". There is no such setting. The app's entire persisted settings surface is one boolean, and the module that owns it says so in its own header comment:

/**
 * The auto-reply on/off switch. That is the entire settings surface.
 */
Enter fullscreen mode Exit fullscreen mode

A Site in the config has a name, a URL, an enabled flag and two optional fields, one about logging in and one choosing how a reply is sent. No interval. The FAQ is describing a settings screen that was imagined and never built.

"Yes. You can add as many monitors as you like, each with its own URL, name, and check interval."

export const MAX_SEARCHES = 15;
Enter fullscreen mode Exit fullscreen mode

Fifteen, and the limit is deliberate rather than arbitrary: every search is checked in sequence inside one cycle, so the count is what decides how stale the slowest one gets. I have argued elsewhere that that constant is a product promise and that it is a latency budget rather than a pricing tier. Then the FAQ said "as many as you like", and repeated the invented per-monitor interval for good measure.

Three wrong answers out of six, on a support page, emitted as structured data. None of them is a lie anybody told on purpose. They are all the same failure: the FAQ was written when the app was younger, the constants moved, and prose does not have a type.

Why the single source of truth did not help

This file has exactly the property that is supposed to prevent this. One array, two consumers, no copy to forget.

It is a single source of truth for the wrong thing. It guarantees the accordion and the structured data agree with each other, and it has nothing to say about whether either agrees with the app. Those numbers live in a different workspace in the repo: MAX_SEARCHES is in the Electron app, POLL_INTERVAL_MS is in the monitor, and the website is a separate Next.js project with its own package.json. There is no import path from one to the other, so the drift had nothing to push against.

The repo already contains the pattern that would have caught it. A test in the website project reads our own source files to count the characters a title template spends, because a metadata limit is a claim about rendered output and the only honest way to check it is to go and look. A FAQ answer containing a number is exactly the same kind of claim.

So the fix is in three parts, and the order matters:

  1. Correct the three answers. A support page that is wrong about the product is worse than no support page, and it is worse now that the answers are formatted for quoting.
  2. Stop writing numbers in FAQ prose where a constant exists. "Every 30 seconds" in a sentence is a copy of POLL_INTERVAL_MS that no tool can see. Either interpolate it or write the answer without the figure.
  3. Render the answers into the DOM and hide them with CSS, so the structured data is about content that is present.

Two of those three are small. The second one is the actual lesson, and it is not about FAQs. Any number in user-facing prose that also exists as a constant is a copy, and copies drift, and a copy a build cannot see drifts silently and indefinitely.

Go and look

By the time you read this the answers may be fixed, which would be the good outcome of writing it down. The page is notifio.app/help, and the real behaviour is described per site on pages like notifio.app/alerts/kamernet and notifio.app/alerts/pararius, which are generated from typed data and therefore cannot say "as many as you like" about a field that has a number in it.

If you want to check my working rather than take it, fetch /help, search the HTML for an answer string, and count how many of the hits are inside a <script> tag.

Top comments (0)