Subchapter 9.11
references/inline-recipes/how-to-code-a-blog.mdMarkdown19 KBView on GitHub
RECIPE: How to Code a Wix Blog Frontend (Blog V3 + Ricos rich content)
A concise contract for writing the frontend code of a blog against a Blog V3 site: listing posts, opening a post by slug, rendering its rich content, resolving categories/tags/cover images, and (optionally) reading and submitting comments. This recipe is the how (which modules, which calls, which fields), not the what — which posts to show, how the page looks, and the framework are decided by the request you’re fulfilling.
This recipe is for CODING the blog, not for seeding it. It assumes a Blog V3 site already exists (published posts, optional categories/tags). It says nothing about creating posts — only how to read and render them from frontend code.
⚠️ Reading rule — always append
.md?apiView=SDKto every doc link below. The Wix docs render two views of the same page. The bare / REST view showsid; the — and the SDK is what your frontend calls. Reading the REST view by mistake is the most common source of -id bugs (links to ). Fetch the form directly; if a field name surprises you, you’re probably reading the REST view.
?apiView=SDK view shows _idundefined/blog/undefined.md?apiView=SDKWix Blog app id (a constant Astro needs for blog-post page routing, and comments need as appId):
14bcded7-0066-7c35-14d7-466cb3f09103
⚠️ CRITICAL: blog posts are NOT CMS collections — use @wix/blog, never @wix/data. Querying blog content through @wix/data reads the wrong store and returns nothing. Import only:
| Need | Package | Module |
|---|---|---|
| Posts (list, get, query by slug) | @wix/blog | posts |
Categories (resolve categoryIds) | @wix/blog | categories |
Tags (resolve tagIds) | @wix/blog | tags |
| Render rich content | @wix/ricos | RicosViewer + quickStartViewerPlugins |
Resolve wix:image:// cover URIs | @wix/sdk | media |
| Likes — like/unlike a post (only if the site has members) | @wix/blog | likes |
| Comments — read/submit (only if the blog has comments) | @wix/comments | comments |
| Comment/like author name/photo (only with members) | @wix/members | members |
Discover the exact current call shapes with SearchWixSDKDocumentation (e.g. "blog query posts", "comments queryComments createComment") rather than walking a module menu — the menu pages often surface only dashboard/extension pages, not the runtime query functions. The @wix/ricos viewer API moves between versions — follow the blog doc to the current viewer API; don’t pin a version blind.
Auth / client — framework split:
posts / categories / tags directly from server components and backend routes (src/pages/api/*.ts) — no createClient, no OAuthStrategy, no clientId.import { createClient, OAuthStrategy } from '@wix/sdk';
import { posts, categories, tags } from '@wix/blog';
const client = createClient({
modules: { posts, categories, tags },
auth: OAuthStrategy({ clientId: /* the project's PUBLIC OAuth client id */ }),
});clientId is public, not a secret.Each section below is a self-contained blog feature — implement only the ones the site uses; they don’t have to be built in order. The only ordering is within a feature (e.g. resolve categoryIds after you have the post).
Query posts with posts.queryPosts(...), newest first:
const { items } = await posts
.queryPosts({ fieldsets: ["RICH_CONTENT", "URL"] })
.descending("firstPublishedDate")
.limit(20)
.find();⚠️ CRITICAL: request the RICH_CONTENT fieldset, or post.richContent is undefined. queryPosts omits the body by default; without fieldsets: ["RICH_CONTENT"] the post comes back with no richContent, and the detail page renders blank. (Add "URL" to get post.url/slug for links.)
⚠️ CRITICAL: the entity id is _id, NOT id. The SDK normalizes every entity’s id to _id; post.id is undefined in SDK code. Use post._id for keys and lookups, and post.slug for links (/blog/${post.slug}). (A surprising field name means you’re reading the REST doc view — re-open it with ?apiView=SDK.)
Visibility: only published posts are returned to a visitor token, so a missing post usually means it wasn’t seeded publish: true — not a query bug (ties back to the seed recipe).
Filter by slug and take the first item:
const { items } = await posts
.queryPosts({ fieldsets: ["RICH_CONTENT", "URL"] })
.eq("slug", slug)
.find();
const post = items[0];
if (!post) { /* 404 → redirect to /blog */ }Astro page routing + SEO (Wix-managed) — wixMetadata is required. A blog-post detail page (src/pages/blog/[...slug].astro) is a Wix item page: its <title>/description/OG/canonical come from what the owner sets in the dashboard. Wire it per the canonical guide — Add SEO Support to Item Pages (opens in a new tab) — which covers all three steps: export wixMetadata (registers the route → sitemap + dashboard SEO editor), call loadSEOTagsServiceConfig(...), and render <SEO.Tags> (from @wix/seo; deps + @wix/essentials ≥ 1.0.10 are in the guide’s “Before you begin”). Source wixMetadata from WIX_APPS, referenced directly inside the export (it’s evaluated in module scope):
import { WIX_APPS } from "@wix/essentials";
import { seoTags } from "@wix/seo"; // → itemType: seoTags.ItemType.BLOG_POST
export const wixMetadata = {
appDefId: WIX_APPS.blogs.id,
pageIdentifier: WIX_APPS.blogs.postPageMetadata.pageIdentifier,
identifiers: { slug: WIX_APPS.blogs.postPageMetadata.identifiers.slug },
};Use the [...slug] rest param (not [slug]), and Astro.params directly — Wix headless projects run output: "server" (SSR), so there’s no getStaticPaths(). If you add a blog category route (e.g. /category/[slug]), wire it the same way with WIX_APPS.blogs.categoryPageMetadata + seoTags.ItemType.BLOG_CATEGORY. ⚠️ Dashboard SEO overrides for blog categories may not be honored by the resolver (it can return label-derived defaults) — the route still registers and gets valid default tags, so this is a Wix-side gap, not a wiring bug.
The body is Ricos (post.richContent), not HTML — never set:html={post.content} (there is no .content), and never String(node). Render it with @wix/ricos:
// src/components/RicosViewer.tsx
import { quickStartViewerPlugins, RicosViewer } from '@wix/ricos';
import '@wix/ricos/css/all-plugins-viewer.css';
const plugins = quickStartViewerPlugins(); // module-level, once — not per render
export default function RicosContentViewer({ content }) {
if (!content) return null;
return <div className="ricos-content"><RicosViewer content={content} plugins={plugins} /></div>;
}Doc: https://dev.wix.com/docs/api-reference/business-solutions/blog/introduction.md?apiView=SDK (opens in a new tab) (follow it to the current Ricos viewer API; verify with SearchWixSDKDocumentation "ricos viewer").
⚠️ CRITICAL (Astro): render the viewer with client:only="react". @wix/ricos is a React component that breaks under SSR. <RicosViewer client:only="react" content={post.richContent} /> makes it render client-side only. @wix/ricos accepts the camelCase richContent from the @wix/blog SDK directly — no key renaming.
⚠️ Ricos text invisible on dark themes. The Ricos library CSS hardcodes near-black text color on paragraphs/lists. If the site theme is dark, scope an override on the .ricos-content wrapper forcing var(--color-text) — and in Astro use <style is:global>, because React islands don’t inherit scoped Astro styles.
For a short excerpt/card summary use post.excerpt (a plain string) — not the raw richContent nodes.
post.categoryIds / post.tagIds are id arrays — resolve each to a label:
const { category } = await categories.getCategory(id); // ENVELOPE — destructure { category }
const tag = await tags.getTag(id); // returns the BlogTag DIRECTLY — do NOT destructureDocs: https://dev.wix.com/docs/api-reference/business-solutions/blog/category/get-category.md?apiView=SDK (opens in a new tab) · https://dev.wix.com/docs/api-reference/business-solutions/blog/tags/get-tag.md?apiView=SDK (opens in a new tab)
⚠️ CRITICAL: the two return shapes differ. categories.getCategory(id) returns an envelope { category } — destructure it. tags.getTag(id) returns the tag object directly — destructuring it as { tag } yields undefined. Getting this backwards is a silent undefined.label.
A post’s cover lives at post.media?.wixMedia?.image and may be a wix:image:// identifier, not a ready URL. Resolve it with the SDK media module:
import { media } from '@wix/sdk';
const coverUrl = post.media?.wixMedia?.image
? media.getImageUrl(post.media.wixMedia.image).url // or media.getScaledToFillImageUrl(ref, w, h)
: undefined;Never hand-build a static.wixstatic.com/.../v1/fit/... URL — the format is easy to get wrong and the image then 403s. Only wix:image:// values need resolving; an already-absolute https:// URL (e.g. an Unsplash placeholder seeded when imagery is off) goes straight into <img src>. Constrain Wix image URLs with aspect-ratio + object-fit: cover so they don’t overflow at intrinsic size. Doc: https://dev.wix.com/docs/sdk/core-modules/sdk/media (opens in a new tab)
When the site has members (login — see how-to-code-members-astro.md / -non-astro.md), two blog features become member-native. Both follow the same read-public / write-as-the-logged-in-member shape as everywhere else — resolve identity at the action, never gate the whole page, and never auth.elevate() for a member acting on their own behalf.
⚠️ Members-only (gated) content — gate on a LIVE, queryable signal, never a hardcoded id/slug list. If the brief gates full articles to paying members, decide “is this post gated?” from a signal carried on the post itself and read at request time — a
members-onlyblog category or tag, or a boolean field on a companion CMS record — so a gated post the owner publishes later self-classifies with no code change. Do NOT gate by a hardcoded list of “premium” post slugs/ids frozen at build time: a members-only post added afterward would read as public (an owner edit silently lost). Combine the gate with the member check — resolve the visitor’s plan/login at the action, and reveal the full body only when both the post is flagged gated and the member is eligible (plan eligibility is itself a live read — seehow-to-code-pricing-plans.md).
⚠️ Members can LIKE and COMMENT — they CANNOT author or manage posts. Every draft-post write/query method (
createDraftPost,updateDraftPost,publishDraftPost,deleteDraftPost,queryDraftPosts) is admin-only (applicableIdentities: [APP], Manage-Blog scope) — a member token is rejected. Blog authoring is a back-office capability; the seed recipe’smemberIdonly attributes a post to a member, it doesn’t grant members authoring rights (setup-blog.mdSTEP 1). Do not build a “write a post” / “my drafts” member surface expecting the member’s own session to work. (It’s only achievable by an app-mediated backend thatauth.elevate()s to APP, creates on the member’s behalf, and enforces per-member ownership itself — an authoring workflow with moderation implications, not a drop-in feature. Don’t build it unless the brief explicitly asks.)
Liking is a first-class member action — likes.createLike / deleteLike / queryLikes accept a MEMBER identity (not admin), so a logged-in member likes/unlikes a post directly.
likes.queryLikes (public read) for the per-post total; the current member’s like state comes from their own likes. Post-level engagement counts are also on the post via the metrics fieldset.src/pages/api/*.ts) that calls likes.createLike / deleteLike with the request session; if the caller isn’t a member, redirect to /api/auth/login?returnUrl=… (same gate-on-action shape as comments). Keys off the post id (post._id).SearchWixSDKDocumentation "blog likes createLike queryLikes".⚠️ Intent-gate this feature. Comments hard-depend on members/login, so build them only when the brief explicitly asks for reader comments or discussion. An unrequested comments feature drags in an unrequested login gate — consistent with the
CAPABILITIES.mdblog entry (comments are optional / intent-gated, not part of the baseline blog surface). Reading comments is public; submitting / editing / deleting needs a logged-in member — render the thread always, and resolve identity at the action (show a “log in to comment” prompt to anonymous visitors, not a form that bounces).
Package: @wix/comments (comments). The API keys off the post’s referenceId — request the REFERENCE_ID fieldset when fetching the post, then contextId = resourceId = post.referenceId, and appId = "14bcded7-0066-7c35-14d7-466cb3f09103" (the Blog app id).
listCommentsByResource, NOT queryComments. comments.listCommentsByResource(APP_ID, { contextId, resourceId, commentSort: { order: "OLDEST_FIRST" }, cursorPaging: { limit } }) → returns { comments }. ⚠️ The docs’ queryComments example ships wrapped in auth.elevate(comments.queryComments) — an admin path that must NOT be used for the public/anonymous SSR read. Reaching for queryComments steers you straight into that elevated example; use listCommentsByResource for the visitor read.createComment / updateComment take content: { richContent: <ricos-doc> } — the same { nodes: [{ type:"PARAGRAPH", nodes:[{ type:"TEXT", textData:{ text, decorations:[] }}]}]} shape as post bodies. Convert the textarea string ↔ Ricos yourself (a small plainTextToRicos / ricosToPlainText helper).src/pages/api/*.ts) that resolves the session and calls comments.createComment(...); if the caller isn’t a member, redirect to /api/auth/login?returnUrl=… (framework-provided on managed Astro — no src/pages/api/auth file needed, @wix/astro emits it at build). Not a client island.comments.getComment(id)), check existing.author?.memberId === member._id, then comments.updateComment(id, { revision: existing.revision, content }) (⚠️ update requires the fetched revision) or comments.deleteComment(id).METRICS fieldset on the post fetch and read post.metrics.comments. Do not call comments.countComments — it needs elevation.comment.author?.memberId (and post.memberId) via @wix/members. Comment fields normalize to _id / _createdDate (same _id rule as posts).listCommentsByResource is cursor-paged — pass cursorPaging: { limit } and follow the returned paging cursor for the next page; don’t offset-page.comment.contentEdited === true — render an “(edited)” marker.comment.parentComment (present only on replies); comment.replyCount is the number of replies; fetch one comment’s thread with comments.getCommentThread(commentId, …). For a flat thread (the common case), ignore parentComment and just render the listCommentsByResource results in OLDEST_FIRST order.comment.voteSummary.upvoteCount / downvoteCount; and marking/pinning — comment.marked.⚠️ REST-vs-SDK permission trap. The raw REST comments methods all report
applicableIdentities: [APP](admin) — but the@wix/commentsSDK path resolves the read as public and the write as the logged-in member. Don’t let the REST spec push you intoauth.elevate()-ing the read or the member write — that’s wrong for the visitor thread. Only the genuine moderation calls (hideComment,moderateDraftContent,countComments,markComment, publish/bulk-*) need elevation, and those are back-office, not part of the visitor-facing feature.
A correct Blog V3 frontend:
posts / categories / tags from @wix/blog (+ @wix/ricos, media) — never @wix/data for blog content;RICH_CONTENT fieldset (else richContent is undefined) and uses post._id (never post.id) and post.slug for links;@wix/ricos RicosViewer (client:only="react" in Astro, .ricos-content color override for dark themes) — never set:html, never raw nodes;categories.getCategory as { category } but takes tags.getTag directly (not { tag });wix:image:// covers via the SDK media module — never a hand-built CDN URL;wixMetadata on the Astro [...slug] detail page;post.referenceId + the Blog appId;@wix/blog likes, MEMBER-scoped) and comments as member-native, gate-on-action features — but never a member post-authoring surface (all draft-post methods are APP-only; a member token is rejected).