FAQ module

Module id: faq. Dependencies: none.

What it adds:

  • Questions: a question and its answer, stored as a row rather than as another paragraph in a hand-written accordion.
  • Categories: optional named groups of questions (Verzending, Retourneren), each with its own page.
  • An FAQ item in the admin sidebar (Content section), where you write, reorder, publish and group them.
  • Two public pages, /faq and /faq/<category>, with a search box.
  • Theme helpers, a ready-made accordion, and a schema.org FAQPage block for machine readers.

Nothing here reaches into products, orders or media, so a brochure site with no webshop can run an FAQ on its own.

The unit is one question

An FAQ is a list of question-and-answer pairs, and each pair is a row. That is what makes the rest work: an editor reorders without touching markup, a visitor searches over questions and answers, the sitemap can report that one answer changed, and a machine reader can lift a single self-contained pair out of the page.

A category is optional. Most FAQs are a flat list of ten questions, and inventing a grouping before writing the first answer buys nothing. Uncategorised questions render straight on /faq, above the categories. Add categories when the list grows past what a visitor will scan, not before.

One FAQ per language, not one FAQ translated

A category and a question each belong to exactly one language. There is no translation panel, and no fallback to another language.

This is deliberate and it is the one thing to understand before writing content. A FAQ is not the same page in several languages: different countries ask different things, and a Dutch FAQ grouped for Dutch customers is not the German one with the labels swapped. Forcing every language to answer the same list means either padding it with questions nobody asked or leaving half the list in the wrong language.

So the admin screen asks which language you are working in first, and everything below it is that language's own FAQ. A language you have written nothing for renders nothing, rather than the default language's questions.

Slugs are unique per (website, language), so /nl/faq/verzending and /de/faq/versand are separate pages, and two languages may even share a slug without colliding.

Sites created before this changed had one set of categories with translated text on them. The migration turned every translation into a row of its own, so each language kept what it had; there is nothing to redo by hand.

What operators set

Category

Field What it does
Name The heading a visitor scans for, in this category's own language.
Slug Generated once from the name. It is the URL: /faq/<slug>, and the handle a theme passes to cms_faq({ category: '<slug>' }).
Intro Optional sentence of context above the questions. Plain text, max 500 characters; markup is stripped.
Published Off hides the category and every question in it from the site.
Order Drag to set the order the categories appear in.

Question

Field What it does
Question Written as a visitor would ask it, max 500 characters. Plain text.
Answer Rich text, sanitised on save against the same allowlist as a page's html field, so an answer pasted out of Word brings no styles or scripts with it.
Category One of this language's categories, or none. Moving between them is a normal edit.
Slug The anchor a shared link points at: /faq/verzending#faq-<slug>. Unique across the language.
Published Off hides the question. See the rule below.
Order Drag within the category (or within the uncategorised list).

Rules that bite

Renaming never moves a URL. Slugs are generated once and then left alone. Renaming "Verzending" to "Bezorging" keeps /faq/verzending, and rewording a question keeps its anchor, so a link somebody shared last year still lands on the answer. Editing the slug field explicitly is how you move one, and that is a redirect you are choosing to need.

A question cannot be published without an answer. Publishing an empty one is refused on save. It is the one shape that both disappoints a visitor and, once a machine reader lifts the pair out of the page, misrepresents the site. Saving it unpublished is fine, and is how a question gets drafted. Clearing the answer of a published question is refused just as loudly.

An empty category never renders. A category with no published questions is dropped from the site entirely, not rendered as a heading over nothing. So a new category stays invisible until it has a published question in it: if a section you created is not on the site, check that first.

Deleting a category keeps its questions. They become uncategorised and reappear on /faq. Deleting a grouping is not a way to delete the answers that happened to sit in it; delete those individually if that is what you meant.

Unpublishing a category hides its questions with it, without changing their own published flag. Publish the category again and they come back as they were.

The public pages

URL What it shows
/faq The uncategorised questions, then every published category with its questions.
/faq/<slug> One category, plus links to the other categories.
/faq?q=<term> Search results, above the normal list.

On a multi-locale website both carry the locale prefix (/nl/faq), like pages and blog articles do, because each language's FAQ is different content and not a translation of the other.

Every answer is in the HTML of every request, collapsed with the browser's own <details> element. Nothing is fetched on click. That is the point: an answer injected by script is invisible to anything that does not run JavaScript, which includes the answer engines this content is most useful to.

The box is a plain GET with ?q=, so a result is a page a visitor can bookmark, share and reach with the Back button, and it works with the script broken.

Results are ranked: questions that match first, then questions in a matching category, then questions whose answer matches. Within a rank they keep the editor's order, so two runs of the same search never disagree. At most twenty results are shown, there is no second page, and the search only ever sees the current language's published questions.

Search-result pages are noindex, follow: they are slices of answers that already have a canonical home, and indexing them would fill the index with near-duplicates. The unsearched /faq and the category pages index normally.

Structured data and the sitemap

The bundled accordion emits a schema.org FAQPage block built from exactly the questions rendered underneath it, so the structured data cannot describe a different set than the visitor sees.

Be realistic about what that buys: Google dropped FAQ rich results from ordinary search results in 2023. This is correctness for machine readers, not a decoration play. What gets a site quoted is the clean server-rendered markup.

One page should carry one FAQPage. A page that renders the accordion twice must pass jsonld: false on the second call (see twig-helpers.md).

/sitemap-faq.xml lists the category pages, one entry per language for the categories that language actually has. lastmod is the newest question in the category rather than the category row, so rewriting one answer is visible to a crawler even though the category itself was not touched. There are no hreflang alternates between them: two languages that happen to share a slug are different questions, not translations, and claiming otherwise would be a lie to a crawler. The /faq index itself is not listed.

What a theme has to do

Nothing, if the theme ships no FAQ template: /faq and /faq/<slug> fall back to a bundled page. To put an FAQ somewhere else, the short version is one call:

{{ cms_faq() }}
{{ cms_faq({ category: 'verzending', heading: 'Verzending en retour' }) }}

That renders the questions, the structured data, and registers its own JS and CSS. It returns an empty string when the module is off or there is nothing published, so a shared theme can call it unconditionally, which is also what makes it safe to embed one category on the page it belongs to (the shipping category on the shipping page).

Options are category, search, heading, open_first, aria_label and jsonld. Signatures and the raw-maps route for a theme that builds its own markup are in twig-helpers.md.

Accessibility and no-JS behaviour

Each question is a <details>/<summary> pair with an <h3> inside the summary. Opening, closing, keyboard operation and focus all come from the element itself, so every question still works with the script missing, and the heading outline stays intact for a reader lifting one pair out.

faq.js adds only what the element cannot do alone: a link to #faq-<slug> opens the question it points at and scrolls to it, and opening a question rewrites the address bar (with replaceState, so Back leaves the page rather than walking through everything that was expanded).

Overridable templates

@GsidCmsCore/pages/faq.html.twig  →  @website/pages/faq.html.twig   (the page wrapper)
@GsidCmsCore/faq/_faq.html.twig   →  @website/faq/_faq.html.twig    (the accordion)

They are separate on purpose: a theme can restyle the page around the questions without taking over the accordion, or the reverse. Before overriding the accordion, try the --cms-faq-* custom properties in cms-public/public/assets/faq.css, which cover width, spacing, the rule between questions, the marker and the search box without giving up the <details> semantics.

Enabling it

Modules tab → FAQ → save. No dependencies to enable first, and no Module settings panel: everything lives on the categories and questions themselves. The FAQ sidebar item appears for editors and above; with the module off, the admin endpoints and both public routes 404.