Skill 16 · Wix Headless Templates
Subchapter 16.351
faq/INSTRUCTIONS.mdMarkdown21 KBView on GitHub
The FAQ machinery ships as files — category and question reads over the Wix FAQ app (owner order, the Ricos answer rendered to sanitized HTML), one shared state machine for the page (category filter, search, the accordion, the URL), its React binding, and reference components, typed end-to-end. The presentation is yours to design on the shipped hook/DTOs: the accordion’s look, the category nav, the search box, the FAQ page, the home page’s “top questions” strip, and the brand. You never write FAQ data logic; you never skip designing. The vertical is — visitors read; authoring stays in the dashboard.
On Astro and React the shipped files are tested and work as they are — this table and the
contracts below are everything you need to use them, so don’t spend the run reading their source;
wire them and build your surfaces. Reading them is the right move when something is off (a runtime
error, a field this playbook doesn’t cover) or when the brief wants a behaviour they don’t offer —
then read the file that owns it and change or extend it. On lib, static, and a port the hooks
and components don’t deploy at all; the store behind them does, and each wiring section below says
how to bind it. Files you edit: SiteLayout.astro, styles/global.css, and the shipped page’s
template. Files you create: your accordion island (skeleton below) and your home page.
| file | what it is |
|---|---|
wix/config.ts · wix/sdk.ts | shared auth seam (deploy configures it — nothing to set by hand) |
wix/media.ts · wix/money.ts | shared helpers; an answer’s inline images are already resolved https URLs inside answerHtml |
wix/faq/types.ts | the DTOs (FaqCategory, FaqQuestion, FaqSection, FaqData, FaqQuestionPage) — contracts below |
wix/faq/faq.ts | fetchFaq (everything the page needs), fetchCategories, fetchQuestions (one cursor page, optionally one category), fetchQuestionById — the transport; the rules and DTO mappers are in faq-core.ts beside it (shared with the REST layer) |
wix/faq/faq-core.ts | the rules: owner ordering, grouping into sections, client-side search, the deep-link spelling (anchorId, faqDeepLink, parseDeepLink), faqJsonLd (schema.org FAQPage), the Ricos-to-HTML subset, the wire spelling of a query |
wix/faq/faq-store.ts | the page’s state machine, framework-free and module-scoped (one store per page, not per surface — the nav, the search, and the accordion share it across islands): seedFaq, getFaqState/subscribeFaq + actions; the hook binds it to React, every other stack uses it directly |
hooks/faq/useFaq.ts | React binding of faq-store.ts — contract below |
components/faq/FaqAccordion.tsx · FaqCategoryNav.tsx · FaqSearch.tsx | reference implementations — correct, plain, accessible; build your own instead of shipping them |
styles/global.css | the design system: Tailwind v4 + the @theme token block (shared across verticals). Everything, shipped and yours, styles from these tokens |
Astro stack additionally gets:
| file | what it is |
|---|---|
layouts/SiteLayout.astro | site chrome — yours to brand (keep the seo-tags slot + global.css import). If another vertical is also deployed, its layout won — add an FAQ nav link there |
pages/faq.astro | SSR FAQ page — keep the frontmatter (the fetch, the ?category= pre-selection, the FAQPage JSON-LD in the seo-tags slot); swap the island imports to YOUR components. Not a Wix item page: there is no owner-editable SEO type for FAQ, so <title>/description come from the layout props |
useFaq, grouped under category
headings in owner order; skeletons while data === null, an honest empty state, a “no results”
state with a clear control while searching, an error line with retry.setQuery, the results count
announced. Three islands or one — the store is shared either way.pages/faq.astro‘s template around your islands (heading, intro copy
from the brief, the islands). Keep the frontmatter.fetchFaq() in the frontmatter → your markup), each linking to
faqDeepLink(q).Plus the theme (@theme block, one edit) and the chrome (SiteLayout, one pass). Style
everything with Tailwind utilities on the tokens; the answer’s HTML is plain tags (p, h4,
ul/ol/li, blockquote, pre/code, a, img, hr, strong/em/u/s) — style them
through descendant variants on your wrapper ([&_p]:my-2 [&_ul]:list-disc …), as the reference does.
Defaults for a brief that says nothing about them; the prompt wins where it differs. Look at the seeded content before designing (how many categories, how long the answers run, whether they carry lists or images) and design for this content, not for a stereotype of an FAQ.
/faq#q-<slug>; not a copy of the FAQ page.Tested and working as they are; read the source when something is off or the brief wants more.
// FaqCategory: { id, title, sortOrder /* number | null */, questionCount /* hide empty categories with it */ }
// FaqQuestion: { id, slug /* "" when absent */, question,
// answerHtml /* sanitized HTML — render via set:html / dangerouslySetInnerHTML on a wrapper */,
// answerText /* plain text — snippets, search, JSON-LD */,
// categoryId, sortOrder, shareLink /* Wix's own widget URL, "" unless asked */, labels /* not for visitors */ }
// FaqSection: { category, questions } // one category's questions in owner order
// FaqData: { categories, questions, truncated /* true → more than MAX_QUESTIONS (500) exist */ }
// fetchFaq() → FaqData // categories (counted) + every question, owner order
// fetchQuestions({ categoryId?, limit? /* ≤100 */, cursor? }) → { questions, nextCursor }
// fetchCategories() → FaqCategory[] // questionCount is 0 here; fetchFaq fills it
// fetchQuestionById(id) → FaqQuestion | null
// useFaq({ initialData?, initialCategoryId?, expandOnlyOne? /* true */, openFirst? /* false */, syncUrl? /* true */ }) →
// { data: FaqData|null /* null = loading → skeletons */,
// sections: FaqSection[] /* the VISIBLE questions grouped by category — render these */,
// visible: FaqQuestion[], activeCategoryId, selectCategory(id|null),
// query, setQuery(q), resultsCount /* visible.length while a query is typed */,
// expandedIds, isExpanded(id), toggleQuestion(id), expandQuestion(id), collapseAll(),
// loading, error, retry() }
// One store per page: every island that calls useFaq sees the same state. Pass the page's SSR props
// to EVERY island (the first to render seeds; the rest are no-ops) so server HTML and first paint agree.
// anchorId(q) → "q-<slug || id>" // put it as the id of every question's wrapper
// faqDeepLink(q, base = "/faq", categoryId?) → "/faq?category=…#q-<slug>"
// faqJsonLd(questions, pageUrl?) → the FAQPage object (the page already emits it)The Astro page ships; its frontmatter is machinery (SSR fetch → DTO props → islands; the JSON-LD; the
category pre-selection) and stays as shipped. What you create is the accordion island faq.astro
mounts (and your nav and search, or one island holding all three). Hooks first, branches after (an
early return above a hook changes hook order and React throws). Islands render on the server too,
after the 200 is sent — nothing in a render path may throw.
// src/components/faq/<YourAccordion>.tsx — YOU build it; faq.astro mounts it (swap the import there).
import { useFaq } from "../../hooks/faq/useFaq";
import { anchorId, faqDeepLink } from "../../wix/faq/faq-core";
import type { FaqData } from "../../wix/faq/types";
export default function YourAccordion(props: {
initialData?: FaqData; // SSR props from faq.astro — pass straight to useFaq;
initialCategoryId?: string | null; // omitted in a SPA (client fetch)
}) {
const { data, sections, query, setQuery, isExpanded, toggleQuestion, error, retry } = useFaq(props);
// …you implement the render:
// • data === null → skeleton rows; data.questions.length === 0 → your honest empty state
// • error → a short inline message with a retry control
// • query set and sections empty → "No results for '…'" with a clear control (setQuery(""))
// • else per section: the category title (an <h2>) and per question a wrapper
// <div id={anchorId(q)}> holding an <h3><button aria-expanded={isExpanded(q.id)}
// aria-controls={panelId} onClick={() => toggleQuestion(q.id)}>{q.question}</button></h3>
// and a <div id={panelId} role="region" hidden={!isExpanded(q.id)}> with the answer:
// <div dangerouslySetInnerHTML={{ __html: q.answerHtml }} /> — the ONLY HTML render — and a
// link to faqDeepLink(q) (plus a "copy link" control if you like)
// • data.truncated → one honest line ("Showing the first N questions")
}On lib, static, and a port, nothing under components/ or hooks/ arrives. The state machine
behind the hook does arrive — wix/faq/faq-store.ts — so you never rewrite it: seedFaq(options)
once, subscribeFaq, render from getFaqState(), call its actions. Its FaqState interface is the
render contract; read it. What you write is the rendering — the accordion, the nav, the search — and
for that read these first; they are tested code for exactly that behaviour:
components/faq/FaqAccordion.tsx — the button-in-heading + region pattern with aria-expanded,
aria-controls and hidden; the states in order (skeleton → error → empty → no results →
sections); the copy-link control; the truncation line.components/faq/FaqCategoryNav.tsx — “All” first, only categories with questions, aria-pressed
on the active pill, the asLinks no-JS variant.components/faq/FaqSearch.tsx — the 250 ms debounce with immediate flush on Enter and on clear,
the aria-live results count.All under templates/faq/app/.
@theme tokens (one edit); brand SiteLayout.astro (one pass — merge into the other
vertical’s layout instead if both are deployed).src/components/faq/ (new names — don’t overwrite the references) and
swap the island imports in pages/faq.astro; restyle its template (keep the frontmatter).
Author your surfaces in as few messages as possible — batch multiple Writes per message.pages/index.astro (home) — it exists from the scaffold; Read it before overwriting.Read the reference files listed above before writing any surface.
deploy.mjs faq --stack lib put the data layer in src/wix/ and nothing else: sdk.ts (the
visitor client, configured with the public client id), media.ts, money.ts, and wix/faq/ —
faq.ts, types.ts, faq-core.ts, and the store faq-store.ts. None of it is React. The hook and
components don’t ship on this stack; the store replaces the hook, and you write the components in
your framework to the contracts on this page:
shallowRef updated in
subscribeFaq; Svelte: readable(getFaqState(), (set) => subscribeFaq(() => set(getFaqState())));
Solid: a signal set in subscribeFaq). seedFaq({ initialData? }) once when the page’s data is
known (a SPA passes nothing and the first subscription fetches); groupByCategory(state.data.categories, state.visible) from faq-core gives the sections. State in, actions out — exactly the hook’s
contract above.Route /faq (?category= and #q-… are read and written by the store); dev server on 4321; a
static build goes through npx @wix/cli@latest release with site.outputDirectory pointing at the
build folder, an SSR build is hosted by you. Page tags from the first questions.
Read static.md in this folder: it is the whole playbook for this stack, in one file. The page
imports the modules from the site’s js/wix/ folder and writes the accordion, the category nav
and the search on the store; static.md shows how to drive it, with the routes, the rules and the
seed.
Read the reference files listed above before writing any surface.
Run deploy.mjs faq --stack static in the project folder anyway: js/wix/ is both the browser-side
code and the readable spec. Reads render on the server: port js/wix/faq.ts and faq-core.ts to
your language — the same four functions returning the same DTO shapes as dicts, one anonymous
visitor token per process for these public reads (mint and refresh per client.ts) — the
Ricos-to-HTML subset ports with the core (escape text, allow only http(s) links, the node table in
faq-core.ts), and the FAQPage JSON-LD is server-rendered from the same DTOs, so questions and
answers are in the HTML. Nothing in this vertical runs on the visitor’s behalf, so the browser side
is optional: the category filter, the search, and the accordion can run client-side on
./js/wix/faq-store.js exactly as the static wiring above, or as plain server routes
(/faq?category=<id>, answers rendered open when there is no script). Route stays /faq.
Pre-rendered (Frozen-Flask, Pelican, any static-site generator) → Wix-hosted. Same port for the
reads, run at build time with one anonymous token — walk fetchQuestions by nextCursor until it
is null (or call the ported fetchFaq). Run deploy.mjs faq --stack static --out <build dir> so
js/wix/ is inside the output the page imports from, point site.outputDirectory at that folder,
wix release. Give the templates one base path to js/wix/ (root-relative /js/wix/…), never a
relative ./js/wix/. The frozen page is the first paint; filter, search, and the accordion still run
client-side on ./js/wix/faq-store.js. Close with the live URL, the rebuild + release command, and
one line for the owner: questions edited in the dashboard reach the site when that command runs.
Import ./styles/global.css once at the app entry (needs @tailwindcss/vite in the vite plugins —
deploy added the dep). Route /faq → your islands on useFaq() (no initialData — the store
fetches on mount and reads ?category=/#q-… from the address bar). Deploy wrote the public client
id into wix/config.ts; nothing else to configure.
Routes on Wix hosting: the host serves files only, so a clean route answers 404 when loaded directly — hash routes, or one HTML file per route, decided before the first route is written; any URL handed to Wix as a return target must be one the host serves (SKILL.md step 1).
@wix/faq in a component, never
re-derive a request shape; extend by adding a function in wix/faq/ (API contracts: the
wix-docs skill).answerHtml is HTML, answerText is copy — render the body with set:html /
dangerouslySetInnerHTML on a wrapper you control, and only that field; never interpolate it as
text, never innerHTML anything else (question, titles, labels are text).question by prefix only and has no full-text search, so the whole FAQ is loaded once (capped at
MAX_QUESTIONS, 500) and filtered in the store; this is the one vertical where “page at the source”
does not apply. Never re-implement search against the API, never raise the cap; render
data.truncated honestly.sortOrder, already applied) — never sort
alphabetically or by length unless the brief says so./faq#q-<slug> — every question’s wrapper carries id={anchorId(q)}; links to a
question go through faqDeepLink(q); never hand-build them from ids. shareLink is Wix’s widget
URL, not this site’s — don’t surface it as the share target.<button aria-expanded aria-controls> inside a heading, the answer a role="region" panel it labels, collapsed with
hidden (still in the DOM — that is what makes the FAQPage markup honest). Never collapse by
unmounting the answer.useFaq (the store seeds once) — an island
without them renders skeletons on the server and flashes on hydration.@theme tokens, your markup in Tailwind utilities
on the same tokens; no parallel theme files, no hardcoded palettes. Where they don’t (lib,
static, a port): style with what your stack does well, on one token set of your own.Hand the owner this link — {siteId} is siteId in wix.config.json (the deploy JSON prints it as
dashboardUrl).
| page | https://manage.wix.com/dashboard/{siteId}/ + |
|---|---|
| FAQ (categories and questions) | app/14c92d28-031e-7910-c9a8-a670011e062d — the app-id form, which redirects to the FAQ app’s own dashboard page |
Every category and question in the dashboard appears on the site; the API exposes no draft state.
Per seed/SEED.md — plain-data plan.json into seed-faq.mjs from the project root. Seed
questions that exercise the UI (2–4 categories, 2–4 questions each, at least one answer with a list
or several paragraphs). A fresh FAQ install carries Wix’s sample categories and questions (“General”,
“Setting up FAQs”), and the page shows them. Never delete them, or anything else on the site: the
result’s preexisting[] names what is on the page, and the closing message says so with the
dashboard link so the owner removes them there — never release a site that shows “Setting up FAQs”
without telling the owner.
The FAQ API’s reference exists (https://dev.wix.com/docs/api-reference/business-management/faq-app/…) and the
shipped calls follow it: POST /faq/v2/categories/query, POST /faq/v2/question-entries/query, the
RICH_CONTENT content format and the SHARE_LINKS field set. Not confirmed on a live site, and
guarded in the code:
fetchFaq answers 403 for a visitor (the SSR fetch is guarded — the page then
renders the empty state), the read belongs behind a narrow server endpoint per
templates/shared/CUSTOM_OPERATIONS.md; on managed Astro the SSR call already runs server-side.plainText even when
RICH_CONTENT is requested; the core renders richContent, then plainText, then a minimal
Draft.js read (paragraphs, lists, headings — no inline styles), so every format shows something.fetchQuestions({ categoryId }) sends the cursor
alone (the cursor carries the filter, as Blog’s does); fetchFaq pages the unfiltered set, so the
page never depends on it.status the entity does not expose): the seed reads before writing and reports what it
found; the read layer shows what the API returns.