FAQ Accordion Connected to Schema Markup with Alpine.js
AI generated
x-data
Alpine
Alpine.js · SEO · Structured Data · Frontend
FAQ Accordion Connected to Schema Markup with Alpine.js
One data source for visible content and rich snippets

A FAQ accordion and its accompanying FAQPage schema markup are, in practice, almost always maintained separately, which almost inevitably leads to drift between the visible content and the structured data. With Alpine.js you build a FAQ accordion that uses the same data source for the visible display and for the generated JSON-LD, so both are guaranteed to stay in sync and rich snippets keep working reliably.

18 min read x-data · x-collapse · JSON-LD · single source Alpine.js 3.x

1. Why maintaining the accordion and schema markup separately creates errors

A FAQ accordion and a FAQPage schema markup at first glance fulfill two different jobs: one is visible UI for users, the other invisible structured data for search engines. In practice, however, both are fed almost always from the same underlying question and answer pairs, and this is exactly where the problem arises when both are maintained separately in the markup. If an editor changes an answer in the visible FAQ accordion but forgets to update the corresponding JSON-LD, a discrepancy arises between what users see and what Google shows in the rich snippet.

Google itself explicitly warns that structured data must reflect the actually visible page content. A FAQ accordion with divergent schema markup violates this guideline and risks having the rich snippets disabled, manually or automatically. The technically cleanest solution is therefore not to maintain the accordion and the schema markup separately, but to generate both from a single, central data source.

The following sections build a complete FAQ accordion with Alpine.js that implements exactly this single source principle: one data array feeds both the visible accordion UI and the generated FAQPage JSON-LD, structurally ruling out any drift.

2. Foundation: a shared data source in the x-data state

The starting point is a simple array of objects, each with a question and an answer as plain text. This array is defined once and serves as the single source of truth for the entire FAQ accordion area of the page. It is important here that answers should be stored as plain text where possible, or with minimal, controlled HTML, because the JSON-LD later needs to take over the same text unchanged or only lightly cleaned.

In addition to the question and answer, each item in the FAQ accordion data array gets a boolean value for the open state. This state deliberately belongs to the item itself, not to a separate index based system, because that allows multiple entries to be open simultaneously, which is the desired behavior for most FAQ sections, unlike a classic accordion that keeps only one item open at a time.


// faqAccordion.js — single data source for visible UI and JSON-LD
document.addEventListener('alpine:init', () => {
  Alpine.data('faqAccordion', () => ({
    items: [
      {
        question: 'What is a FAQ accordion?',
        answer: 'A list of expandable question and answer pairs that saves space and lets users view only the relevant answers.',
        open: false
      },
      {
        question: 'Why connect schema markup to the accordion?',
        answer: 'So visible content and structured data are guaranteed to match and Google guidelines are followed.',
        open: false
      }
      // ... further question/answer pairs
    ],

    toggle(index) {
      this.items[index].open = !this.items[index].open;
    }
  }));
});

3. Rendering the visible FAQ accordion from the data array

The visible display of the FAQ accordion is produced by an x-for loop over the data array, where each item renders a question as a clickable header and an answer as an expandable content area. Clicking the header calls the toggle(index) method, which exclusively toggles the open state of that item without affecting any other entry.

A common beginner mistake when implementing a FAQ accordion with x-for: if no unique :key is set, Alpine can incorrectly reuse DOM elements when the list changes dynamically, for instance through a later filter feature, and assign the open state to the wrong entry. The index as a key works reliably for a static list, for dynamically filtered lists a stable, content based key such as a unique ID is preferable.


<div x-data="faqAccordion()" class="faq-accordion">
  <template x-for="(item, index) in items" :key="index">
    <div class="faq-item">
      <button
        @click="toggle(index)"
        :aria-expanded="item.open"
        class="faq-question"
      >
        <span x-text="item.question"></span>
      </button>
      <div x-show="item.open" x-collapse class="faq-answer">
        <p x-text="item.answer"></p>
      </div>
    </div>
  </template>
</div>

4. A smooth open close animation without fixed heights

The biggest technical problem when animating opening and closing a FAQ accordion is that CSS transitions fundamentally do not support animating height: auto. The classic workaround, setting a fixed maximum height, only works as long as no answer text exceeds that height, which with variably long FAQ answers is practically guaranteed to happen eventually and produces cut off content.

Alpine.js's official x-collapse plugin solves exactly this problem by measuring the actual content height at runtime and animating dynamically to it, independent of text length. For a FAQ accordion with answers of different lengths, x-collapse is therefore the only robust solution, without writing manual JavaScript to measure height.

5. Generating FAQPage JSON-LD from the same data source

Once the data array is established as the single source, the FAQPage JSON-LD is no longer written by hand but generated from exactly this array. On a server rendered page, as is the case with Magefan blog posts, this generation happens server side directly from the same PHP or template data structure that also feeds the visible FAQ accordion, not as a separate, redundantly maintained JSON object.

For purely client rendered applications, the JSON-LD can also be generated dynamically via JSON.stringify() from the Alpine state and injected into a script tag. In most blog and CMS contexts, however, server side generation is preferable, because search engine crawlers do not always execute JavaScript, and client side injected JSON-LD simply goes unseen in such cases.


// Same source array used both for the visible accordion (above)
// and the FAQPage JSON-LD (rendered server-side in a template)
const faqItems = [
  {
    question: 'What is a FAQ accordion?',
    answer: 'A list of expandable question and answer pairs that saves space and lets users view only the relevant answers.'
  },
  {
    question: 'Why connect schema markup to the accordion?',
    answer: 'So visible content and structured data are guaranteed to match and Google guidelines are followed.'
  }
];

// Transform once into schema.org FAQPage structure
function buildFaqSchema(items) {
  return {
    '@context': 'https://schema.org',
    '@type': 'FAQPage',
    mainEntity: items.map((item) => ({
      '@type': 'Question',
      name: item.question,
      acceptedAnswer: { '@type': 'Answer', text: item.answer }
    }))
  };
}

6. Validation: the Google Rich Results Test and common pitfalls

After merging the FAQ accordion and the JSON-LD, every page should be run through the Google Rich Results Test before going live. Common pitfalls are missing acceptedAnswer fields, empty answer strings because an answer has not yet been filled in in the CMS, or incorrect nesting where mainEntity is accidentally not an array but a single object.

A second, more subtle mistake concerns the number of questions. Google usually shows only two to three FAQ entries as a rich snippet in the search result preview, even if the FAQ accordion contains considerably more questions. This is not a technical error or a misconfiguration, it is Google's intended behavior, outside the site operator's control, and cannot be influenced through additional markup tricks.

7. Correctly stripping HTML tags from answers for JSON-LD

If an answer in the visible FAQ accordion contains HTML formatting, for instance a link or a bullet list, this HTML must not be carried over unchanged into the text field of the FAQPage JSON-LD. Google does accept a limited set of simple tags such as <a>, <ul> and <li>, but more complex markup, inline styles or scripts inside the answer lead to validation errors or are simply ignored by Google.

The more robust solution for a technically clean FAQ accordion is to keep answers as plain text by default and, where formatting is absolutely necessary, only use the allowed, minimal tags. Server side, a cleanup function can additionally be applied that automatically strips disallowed tags before JSON-LD generation, without changing the visible display in the accordion itself.


// Strip disallowed HTML before generating JSON-LD — plain text only
function sanitizeForSchema(html) {
  const allowedTags = ['A', 'UL', 'LI', 'STRONG', 'EM'];
  const container = document.createElement('div');
  container.innerHTML = html;

  container.querySelectorAll('*').forEach((el) => {
    if (!allowedTags.includes(el.tagName)) {
      el.replaceWith(document.createTextNode(el.textContent));
    }
  });

  return container.innerHTML;
}

8. Accessibility: aria-expanded and semantic markup

A FAQ accordion should use real <button> elements for the question headers, not clickable <div> or <span> elements, because buttons are automatically focusable by keyboard and operable with Enter or Space. The aria-expanded attribute, dynamically bound to each item's open state, tells screen readers whether the corresponding section is currently open or closed.

In addition, the expandable answer area should be referenced via aria-controls with a unique ID, so the semantic relationship between question and answer is unambiguous for assistive technologies too. These attributes are independent of the schema markup and only concern the accessibility of the visible FAQ accordion for people using screen readers or pure keyboard operation.


<!-- Semantic button with aria-expanded and aria-controls -->
<button
  @click="toggle(index)"
  :aria-expanded="item.open"
  :aria-controls="`faq-answer-${index}`"
  :id="`faq-question-${index}`"
>
  <span x-text="item.question"></span>
</button>
<div
  :id="`faq-answer-${index}`"
  role="region"
  :aria-labelledby="`faq-question-${index}`"
  x-show="item.open"
  x-collapse
>
  <p x-text="item.answer"></p>
</div>

9. FAQ schema approaches compared

There are several common ways to connect a FAQ accordion to schema markup, with substantial differences in maintainability and error proneness.

Approach Error proneness Maintainability Synchronization
Manually maintained JSON-LD separate from the accordion High Two places per change Not guaranteed
SEO plugin without a visible accordion connection Medium Via plugin UI Separate data maintenance
FAQ accordion with a shared Alpine.js data source Structurally ruled out One place per change Guaranteed identical
Plain HTML accordion without any schema markup No schema error possible Simple No rich snippet

The comparison shows that only a FAQ accordion with a shared data source structurally rules out drift between visible content and structured data, instead of relying on disciplined manual maintenance in two separate places.

Mironsoft

Alpine.js components and structured data for Magento Hyvä shops

A FAQ accordion that also delivers rich snippets?

We build custom Alpine.js components for your Hyvä shop, from FAQ accordions with clean schema markup to pricing tables and cookie banners, technically sound and SEO compliant.

Schema audit

Reviewing existing FAQPage markup for synchronization with visible content

Custom development

FAQ accordions and further marketing widgets with Alpine.js

SEO consulting

Identifying rich snippet potential and implementing it validly

10. Summary

A clean FAQ accordion connected to schema markup above all needs a single, shared data source for visible content and structured data. Alpine.js provides the right tools with x-data and x-collapse to build a smoothly animated accordion, while the same data array is transformed server side or client side into valid FAQPage JSON-LD. This coupling structurally rules out visible content and rich snippet ever drifting apart.

The details still matter: HTML formatting in answers must be cleaned before JSON-LD generation, every page should be run through the Google Rich Results Test, and semantic markup with aria-expanded and real button elements makes the FAQ accordion fully usable for screen reader users too. Anyone who observes these points ends up with a FAQ system that reliably serves both users and search engines.

FAQ Accordion Connected to Schema Markup — The Essentials at a Glance

Single source

One data array feeds both the visible accordion and the FAQPage JSON-LD.

Animation

x-collapse measures the actual height at runtime, independent of text length.

HTML cleanup

Reduce answers to allowed, minimal tags before JSON-LD generation.

Accessibility

Real button elements with aria-expanded and aria-controls.

11. FAQ: FAQ Accordion Connected to Schema Markup with Alpine.js

1Why share the same data source?
Separate maintenance almost inevitably leads to drift, violating Google guidelines.
2Why not a fixed max-height?
CSS transitions do not animate height: auto, fixed values break with longer text.
3Benefit of x-collapse?
Measures actual height at runtime, independent of text length, no manual JavaScript.
4Server or client side generation?
Server side preferable, since crawlers do not always execute JavaScript.
5Not all FAQ entries visible?
Google usually shows only two to three, this is intended behavior.
6HTML allowed in answers?
Only limited, simple tags like a, ul, li, more complex markup causes errors.
7Why real buttons?
Automatically keyboard operable, a div needs additional handlers.
8Why aria-expanded?
Tells screen readers the current open or closed state.
9Testing JSON-LD validity?
With the Google Rich Results Test, revealing missing fields and errors.
10Index as key problematic?
Not for static lists, prefer a stable ID for filtered lists.