Skill 16 · Wix Headless Templates
Subchapter 16.54
blog/INSTRUCTIONS.mdMarkdown30 KBView on GitHub
The blog machinery ships as files — post reads (feed paging with pinned-first order, slug lookup with the body/SEO fieldsets, related posts, counters), author bylines, category/tag taxonomy, likes, comments, the feed/post/comments state machines, and the Ricos body renderer, typed end-to-end. The presentation doesn’t ship — you build it on the shipped hooks/DTOs: the post card, the grid, the blog index surface, the category and tag surfaces, the post page surface, the home page, and the brand. You never write blog data logic; you never skip designing. Authoring stays in the dashboard; visitors read, like, and comment.
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
components don’t deploy at all, and each wiring section below opens with the files to read before
writing their equivalents. Files you edit: SiteLayout.astro, styles/global.css, and the shipped
pages’ templates. Files you create: your feed island (skeleton below), your post surface, 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 | imgAttrs(url, sizes, ratio) — every <img> attribute for a DTO image (src, srcSet, sizes, lazy): <img {...imgAttrs(post.coverUrl, "(min-width: 1024px) 33vw, 100vw", 9 / 16)} alt={post.coverAlt} />; imgSrc() / imgSrcSet() underneath |
wix/blog/types.ts | the DTOs (PostSummary, PostDetail, BlogAuthor, PostMetrics, BlogCategory, BlogTag, PostPage, BlogComment, CommentPage) — contracts inlined below |
wix/blog/posts.ts | fetchPosts (cursor-paged, pinned first, category/tag filtered on Wix, authors joined), fetchPostBySlug (full body + SEO + comments fields), fetchRelatedPosts, fetchPostsByIds, fetchPostMetrics; the helpers postMetaLine, relativeDateLabel, formatCount, postPath, categoryPath, tagPath — the transport; the rules and DTO mappers are in posts-core.ts beside it (shared with the REST layer) |
wix/blog/taxonomy.ts | fetchBlogCategories({ withPosts? }), fetchBlogTags, fetchTagsByIds, fetchCategoryBySlug, fetchTagBySlug, inIdOrder — the transport; mappers in taxonomy-core.ts |
wix/blog/authors.ts | fetchAuthorsByIds (one batched Members read per page), fetchCurrentMemberId — over the SDK’s authenticated fetch; mappers in authors-core.ts |
wix/blog/post-likes.ts | likePost, unlikePost, fetchLikeState — the viewer’s like, client-only |
wix/blog/comments.ts | fetchComments, fetchReplies, createComment, deleteComment — the post’s thread as the viewer; rules in comments-core.ts |
wix/blog/blog-feed-store.ts · post-store.ts · comments-store.ts | the feed, post, and comments state machines, framework-free (createBlogFeedStore(), createPostStore(), createCommentsStore() — getState/subscribe + actions, one instance per surface); the hooks below bind them to React, every other stack uses them directly |
hooks/blog/useBlogFeed.ts | React binding of blog-feed-store.ts: feed + taxonomy filter + load-more — contract below |
hooks/blog/usePost.ts | React binding of post-store.ts: post by slug + resolved chips + related + like state — contract below |
hooks/blog/useComments.ts | React binding of comments-store.ts: the thread, replies, create/delete — contract below |
components/blog/RichContent.tsx | the post-body (and comment-body) renderer — wire as-is (machinery, not a reference) |
components/blog/BlogFeedView.tsx (+ PostCard, PostByline) · PostView.tsx · PostLikeButton.tsx (+ EngagementRow) · CommentsView.tsx | reference implementations — correct, plain; 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). title/description are optional: an item page whose <SEO.Tags> resolved passes neither (one source of head tags). If another vertical is also deployed, its layout won — add a Blog nav link there |
pages/blog.astro | SSR feed — keep the frontmatter, swap the island import to YOUR component |
pages/blog/[...slug].astro | SSR post page with owner-editable SEO — keep the frontmatter, the [...slug] rest param, and the SEO pieces (wixMetadata, loadSEOTagsServiceConfig, <SEO.Tags>) exactly; restyle the template. Chips in the post’s order, tags read by id, related strip, the like row and the comments as client:load islands, the body island client:only="react" (the ricos viewer breaks under SSR). A slug that resolves to nothing returns a real 404 |
pages/blog/category/[slug].astro | SSR category page (header: cover, label, description; the feed seeded with the category filter) with owner-editable SEO (wixMetadata on categoryPageMetadata, BLOG_CATEGORY) — same rules as the post page. Unknown slug → 404 |
pages/blog/tag/[slug].astro | SSR tag page (header: label; the feed seeded with the tag filter). No SEO item type exists for tags — the layout’s title is the label. Unknown slug → 404 |
| path | page | notes |
|---|---|---|
/blog | the feed | pinned first, newest first; category pills link to the category pages |
/blog/<slug> | a post | postPath(slug) — a slug may contain /; the helper encodes per segment, the [...slug] param re-joins |
/blog/category/<slug> | a category | categoryPath(slug); 404 when the slug matches no category (Wix’s own router answers 200 + empty feed; a headless page that exists for no category is a 404) |
/blog/tag/<slug> | a tag | tagPath(slug); 404 likewise; not in the sitemap (Wix lists no tag pages either) |
Never hand-build these — the three path helpers are the only source; post.url (the legacy blog’s
page) is never used.
postMetaLine(post), the byline and
counters when the DTO carries them) and rhythm, with skeletons while posts === null, an honest
empty state for [] when error is null, and a visibly different error state when it is not.useBlogFeed: category row (only when >1
non-empty category; pills as links to the category pages, or client-side filters), the grid, and a
load-more control gated on hasMore.RichContent, the engagement row (counters, like), the tag chips, the related strip,
and the comments thread — restyle the [...slug].astro template around it.Plus the theme (@theme block, one edit) and the chrome (SiteLayout, one pass).
Style everything with Tailwind utilities on the tokens. Dark theme: the ricos CSS hardcodes
near-black text — add a global override scoping .ricos-content to the foreground token
(in Astro use <style is:global>; React islands don’t inherit scoped Astro styles).
Defaults for a blog whose brief says nothing about them; the prompt wins where it differs. Look at the seeded content before designing (how many posts, categories, covers, body structure) and design for this blog, not for a stereotype of one.
categoryPath(slug), never to an anchor.error is null); load-more, never a raised page size.max-h-[45vh]), the body, the engagement row, the tags, the related strip, the comments; a slug
that resolves to nothing shows only the not-found state.authorName, a counter only when
its number is defined, a comments section only when commentingEnabled && referenceId; no invented
authors, counts, or “trending” labels; no Wix IDs or technical words in visible text. A count that
is unknown renders nothing, never “0”.Tested and working as they are; read the source when something is off or the brief wants more.
// PostSummary (tiles) — display-ready:
// { id, slug, title, excerpt, dateLabel /* "Aug 26, 2026" | "" */, dateISO,
// minutesToRead /* 0 = unknown */, featured, pinned,
// coverUrl /* "" = no cover (or the author hid it); a video cover → its poster, an embed → its thumbnail */,
// coverAlt /* the author's alt text, else the title; "" with no cover */,
// categoryIds, tagIds,
// authorId, authorName /* "" = unresolved → no byline */, authorAvatarUrl /* "" = none */,
// viewCount?, likeCount?, commentCount? /* undefined = unknown → render nothing */ }
// postMetaLine(post) → "Aug 26, 2026 · 4 min read" (either half alone; "" when neither is known) —
// render it inside <time dateTime={post.dateISO}>; don't hand-assemble the separator.
// relativeDateLabel(post.dateISO, now?) → "3 days ago" under a week, "Aug 5" this year, "Aug 5, 2024"
// otherwise — pass the SAME `now` on the server and the client (put it in the island's props).
// formatCount(n) → "1.2K" (formatCount(n, "full") → "1,234"); "" for undefined.
// postPath(slug) · categoryPath(slug) · tagPath(slug) → the only hrefs (slugs may contain "/").
// PostDetail adds: richContent (Ricos JSON | null — render ONLY via RichContent),
// bodyHtml (the same body as sanitized HTML, for a stack without RichContent; "" when richContent is null),
// paragraphs (plain-text fallback body), updatedLabel/updatedISO ("" unless republished),
The Astro pages ship; their frontmatter is machinery (SSR fetch → DTO props → island; the item
pages’ SEO blocks) and stays as shipped. What you create is the feed island blog.astro and the
category/tag pages mount (and, in a SPA, the post surface — PostView.tsx shows its shape). 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/blog/<YourFeed>.tsx — YOU build it; blog.astro and the category/tag pages mount it.
import { useBlogFeed } from "../../hooks/blog/useBlogFeed";
import { categoryPath, formatCount, postMetaLine, postPath } from "../../wix/blog/posts";
import { imgAttrs } from "../../wix/media";
import type { BlogCategory, BlogTag, PostPage } from "../../wix/blog/types";
export default function YourFeed(props: {
initialPage?: PostPage; // SSR props — pass straight to useBlogFeed; omitted in a SPA
initialCategoryId?: string | null; // the filter initialPage was fetched with (category page)
initialTagId?: string | null; // (tag page)
initialCategories?: BlogCategory[];
initialTags?: BlogTag[];
}) {
const feed = useBlogFeed(props);
const { posts, categories, activeCategoryId, hasMore, loadMore, loadingMore, error } = feed;
// …you implement the render:
// • a category row when more than one category has posts (categories.filter(c => c.postCount > 0)):
// "All" (href "/blog") first, then <a href={categoryPath(c.slug)}> per category, the active one
// marked by activeCategoryId (or buttons calling setActiveCategoryId for client-side filtering)
// • error → a short inline message; the empty state ONLY when !error
// • posts === null → skeleton tiles; [] → your honest empty state
// • else YOUR grid of YOUR tiles: cover via
// <img {...imgAttrs(p.coverUrl, "(min-width: 1024px) 33vw, 100vw", 9 / 16)} alt={p.coverAlt} /> — {} when
// there is no cover, render your placeholder then; title WRAPS (no truncate); excerpt clamped;
// <time dateTime={p.dateISO}>{postMetaLine(p)}</time>; byline only when p.authorName; counters only
// when defined (`${formatCount(p.viewCount)} views`); the tile links to postPath(p.slug)
// • hasMore → your "load more" control calling loadMore() (disabled while loadingMore)
}On lib, static, and a port, nothing under components/ or hooks/ arrives. The state
machines behind the hooks do arrive — wix/blog/blog-feed-store.ts, post-store.ts,
comments-store.ts — so you never rewrite them: create a store per surface, subscribe, render
from getState(), call its actions. Their *State interfaces are the render contract; read those.
What you write is the rendering — card, grid, filter row, post header, comment list — and for that
read these first; they are tested code for exactly that behaviour:
components/blog/BlogFeedView.tsx — the filter row (only categories with posts, “All” first,
one active pill, pills as links or buttons), the skeleton grid while posts === null, the empty
state only without an error, load-more disabled while loading; PostCard inside it is the tile:
16:9 cover with the DTO’s alt, wrapping title, clamped excerpt, the meta line in a <time>, the
byline/counters only when present.components/blog/PostView.tsx — the states in order (not found → error → loading → article),
the categories eyebrow by .label in the post’s order, the byline, the engagement row, the tag
chips, the related strip, the comments gate.components/blog/PostLikeButton.tsx — the like control: aria-pressed, disabled while liking,
a skeleton for the count until likeCountLoaded, counters only when defined.components/blog/CommentsView.tsx — the thread: two levels, “replying to”, PENDING badge,
DELETED placeholder, delete on isOwn, “Show N replies” on the seam, the sign-in prompt on
needsSignIn, the form disabled while saving.components/blog/RichContent.tsx — what the body renderer is: the @wix/ricos viewer, a React
component that needs a bundler. On these stacks it doesn’t run; the body renders from
post.bodyHtml (the core’s sanitized HTML, set as the innerHTML of the body’s wrapper;
post.paragraphs when it is “”) and a comment from comment.text.All under templates/blog/app/.
@theme tokens (one edit); brand SiteLayout.astro (one pass — merge into the
other vertical’s layout instead if both are deployed).src/components/blog/ (a new name — don’t overwrite the
references), swap the island import in pages/blog.astro, pages/blog/category/[slug].astro,
and pages/blog/tag/[slug].astro, and restyle the pages/blog/[...slug].astro template (keep its
frontmatter + SEO pieces + the client:only="react" RichContent island; the like row and
comments stay client:load islands — they read as the viewer). 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 blog --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/blog/ —
posts.ts, taxonomy.ts, authors.ts, post-likes.ts, comments.ts, types.ts, the *-core.ts
rules, and the three stores blog-feed-store.ts, post-store.ts, comments-store.ts. None of it is
React. The hooks and components don’t ship on this stack; the stores replace the hooks, 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). createBlogFeedStore(options) per feed (start() when
mounted, stop() when unmounted), createPostStore({ slug, initialPost? }) per post surface,
createCommentsStore({ referenceId }) per thread (only when commentingEnabled && referenceId).
State in, actions out — exactly the hooks’ contracts above;@wix/ricos is a React component — without React it doesn’t render; use
post.bodyHtml as the innerHTML of the body’s wrapper (post.paragraphs when it is “”) and
comment.text.Routes /blog, /blog/:slug (null → your 404 view), /blog/category/:slug, /blog/tag/:slug
(fetchCategoryBySlug/fetchTagBySlug → createBlogFeedStore({ initialCategoryId }) or a plain
setActiveCategoryId); 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. Post-page
tags from post.seoTitle and post.seoDescription.
Read static.md in this folder: it is the whole playbook for this stack, in one file. The pages
import the layer from the site’s js/wix/ folder and write every element on the stores;
static.md shows how to drive each store for the feed, the post page with likes and comments,
with the routes, the rules and the seed.
Read the reference files listed above before writing any surface.
Run deploy.mjs blog --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/posts.ts,
taxonomy.ts, authors.ts and their *-core.ts to your language — the same functions returning
the same DTO shapes as dicts, one anonymous visitor token per process for these public reads (mint
and refresh per client.ts) — and render the feed, the category/tag pages, and the post page in your
templates to the contracts above, so titles and excerpts are in the HTML; post-page tags from
post.seoTitle and post.seoDescription. The body is richContentToHtml (js/wix/rich-content.ts)
ported with the rest, over richContent (the Ricos viewer is React). Likes and comments run on the
visitor’s behalf — they are browser-side only, on
./js/wix/post-store.js and ./js/wix/comments-store.js exactly as the static wiring above (never
through the process-wide token: a like or a comment would be attributed to it). The feed’s category
filter and load-more can run client-side on ./js/wix/blog-feed-store.js the same way, or as plain
server routes (/blog/category/<slug> via fetchCategoryBySlug, paging by cursor). Routes stay
/blog, /blog/<slug>, /blog/category/<slug>, /blog/tag/<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 a page for every
post and every category — walk fetchPosts by nextCursor until it is null, never only the first
page. Run deploy.mjs blog --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 (/, /blog/…): give the templates one base path to js/wix/ (a template
variable, or root-relative /js/wix/…), never a relative ./js/wix/. The frozen grid is the first
paint; the category filter, load-more, likes, and comments still run client-side on the stores
from ./js/wix/. Close with the live URL, the rebuild + release command, and one line for the owner:
posts published 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). Routes: /blog → your feed on useBlogFeed;
/blog/category/:slug and /blog/tag/:slug → the same feed with initialCategoryId/initialTagId
(resolve the slug with fetchCategoryBySlug/fetchTagBySlug); /blog/:slug → your post surface on
usePost({ slug }) (the PostView reference shows the shape; notFound → your 404 view). 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/blog in this template (1.0.645) has no viewPost; the
installed SDK has to expose it before a page can count a view. Views still display from METRICS.referenceId as both contextId and resourceId, with the Blog
app id — the public-SDK shape Wix’s own headless blog uses. Verified live: the REFERENCE_ID
fieldset returns the post without that field, the INTERNAL_ID fieldset carries it, and it equalled
the post id on every post read — so the detail read asks for both and posts-core.ts falls back
to the post id (what Wix’s classic Blog widget addresses the thread by). A post page that shows no
comments section therefore means commentingEnabled is off, not a missing id.needsSignIn. Comments may
come back PENDING when the owner moderates — show “awaiting approval”, never drop them.commentingEnabled even when the owner turned comments off for the whole blog. Say so in the
closing message when you ship comments.wixFetch) with literal REST paths —
the template does not depend on @wix/members or @wix/comments; the REST twins use the same paths.RichContent where it deploys (Astro, React) — a post body
is a Ricos document, not HTML and not text: never set:html/innerHTML it, never stringify its
nodes, never write your own node walker. Where it doesn't deploy, the body is bodyHtml, the
shipped renderer’s sanitized HTML, set as the innerHTML of one wrapper (paragraphs when it is “”).wix/blog/ (API contracts: the wix-docs skill).slug through the shipped path helpers — postPath, categoryPath, tagPath;
never hand-build a post/category/tag URL from ids or from post.url; display taxonomy by .label
(the API has no .name).notFound means not found — render your 404 state; never invent a post. Only published
posts come back, so a “missing” post is usually an unseeded/unpublished one.useBlogFeed‘s filters and fetchPosts run on Wix before
cursor paging; never filter a loaded page client-side, never raise the page limit instead of
paging (hasMore/loadMore).coverUrl and authorAvatarUrl are already
resolved; anything else goes through wix/media.ts.error is null; a failed read shows the
error state.@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.authorName, a
counter needs a defined number, a like control needs the post, a comments section needs
commentingEnabled && referenceId.[...slug] rest param exactly as shipped;
an item page whose <SEO.Tags> resolved passes no title/description to the layout.Hand the owner these links — {siteId} is siteId in wix.config.json (the deploy JSON prints it as
dashboardUrl).
| page | https://manage.wix.com/dashboard/{siteId}/ + |
|---|---|
| Posts (published and drafts) | blog/posts |
| Categories | blog/categories |
| Tags | blog/tags |
| Writers | blog/writers |
| Comments | blog/comments |
| Blog settings | blog/settings |
Only published posts appear on the site. Pinned posts lead the feed; a post’s related posts, cover visibility, alt text, SEO title/description, and per-post comments switch are set on the post.
Per seed/SEED.md — plain-data plan.json into seed-blog.mjs from the project root.
Seed posts that exercise the UI (~3 posts, 2 categories when the brief has sections, varied
content blocks, a cover image per post). Authors resolve to the site owner’s member profile — set a
nickname and photo in the dashboard for a real byline.