A library aisle lined with books under a row of hanging light bulbs

Build Note

FAQ accordions that crawlers can actually read

If the answer only enters the DOM on click, a crawler never sees it. Native details/summary keeps every answer mounted, and keeps FAQPage schema honest.

Published August 3, 2026

If an accordion renders its answer only after a click, that text is not in the document a crawler receives — and any FAQ schema referencing it describes content the page does not contain. Use native <details>, keep every answer mounted, and generate the markup from the same array you render.

The bug: conditionally rendered answers

The default React accordion holds an open state and renders the answer only when it is true. That looks correct in a browser and is wrong in the delivered HTML: the server renders the closed state, so the answers are simply not there. A crawler fetching the page sees a list of questions and no answers.

It gets worse with schema attached. FAQPage markup asserts that the page contains those questions and answers; if the answers only exist after an interaction, the structured data is describing content that is not in the document. That is the kind of mismatch worth fixing whether or not anything ever penalises you for it — the markup is supposed to be a machine-readable copy of what a person sees.

The shape to avoid
{/* Answer only enters the DOM after a click — invisible to crawlers,
    and the FAQPage schema below now describes text the page lacks. */}
{isOpen && (
  <div className="px-6 pb-4">
    <p>{answer}</p>
  </div>
)}

The fix: native details, answer always mounted

Swap the state machine for <details> and <summary>. The browser handles opening and closing, the answer is in the markup whether the item is open or not, and the component needs no client-side JavaScript at all — which means it also costs nothing against a First Load JS budget.

components/ui/accordion.tsx
<details className="tk-fold group ... open:border-tk-teal/30">
  <summary className="flex w-full cursor-pointer items-center justify-between ...">
    <span className="flex-1 font-display text-base font-semibold text-tk-onyx">
      {question}
    </span>
    <svg className="tk-fold-chevron h-5 w-5 shrink-0" aria-hidden="true">…</svg>
  </summary>
  {/* Answer stays mounted (never conditionally rendered) so crawlers and
      FAQPage schema depend on the text being present in the DOM. */}
  <div className="px-6 pb-4 pt-0">
    <p className="font-body text-sm leading-relaxed text-tk-slate/80">{answer}</p>
  </div>
</details>

You get keyboard support, the correct semantics, and the disclosure state exposed to assistive technology without writing any of it. The chevron rotation and the border colour on open are handled with CSS against the open attribute — no state to keep in sync, nothing to hydrate.

Generate the schema from the rendered array

The rule that keeps this honest is mechanical: one array of question and answer pairs, passed both to the component that renders them and to the schema generator. Nobody maintains two copies, so nobody can update one and forget the other — which is how FAQ markup usually ends up describing an older version of the page.

lib/schema.ts
/**
 * Generate FAQPage schema. Only pass Q&A pairs that are rendered visibly on
 * the page, from the same array — markup must match on-page text word for word.
 */
export function generateFAQPageSchema(faqs: FAQItem[]) {
  return {
    "@context": "https://schema.org",
    "@type": "FAQPage",
    mainEntity: faqs.map((faq) => ({
      "@type": "Question",
      name: faq.question,
      acceptedAnswer: { "@type": "Answer", text: faq.answer },
    })),
  }
}

Word-for-word parity is the standard to hold, not approximate agreement. If the visible answer gets edited for tone and the schema keeps the old sentence, you are publishing two versions of your own answer and asking a machine to pick one.

How to check your own pages in a minute

Fetch the page the way a crawler does and search the raw HTML for a distinctive phrase from the middle of a collapsed answer:

Terminal
curl -s https://example.com/services | grep -c "phrase from a collapsed answer"

Zero means the text is not in the document, whatever the browser shows you — and it is worth running against tabs, modals, and "read more" toggles too, which fail the same way for the same reason. Content behind an interaction is a rendering choice; content that only exists after an interaction is a publishing bug.

The same instinct pays off across a site's structured data generally: schema should be generated from the thing being displayed, never authored alongside it. That is one of the quieter parts of building pages for search and AI-era retrieval, and it costs nothing once the wiring is right.

Questions

Does hidden content get ignored by search engines?

Collapsed content that is present in the DOM is indexed normally — Google has been explicit that content behind an accordion is treated like any other content. The problem is different: content that isn't in the DOM at all until an event fires isn't hidden, it's absent, and absent content can't be indexed.

Will FAQ rich results still show?

Rich result eligibility for FAQ markup has narrowed considerably and is largely reserved for government and health sites now. Keep the markup anyway: it's cheap, it stays valid, and its main value today is machine-readable question-and-answer structure that AI assistants can retrieve at passage level.

Do I need JavaScript for the open and close animation?

No. The native element handles the toggle, and a CSS transition on the chevron and the container covers the motion. You lose fine control over the expand easing, which is a small price for an accordion that works before hydration and can't drift out of sync with its own state.

What if the answers are long?

Then the accordion is doing real work and should stay. Length is an argument for collapsing the presentation, never for removing the text from the document. If a page has so much FAQ content that the DOM feels heavy, the fix is fewer questions, not lazier rendering.

Written by

Karol

Senior engineer and systems architect behind Tall Karol. Everything published here is grounded in real client work — no roundups, no tools that haven't run in production.

Why Tall KarolWork with Tall Karol

Related notes

More on web development

Related service: Web Development

Want this kind of engineering on your project?

Tall Karol takes on fractional and project-based engagements for startups and agencies.

Book a working session