Content — pages + blogs
Shopify Storefront Page / Article shapes verbatim so storefront code
ports 1:1. PUBLISHED only — drafts and deleted rows 404 (lib status
filter AND the anon RLS policy). Stored HTML is server-sanitized on write —
safe to render raw (dangerouslySetInnerHTML).
Path convention (src/lib/content/paths.ts, shared with menus + redirects):
/pages/<handle>, /blogs/<handle>, /blogs/<blog>/<post>. Handle renames
create URL redirects automatically — wire redirects.md into
your 404 handler and old content URLs keep working.
Policy pages every store carries (privacy-policy, terms-of-service,
refund-policy, shipping-policy) are seeded as drafts — they 404 here
until the merchant publishes them.
Content is admin-authored; the shared dev tenant seeds no published pages/blogs, so the executable blocks prove the 404 contract and the happy-path shapes are pinned by
tests/store/content-store.test.tsandtests/contract/sdk-content.contract.test.ts(which create fixtures with admin auth).
GET /api/store/pages/:handle — one page
- Purpose: render a static page (
/pages/<handle>). - Auth: anon (
x-client-id). - Request: path handle only.
- Response:
{
"page": {
"id": "cpage_<hex>",
"handle": "about-us",
"title": "About us",
"body": "<h2>…</h2>", // sanitized HTML — render raw
"bodySummary": "…", // stripped, ~160-char word-boundary cut
"seo": {
"title": "About us", // fallback: title
"description": "…" // fallback: bodySummary
},
"publishedAt": "ISO-8601",
"createdAt": "ISO-8601",
"updatedAt": "ISO-8601"
}
}- Errors:
404 not_found— unknown handle, draft, or deleted. - SDK:
content.getPage(client, handle) - Components: static page template; footer policy links.
- Settings: page status (draft/published) in admin → Content → Pages.
# Unknown handle → the clean 404 contract (drafts behave identically).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
"$BASE/api/store/pages/doc-no-such-page-$RUN" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
curl -s "$BASE/api/store/pages/doc-no-such-page-$RUN" -H "x-client-id: $CLIENT_ID" \
| grep -q '"code":"not_found"'GET /api/store/blogs/:handle/posts — blog listing
- Purpose: the blog index (
/blogs/<handle>), paginated. - Auth: anon (
x-client-id). - Request: query
{limit? (≤100, default 20), offset?}. Published only,published_atdesc. - Response:
{
"blog": { "handle": "news", "title": "News" },
"articles": [ /* Article[] — the exact shape below */ ],
"count": 12,
"offset": 0,
"limit": 20
}- Errors:
404 not_found(unknown blog handle) ·400 validation_failed(limit/offset out of range). - SDK:
content.listBlogPosts(client, blogHandle, query?) - Components: blog index template.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
"$BASE/api/store/blogs/doc-no-such-blog-$RUN/posts" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404GET /api/store/blogs/:handle/posts/:postHandle — one article
- Purpose: render one post (
/blogs/<blog>/<post>). - Auth: anon (
x-client-id). - Response — Shopify
Articleverbatim:
{
"article": {
"id": "bpost_<hex>",
"handle": "hello-world",
"title": "Hello world",
"contentHtml": "<p>…</p>", // sanitized HTML — render raw
"content": "…", // plain-text version
"excerpt": "…", // author-provided; falls back to truncated content
"image": { "url": "https://…", "altText": null }, // null when no hero image
"tags": ["news"],
"author": { "name": "Maria" }, // null when unset
"publishedAt": "ISO-8601",
"seo": { "title": "…", "description": "…" }, // fallbacks: title / excerpt
"blog": { "handle": "news", "title": "News" }
}
}- Errors:
404 not_found— unknown blog OR unknown/draft post. - SDK:
content.getBlogPost(client, blogHandle, postHandle) - Components: article template; article cards reuse the listing shape.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
"$BASE/api/store/blogs/doc-no-such-blog-$RUN/posts/doc-no-post-$RUN" \
-H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404