D
P
0
← All articles Baca dalam Bahasa Indonesia

Technical SEO, Redirects & Migrations

Tabbed FAQ Invisible to AI Crawlers? Server-Render All Hundred Q&A into Static HTML

· · 6 min read
Tabbed FAQ Invisible to AI Crawlers? Server-Render All Hundred Q&A into Static HTML

The long-scroll version was rejected first, and that was a sensible rejection. A hundred question and answer pairs stacked into one page really is exhausting to read. The shape that got built instead was ten categories behind compact tab-style pills, one category visible at a time.

The material came from the client. They handed over a hundred question and answer pairs, and someone on the client side said the FAQ page is how people find them through AI assistants like ChatGPT and Claude. They positioned the page as part of how they win work, not as footer decoration.

So one page had to serve two readers whose demands pull in opposite directions. One of them wants to see a little at a time, the other one only reads what is genuinely present in the HTML.

The first version was not a stub

The FAQ page came out of a client revision round that arrived over WhatsApp, and shipped as a single commit touching seventeen files, alongside a mobile drawer rebuild in the same round. That first version already carried cross-category search, a FAQPage JSON-LD block emitting all one hundred entries for SEO, and links in the nav, the footer, and the sitemap.

If that list is what you look at, the page looks finished as far as machines are concerned.

What went on record as the problem

The problem note for this page points at one thing. Content that mounts conditionally on the client is not present in the raw HTML, so crawlers without JavaScript and AI crawlers never see it, even though that reader is the whole reason the page was built. The root cause was read as a conditional render driven by tab state.

The general shape looks roughly like this, and there is nothing strange about writing it that way:

{categories.map((cat) =>
  cat.slug === activeCategory ? (
    <CategoryPanel key={cat.slug} category={cat} />
  ) : null
)}
<button onClick={toggle}>{question}</button>
{isOpen && <div className="answer">{answer}</div>}

To a human eye both do exactly what was asked. Click a pill and the other categories disappear. Click a question and its answer appears. But neither of them hides anything. Both refuse to create the element. When the page renders on the server, the tab state is still its initial value and every answer is still closed, so the losing branch produces nothing to print. The rest is not hidden, the rest was never there.

The site is a Next static export, output: 'export' with images.unoptimized: true, so the HTML that reaches a crawler really is the build output. There is no server layer left to patch it afterwards.

The fix

The SEO hardening did not ride along in the commit that introduced the FAQ page, it was a commit of its own. It came down to one rule: all one hundred Q&A server-render into static HTML, and no part of it may be conditionally mounted.

For categories, the inactive ones are held by the hidden attribute. The general shape is something like this:

{categories.map((cat) => (
  <section
    key={cat.slug}
    id={`category-${cat.slug}`}
    hidden={cat.slug !== activeCategory}
  >
    <CategoryPanel category={cat} />
  </section>
))}

All ten sections still print and nine of them carry hidden. The attribute hides an element through the browser's own stylesheet and makes assistive technology skip it, without taking the text out of the document that gets sent.

For collapsed answers, the mechanism is CSS grid-rows moving from 0fr to 1fr. The general shape is something like this:

.answer {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 200ms ease;
}
 
.answer[data-open="true"] {
  grid-template-rows: 1fr;
}
 
.answer-inner {
  overflow: hidden;
}

The element never leaves the DOM and only the row height changes, so the open and close animation survives without measuring heights by hand and without a guessed max-height that breaks the moment an answer runs long.

What did not change at all is the compact tabbed UX, and that was the point.

How it was checked

The first check ran on staging by pulling the raw HTML with curl, because that is what an AI crawler that does not execute JavaScript sees, and the result was recorded as consistent with the FAQPage JSON-LD block.

The check that recorded numbers ran on production. One hundred questions and one hundred answers server-render into raw HTML, and the FAQPage JSON-LD block holds all one hundred entries, matched by script against the FAQ data file. That check is recorded as satisfying the client requirement above, and in the same session 33 of 33 routes answered 200 and eight security headers were present.

Counting on the structured data side can be as simple as this:

curl -s "$FAQ_URL" > faq.html
grep -o '"@type":"Question"' faq.html | wc -l

That number on its own proves nothing, because it only measures the JSON-LD block. What makes the check mean something is comparing it against two other counts: how many question and answer texts actually appear in the body of that same HTML, and how many entries live in the FAQ data file. All three have to meet at the same number, and the day someone moves a piece back behind a conditional render, the three drift apart.

Two verification traps from the same project

The first one: plain curl produced a false negative once, though not on this FAQ page. The static host answers 308 from the trailing-slash address to the one without it, and without -L curl comes back with an empty body. The effect was that a public key looked like it had not been baked into the bundle, when with -L the key was there in the static chunks all along.

The second one: verifying env vars through sitemap.xml proves nothing on this project, because the site URL variable has an identical hardcoded fallback. The output is the same whether the env var is set or not.

The lesson

The question I carried out of this reaches well beyond an FAQ page. The tool you are checking something with, does it have any way to say no? A JSON-LD block that holds all one hundred says nothing about the body sitting next to it, the same way a sitemap with an identical fallback says nothing about an env var. A check that cannot fail is not a check.

The rest is one habit. A conditional render is not hiding, it is declining to create. If a piece of text has to be findable by a machine, it cannot stand behind state. Print all of it first, then hide what needs hiding with the hidden attribute and CSS. And the most reassuring part, the tabs are still tabs.