My quiz site shows one question at a time: a big card, Yes and No buttons, a progress bar. It's a nicer experience on a phone than a wall of 100 checkboxes. It also hid 99% of the page from search engines, and I only noticed because of a ranking that made no sense.
The symptom
Search Console showed two of my pages competing for the site's main keyword:
- the homepage, which is the quiz, at an average position of about 45;
- a plain "all questions" page, just a list with short notes, at about 28.
The list page has no quiz on it at all. So why was Google preferring it for a query where people clearly want to take the test?
Look at what the crawler gets
The first thing a crawler receives is the HTML your server sends. I fetched my homepage's HTML, stripped scripts and styles, and checked whether a handful of question texts were in what was left:
import html, re, urllib.request
def visible_text(url):
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
raw = urllib.request.urlopen(req).read().decode("utf-8", "ignore")
raw = re.sub(r"(?is)<(script|style|noscript|svg)[^>]*>.*?</\1>", " ", raw)
return html.unescape(re.sub(r"<[^>]+>", " ", raw))
probes = ["Held hands romantically", "Been on a date", "Kissed a non-family member",
"Been convicted of a felony"] # 8 in total, spread across the test
text = visible_text("https://example.com/")
print(sum(p in text for p in probes), "of", len(probes))
My homepage: 1 of 8. Then I ran the same check against the pages ranking in the top four for that keyword: 8 of 8, every one of them. My own list page: also 8 of 8.
Word count wasn't the difference. The #1 result had about 700 words of visible text, and my homepage had more. The difference was that every page Google ranked above mine put the whole test in its HTML, and mine put one question.
Why it happens
The quiz is a client component. Its state is "which question are you on", restored from localStorage so a refresh doesn't lose your place. At build time (this is a static export) there's no saved progress, so the prerendered HTML contains the initial state: question 1. The other 99 questions exist only as data inside the JavaScript bundle.
"But Google runs JavaScript." It does, but rendering the page doesn't change anything here. Googlebot doesn't click Yes or No, so the rendered DOM still shows one question. Any UI where content is revealed by interaction has the same problem: a step-by-step wizard, a carousel, tabs that mount their panels lazily.
The fix: render the state space as content
I didn't want to change how the quiz works. Instead, below the FAQ, the page now renders every question as a plain numbered list, in a server component:
// components/content/QuestionList.tsx — a server component, no 'use client'
export function QuestionList({ id, heading, intro, questions }: {
id: string;
heading: string;
intro: React.ReactNode;
questions: readonly string[];
}) {
return (
<section id={id} className="mt-12">
<h2>{heading}</h2>
<p>{intro}</p>
<ol className="list-decimal pl-8 sm:columns-2 sm:gap-10">
{questions.map((q, i) => (
<li key={i} className="break-inside-avoid">{q}</li>
))}
</ol>
</section>
);
}
// app/(en)/page.tsx
<Quiz />
{/* …about, scoring, FAQ… */}
<QuestionList
id="all-questions"
heading="All 100 questions"
intro={<>The full list in the original order. The quiz above asks them one at a time.</>}
questions={QUESTIONS}
/>
It's the same array the quiz uses, so the two can't drift apart. The list is useful for people too: some visitors want to see what's coming before they start. And it doesn't duplicate the dedicated questions page, because that page groups the questions by theme and explains the confusing ones; the homepage list links there.
Two columns on desktop keep the section from becoming a 100-line scroll. break-inside-avoid stops a long question from splitting across the columns.
Lock it in with a test
This kind of regression is invisible in the browser, because the page looks fine. So there's a test that renders the page and checks the list:
it('lists all 100 questions in their original order below the quiz', () => {
const { container } = render(<HomePage />);
const section = container.querySelector<HTMLElement>('#all-questions')!;
const items = within(section).getAllByRole('listitem').map((li) => li.textContent);
expect(items).toEqual([...QUESTIONS]);
});
After deploying, I re-ran the probe against the live page: 8 of 8, and visible text went from about 860 words to about 1,520. Then I requested reindexing in Search Console. I applied the same fix to the French version and to the two shorter variants, which had the identical problem.
I don't have ranking results yet, as it's been a few days. What I'm confident about is the diagnosis: for a query whose intent is "take this test", every competitor that outranked the page served the test's content in HTML, and the page didn't.
The takeaway
If your page's main content is state, the questions of a quiz or the steps of a wizard, prerender the whole state space somewhere on the page as ordinary HTML. Then check it the boring way: fetch the HTML, strip the scripts, and search for your content. It takes five minutes and it would have saved me two weeks.
The site is ricepuritytestup.com. (It's my site. The quiz asks personal yes/no questions; your answers are scored in your browser and stay there.)
Top comments (0)