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,
/faqand/faq/<category>, with a search box. - Theme helpers, a ready-made accordion, and a schema.org
FAQPageblock 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.
Search
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.