Skill 16 · Wix Headless Templates
Subchapter 16.173
cms/INSTRUCTIONS.mdMarkdown26 KBView on GitHub
The data machinery ships as files — typed reads/writes over ANY collection, the list and item
state machines, their React bindings, and the normalization layer (dates → ISO strings,
wix:image:// → https URLs), correct end-to-end. CMS is schema-generic, so unlike other
verticals there are no fixed pages: the seed plan (plan.json) is the site’s content model,
and — a listing surface and an item page per content collection,
plus the home page and the brand. You never write data-access logic; you never skip designing.
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
don’t deploy at all; the stores behind them do, and each wiring section below says how to bind
them. Files you edit: SiteLayout.astro and styles/global.css. Files you create (skeletons
below): the listing and item pages with their islands, plus 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 | imgAttrs(url, sizes, ratio) — every <img> attribute for a DTO image (src, srcSet, sizes, lazy). The DTO URL carries the image’s natural aspect and imgRatio(url) reads it back: <img {...imgAttrs(item.photo, "33vw", imgRatio(item.photo) ?? 1)} alt={item.title} /> keeps the srcset at that aspect; pass a number instead (0.75 for 4:3) to crop every card alike. {} for an absent image — render your placeholder then |
wix/cms/types.ts | the DTOs (CmsItem, CmsFilter, CmsSort, CmsQuery, CmsPage, CmsCollectionSchema) — contracts below |
wix/cms/items.ts | queryItems, getItemById, getItemBy, countItems, distinctValues, insertItem, updateItem, patchItemFields, removeItem, linkItems, unlinkItems — the transport; the rules and DTO mappers are in items-core.ts beside it (shared with the REST layer) |
wix/cms/items-core.ts | the normalization rules, the filter guard, the wire spelling of a query, and formatDate(iso) — a DATE field as copy (“” for absent/invalid, never “Invalid Date”) |
wix/cms/collections.ts | getCollectionSchema(collectionId) — field types, reference targets, permissions at runtime; collections-core.ts beside it holds the mapper and fieldsOfType(schema, type) |
wix/cms/collection-store.ts · item-store.ts | the list and item state machines, framework-free (createCollectionStore(), createItemStore() — getState/subscribe + actions, one instance per surface); the hooks below bind them to React, every other stack uses them directly |
hooks/cms/useCollection.ts | React binding of collection-store.ts: list state, append or page-N paging, refetch behind the items on screen — contract below |
hooks/cms/useItem.ts | React binding of item-store.ts: one item by _id or slug field — contract below |
styles/global.css | the design system: Tailwind v4 + the @theme token block (shared across verticals) |
There are no shipped components — the data layer, stores, and hooks are the machinery; every component is yours.
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 your content nav links there |
There are no shipped pages — you author them (below).
Read the seed plan first: its collections, field keys, and permissions are the contract your pages bind to (collection ids and field keys verbatim).
useCollection
(or SSR via queryItems alone when the page needs no interactivity).slug field — your detail layout on the DTO
(SSR fetch via getItemBy), with a real 404 on miss.Plus the theme (@theme block, one edit) and the chrome (SiteLayout, one pass).
Style everything with Tailwind utilities on the tokens.
Defaults for a brief that says nothing about them; the prompt wins where it differs. Look at the seeded content before designing (how many collections, which fields, whether items carry images and dates) and design for this content, not for a stereotype of the business.
/<collection>/<slug>); loading, empty, and error states that look
different; “load more” when hasNext (or a numbered pager when the brief asks for pages). A
filter or sort change keeps the cards on screen and dims them (fetching) — skeletons only
before the first answer.Everything you need — tested and working as they are; read a file when something is off or the brief wants more.
// CmsItem — display-ready, fields FLAT on the item (never item.data.*):
// { _id, _createdDate?, _updatedDate?, _owner?, ...fields }
// TEXT/URL/EMAIL → string · NUMBER → number · BOOLEAN → boolean · ARRAY_STRING (tags) → string[]
// DATE/DATETIME → ISO string (render: formatDate(item.publishDate) from wix/cms/items-core)
// TIME → "hh:mm:ss.SSS" string (text on the wire — write it as a string too)
// IMAGE → resolved https URL at the image's own aspect
// (<img {...imgAttrs(item.photo, "33vw", imgRatio(item.photo) ?? 1)} />; {} when absent → your placeholder)
// RICH_TEXT → the stored HTML string (render via set:html on a wrapper you control)
// REFERENCE/MULTI_REFERENCE → id(s); full CmsItem(s) when queried with include
// (RICH_CONTENT, VIDEO, DOCUMENT pass through unresolved — seed RICH_TEXT and IMAGE instead)
// queryItems(collectionId, { filters?, sort?, limit?, skip?, include?, withTotal? })
// → { items: CmsItem[], hasNext, total } // filters: [{ field, op, value }]
// ops: eq ne gt ge lt le contains startsWith endsWith in hasSome hasAll between isEmpty isNotEmpty
// in: value is an array (equal to any) · between: value is [start, end], end EXCLUSIVE
// DATE comparands must be Date objects (an ISO string matches nothing)
// getItemBy(collectionId, field, value, { include? }) → CmsItem | null // slug routing
// getItemById(collectionId, id, { include? }) → CmsItem | null
The class names in these skeletons are the Astro/React spelling of layout rules that hold on
every stack, and each rule is also named in words beside its classes. On a stack where the hooks
don’t deploy (lib, static, a port), keep the rule and write it in your own CSS.
Each page is a thin SSR shell (fetch → DTO props → island, or no island when the page has no
interactivity); each island is a thin view over a hook. Hooks first, branches after. The islands
render on the server too (client:load SSRs), and by then the 200 and headers are already sent — a
render throw truncates the body mid-stream. Render every state totally; nothing in a render path may
throw.
---
// src/pages/recipes.astro — YOU create it (one per content collection). Items are fetched
// SERVER-SIDE (view-source shows titles) and handed to the island as serialized DTO props.
import SiteLayout from "../layouts/SiteLayout.astro";
import CollectionView from "../components/cms/CollectionView";
import { queryItems } from "../wix/cms/items";
import type { CmsPage } from "../wix/cms/types";
const SORT = [{ field: "publishDate", direction: "desc" as const }];
let page: CmsPage = { items: [], hasNext: false, total: null };
try {
page = await queryItems("recipes", { sort: SORT, limit: 12 });
} catch {
// Guarded: an unguarded SSR throw truncates the response mid-stream; the island renders your empty state.
}
---
<SiteLayout title="Recipes">
<!-- your page heading / intro, then: -->
<CollectionView client:load collectionId="recipes" sort={SORT} limit={12} initialItems={page.items} initialHasNext={page.hasNext} />
</SiteLayout>---
// src/pages/recipes/[slug].astro — YOU create it. SEO is plain <title>/<meta name="description">
// from the DTO via the layout props — CMS collections have NO owner-editable SEO item type, so
// don't copy another vertical's wixMetadata/<SEO.Tags> wiring.
import SiteLayout from "../../layouts/SiteLayout.astro";
import { getItemBy } from "../../wix/cms/items";
import { formatDate } from "../../wix/cms/items-core";
import { imgAttrs, imgRatio } from "../../wix/media";
let item = null;
try {
item = await getItemBy("recipes", "slug", Astro.params.slug!, { include: ["categories"] });
} catch {
// Guarded: an unhandled SSR throw truncates the response mid-stream.
}
// A missing slug is a real 404 — never a fallback to another item.
if (!item) return new Response(null, { status: 404 });
---
<SiteLayout title={String(item.title)} description={String(item.summary ?? "")}>
<!-- your detail layout: title; the image at its own aspect —
<img {...imgAttrs(item.photo, "(min-width: 1024px) 60vw, 100vw", imgRatio(item.photo) ?? 1)} alt={String(item.title)} />
(a placeholder when imgAttrs returns {}); formatDate(item.publishDate); the RICH_TEXT body
with set:html on a wrapper you control; included reference items by name — never an id as content -->
</SiteLayout>// src/components/cms/CollectionView.tsx — YOU build it; a listing page mounts it.
import { useCollection } from "../../hooks/cms/useCollection";
import { formatDate } from "../../wix/cms/items-core";
import { imgAttrs } from "../../wix/media";
import type { CmsFilter, CmsItem, CmsSort } from "../../wix/cms/types";
export default function CollectionView(props: {
collectionId: string;
filters?: CmsFilter[];
sort?: CmsSort[];
limit?: number;
initialItems?: CmsItem[]; // SSR props from your page — pass straight to useCollection;
initialHasNext?: boolean; // omitted in a SPA (client fetch)
}) {
const { collectionId, ...options } = props;
const list = useCollection(collectionId, options);
// …you implement the render:
// • error → a short inline message
// • items === null → skeleton cards; [] → your honest empty state (no mock items)
// • fetching → the SAME cards, dimmed (`opacity-60`, `aria-busy`) — never skeletons again
// • else YOUR grid of YOUR cards: image via <img {...imgAttrs(item.photo, "(min-width: 768px) 33vw, 100vw", 0.75)} alt={String(item.title)} />
// — src, srcSet, sizes and lazy loading in one spread; a fixed ratio (4:3 here) crops every
// card alike, `imgRatio(item.photo) ?? 1` keeps each photo's own aspect; {} when the field is
// absent → your placeholder; title WRAPS (`min-w-0`, `break-words`), no `truncate`; the date
// as formatDate(item.publishDate); the card links to `/${collectionId}/${item.slug}`
// • hasNext → your "load more" control calling loadMore() (disabled while loadingMore) — or a
// numbered pager: hasPrev/hasNext → goToPage(page - 1) / goToPage(page + 1), "page + 1 of
// pageCount" when the hook was given withTotal (a filter change goes back to page 0)
}On lib, static, and a port, nothing under hooks/ arrives, and CMS ships no components. The
state machines behind the hooks do arrive — wix/cms/collection-store.ts, item-store.ts — so you
never rewrite them: create a store per surface, subscribe, render from getState(), call its
actions. Their CollectionState / ItemState interfaces are the render contract; read those. What
you write is the rendering — cards, the item view, the states — to the skeletons above. The two
hooks under templates/cms/app/hooks/cms/ are the binding pattern, ten lines each, if your
framework’s external-store primitive is unfamiliar.
@theme tokens (one edit); brand SiteLayout.astro (one pass) and add one nav link
per content surface.src/pages/ per the skeletons above — SSR fetch in the frontmatter,
DTO props to your islands (client:load; a page with no interactivity needs no island at all).pages/index.astro (home) on SiteLayout.deploy.mjs cms --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/cms/ —
items.ts, types.ts, items-core.ts, and the two stores collection-store.ts, item-store.ts.
None of it is React. The hooks don’t ship on this stack; the stores replace them, and you write the
components in your framework to the contracts on this page:
shallowRef updated in
subscribe; Svelte: readable(store.getState(), (set) => store.subscribe(() => set(store.getState())));
Solid: a signal set in subscribe). createCollectionStore({ collectionId, filters?, sort?, limit?, include?, withTotal?, initialItems? })
per listing (start() when mounted, stop() when unmounted, setQuery() when filters change,
loadMore() or goToPage(n), refresh() after a write), createItemStore({ collectionId, ref: { id } | { by }, initialItem? })
per item surface. State in, actions out — exactly the hooks’ contracts above.Routes /<collection>, /<collection>/:slug (via getItemBy, null → your 404); 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 item’s own fields.
Read static.md in this folder: it is the whole playbook for this stack, in one file. The pages
import the modules from the site’s js/wix/ folder and write the rendering on the stores;
static.md shows how to drive each store, with the listing, the item page, the routes, the rules and
the seed.
Run deploy.mjs cms --stack static in the project folder anyway: js/wix/ is both the
browser-side code and the readable spec. Then split by where the call runs. Reads on the
server: port js/wix/items.ts and items-core.ts to your language — queryItems, getItemBy,
getItemById, countItems returning the same DTO shapes as dicts (the core’s toValue rules:
{ "$date": iso } → ISO string, wix:image:// → the https URL form in media.ts, 1200 wide at
the aspect the id’s #originWidth/#originHeight fragment gives), one anonymous
visitor token per process for these public reads (mint and refresh per client.ts) — and render
the listing and item pages in your templates to the contracts above, so titles are in the HTML;
page tags from the item’s fields. Visitor writes in the browser (a collection the seed opened
to visitors or members): insertItem from ./js/wix/items.js on the page, exactly as the static
wiring above — the browser owns the visitor token, so the server handles no per-visitor tokens.
Routes stay /<collection>, /<collection>/<slug>.
Pre-rendered (Frozen-Flask, Pelican, any static-site generator) → Wix-hosted. Same port for the
reads, run at build time with one anonymous token; the generator must emit every item page (walk
queryItems by skip until hasNext is false — never only the first page). Run
deploy.mjs cms --stack static --out <build dir> so js/wix/ is inside the output the pages
import from, point site.outputDirectory at that folder, wix release. Pages sit at different
depths (/, /<collection>/…): give the templates one base path to js/wix/ (a template
variable, or root-relative /js/wix/…), never a relative ./js/wix/ — it breaks one level down.
The frozen listing is the first paint; a filter or “load more” still runs client-side on it
through createCollectionStore() from ./js/wix/collection-store.js, exactly as on a static
site. Close with the live URL, the rebuild + release command, and one line for the owner:
dashboard edits to the content 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). Routes are yours: a list route per collection on
useCollection, a detail route on useItem with by: { field: "slug", value } (components
fetch client-side when no initialItems / initialItem is passed). 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/data directly in your components, never hand-build a static.wixstatic.com
URL._id, fields are flat — item.id is undefined; item.data.title is the
REST shape and reads undefined.read must be
ANYONE) — fix the seed, never reach for auth.elevate (it doesn’t exist on this path).
On a member-scoped collection an empty anonymous read is the gate working, not a bug.updateItem REPLACES the whole item — omitted fields are wiped. Spread the full item,
or use patchItemFields. Date fields on any write are Date objects — a round-tripped ISO
string is silently stored as text and breaks date queries.templates/shared/CUSTOM_OPERATIONS.md.templates/shared/CUSTOM_OPERATIONS.md: validate the caller and
input in a narrow server endpoint before elevating one documented operation. Never call a
privileged Media API directly from a client island.plan.capabilities.mediaUpload; Fast deploys the endpoint, client helper, dependencies, and
generated policy module. Your CMS surface calls uploadMedia(policyId, file) and stores the
returned Wix Media reference. Do not author another upload endpoint for that flow.set:html (Astro) /
dangerouslySetInnerHTML (React) on a wrapper; never interpolate it as text.formatDate, images through imgAttrs — the DTO’s ISO string and https URL
are data, not copy. The URL is at the photo’s own aspect; imgAttrs defaults to a square
srcset, so pass imgRatio(url) ?? 1 (natural) or a fixed ratio (uniform cards) — never leave a
4:3 photo to be cropped square by default.include — don’t render an id as
content. getItemById‘s include rides the get request’s includeReferences key (the
installed typings’ spelling); confirm once on a live site that it comes back with full items —
if it returns ids, use getItemBy(collectionId, "_id", id, { include }), the query path Wix’s
own components use for that reason.data — insert and update drop the value
silently (200, no error). Create the item, then linkItems (or insertItem(…, { link }));
unlinkItems removes. A single REFERENCE is set at insert as the target item’s _id.refresh() the listing — the store can’t know, and Wix Data is eventually
consistent (a read right after a write may not show it yet); refresh() re-reads what is on
screen without skeletons. Never splice the written item into the list by hand.imgAttrs returns {}; render a fallback, never an empty/broken <img>.hasNext/loadMore() append the next page from
Wix, goToPage(n) replaces it; never raise the page limit instead of paging, never filter or
sort client-side a page you already hold — pass filters/sort and let Wix apply them across
the collection; filter chips come from distinctValues, not from the page in hand. between is
[start, end) — a date range’s end is the first excluded day.notFound/loading above its hooks changes hook order between renders and React throws.@theme tokens, your
markup on the same tokens; no parallel theme files, no hardcoded palettes. Where it doesn’t
(lib, static, a port): style with whatever your stack does well, on one token set of your
own; the rule that survives is the token set, not Tailwind.Hand the owner these links — {siteId} is siteId in wix.config.json (the deploy JSON prints it as
dashboardUrl); a collection id is its _id (the seed result lists them).
| page | https://manage.wix.com/dashboard/{siteId}/ + |
|---|---|
| Collections | wix-cms |
| A collection’s items (fields, and More Actions → Permissions & Privacy) | wix-cms/data/{collectionId} |
Per seed/SEED.md — plain-data plan.json into seed-cms.mjs from the project root. The
plan is the content model your pages bind to: design collections that exercise the UI
(a slug field per detail page, an IMAGE field with a verified imageUrl per item, a DATE
field when the content is chronological).