Blog (CMS + Redesign)
Live on dev only. Prod has no blog tables yet — the pending prod release must apply all
three migrations below via the Supabase Management API before /blog works on prod.
DB-backed multilingual blog CMS — English plus Bulgarian, Romanian, German, French and Spanish
since #548 (see Languages). Posts publish instantly (no deploy) since the public
pages are force-dynamic and read straight from Postgres — or at a scheduled future minute
(see Scheduled posting).
Data model
Table blog_posts (spec: 05_tasks/specs/blog-v2.md; migrations in
02_app/apps/web/supabase/migrations/):
20260722_blog_posts.sql— the table.slugis the URL identity (unique, lowercase-kebab check). EN fields (title_en,description_en,body_en) are required; BG fields (title_bg,description_bg,body_bg) are optional — a post is bilingual only whenbody_bgis set. Also:category,tags text[],cover_image_url+cover_image_alt, author fields (author_name,author_role,author_avatar_url), SEO overrides (seo_title_en/bg,seo_description_en/bg, falling back to title/description when null),faq jsonb(FAQPage JSON-LD source),status(draft/published),published_at. RLS enabled, no public policies — reads are service-role only (server components + admin APIs). Also creates the public Storage bucketblog-images.20260722_blog_body_format.sql— addsbody_format(markdowndefault, orhtml); one format per post, applies to both EN and BG bodies.20260723_blog_cover_mobile_url.sql— adds nullablecover_image_mobile_url(4:5 cover); falls back to the desktopcover_image_urlwhen null. Additive, existing rows unaffected.20261002b_blog_translations.sql(#548) — addstranslations jsonb(every non-English language, keyed by locale) and copies existing Bulgarian intotranslations.bg. The*_bgcolumns stay and are still written (Bulgarian mirrored on every save) so a rollback keeps showing Bulgarian.20261002c_blog_translations_seed.sql(#548) — the eight published posts in BG/RO/DE/FR/ES (AI translations,origin: "ai"). Each statement applies only to the exact English body it was translated from (md5 of the trimmed text) and never overwrites a language a post already has.
Public pages
All force-dynamic — a publish in the admin editor is live immediately, no deploy:
src/app/blog/page.tsx(English) andsrc/app/[lang]/blog/page.tsx(/bg/blog,/ro/blog, …) — listing, both rendered bysrc/components/blog/blog-index.tsx.src/app/blog/[slug]/page.tsx(English) andsrc/app/[lang]/blog/[slug]/page.tsx— post pages, both rendered by the sharedsrc/components/blog/post-view.tsx.src/app/blog/bg/[slug]/page.tsx— the pre-#548 Bulgarian address, now a permanent redirect to/bg/blog/<slug>.
Zipchat-style redesign (2026-07-23)
Spec: 05_tasks/specs/blog-zipchat-redesign.md (issue #44).
Listing — hero with a dual CTA (Install / Book-a-demo) and a trust line built from the
shared RESULTS constant; responsive 3/2/1-column card grid
(src/components/blog/post-card.tsx, 16:9 covers with a gradient placeholder when no cover is
set); a FinalCTA panel at the bottom.
Article — two-column desktop layout: ~720px main column + a 300px sticky right rail holding
a conversion card composed from landing-page atoms. On mobile the same card is collapsed into an
always-visible pill bubble that expands on tap
(src/components/blog/conversion-bubble.tsx; expand/dismiss state persisted in
sessionStorage via src/lib/blog/cta-state.ts).
Also on the article page:
- Auto TOC (“What you will learn”) built from the post’s H2/H3 headings —
src/lib/blog/toc.ts. Oneslugify+ uniqueness algorithm (duplicate headings get-2,-3… suffixes) drives anchor ids for both body formats: markdown headings via an inline rehype plugin, and raw-HTML bodies via id-injection that masks<script>/<style>/<pre>blocks first so it never rewrites text inside code/scripts. - Tables are styled and wrapped
display:block; overflow-x:autoso wide tables don’t break layout on mobile. - ChatGPT / Perplexity “summarize this” links.
- Related posts via
scoreRelatedPosts, rendered with the samepost-card.tsx.
Covers (desktop + mobile)
- Desktop
cover_image_url— 16:9, min 1600×900. - Optional mobile
cover_image_mobile_url— 4:5, min 800×1000; falls back to the desktop cover when not set. - Contracts live in one place,
src/lib/blog/ar-check.ts(COVER_CONTRACTS) — the admin editor’s slot labels are built from this same object. The aspect-ratio check (checkCoverDimensions, ±3% tolerance) never blocks an upload; it only returns a friendly non-blocking warning message. - Rendered via
src/components/blog/cover-picture.tsx— a<picture>with a(max-width:600px)mobile<source>and explicitwidth/height(avoids CLS).
Gotcha (caused a live 500 on dev, 2026-07-23): marketing constants (RESULTS,
INSTALL_URL, DEMO_URL) live in src/lib/marketing.ts — a plain module with no
'use client'. They must never be re-exported from the landing-page client module
(components/landing/landing-page.tsx). A server component (the blog pages) importing a value
from a 'use client' module gets a client reference instead of the value, which throws
"not in the React Client Manifest" — but only at request time, since both next build
and vitest stay green (the pages are force-dynamic, never statically rendered/tested against
a real request). Always import shared data constants from the plain module, never from a
'use client' file.
Languages (#548)
Owner decision 2026-10-02: the blog publishes in six languages — en, bg, ro, de, fr,
es (the biggest markets plus the ones we sell into), not all 24 site languages. The list is
BLOG_LOCALES in src/lib/marketing-i18n/registry.ts, beside the site’s list it is a subset of;
src/lib/blog/locales.ts builds the model on it. Adding a language = one entry there + its strings in
src/lib/blog/ui-copy.ts (typed — a missing string is a compile error). No schema change.
- Storage. English is the post itself (
*_encolumns; the slug stays English in every language). Every other language istranslations.<locale>= title, description, body, SEO title/description, cover alt, FAQ answers (same index as the English questions),origin(ai/human) andtranslatedAt. A language exists once its body is non-empty (hasTranslation). - URLs.
/blog/<slug>for English,/<locale>/blog/<slug>for the rest — like the site.localePath(locale, "/blog")returns/<locale>/blogfor a blog language and/blogfor every other site language (PARTIALLY_LOCALIZED_PATHS), so the nav, footer and language picker never link a 404. - Missing translation.
/<locale>/blog/<slug>for a post not in that language still answers: the English article with a notice in the reader’s language,robots: noindex, followand the canonical on the English page — never a 404 for a shared link, never a duplicate in Google. A language index with no article yet isnoindextoo. Drafts and unknown slugs stay real 404s. - The page’s own words (buttons, headings, notices, the conversion card) come from
BLOG_UI[locale]; the nav and footer use the site’s catalogue for that language. - Admin. The editor’s language tabs cover all six; a filled tab publishes that language. An AI
translation is labelled
· AIin the list and the tab, and any edit to it marks it reviewed (origin: "human"). Every language’s markdown is compile-checked on save. - AI translation button / automatic posts: not built yet — they need an Anthropic API key in the app (the follow-up issue). The first translations were made outside the app and shipped as the seed.
- Proof:
05_tasks/proof/548/· real seamscripts/real-seam/rs-548-blog-translations.mjs.
Admin
- Editor:
src/components/admin-blog.tsx— a Blog tab in/admin. Markdown editor (@uiw/react-md-editor) or raw-HTML mode. - CRUD API:
src/app/api/admin/blog/route.ts. Markdown bodies are compile-validated (MDX) on save; HTML bodies are stored raw (trusted-admin input, no sanitization). - Image upload:
src/app/api/admin/blog/upload/route.ts→ public Storage bucketblog-images(max 8MB;jpeg/png/webp/gif/avif).
SEO
buildPostMetadatainpost-view.tsx— canonical URL,hreflangfor every language the post exists in plusx-default, OGarticlemetadata in the page’s language.- JSON-LD:
BlogPosting+BreadcrumbList, plusFAQPagewhen the post hasfaqentries. src/app/blog/rss.xml/route.ts— RSS feed (English);src/app/[lang]/blog/rss.xml/route.ts— one feed per blog language, only the posts that exist in it.src/app/sitemap.ts— every language of every post (reciprocal alternates) and the index of every language with at least one article.src/app/llms.txt/route.ts— generated route, backed by the pure buildersrc/lib/blog/llms.ts. Emits a## Blogsection listing published posts (EN, plus one line per other language the post exists in, tagged(DE)etc.), inserted above## Optionalsince the llms.txt convention says crawlers may skip that section.
Freshness gotcha — sitemap/llms.txt/RSS served stale (fixed 2026-07-24, issue #97)
Blog v2 publishes instantly, but three SEO/AI-SEO surfaces did not reflect newly published posts, for two distinct reasons:
src/app/sitemap.tshad nodynamicexport, so Next prerendered it at build time — prod served it withX-Vercel-Cache: HIT, meaning posts published after a deploy stayed invisible to Google/Bing until the next unrelated deploy rebuilt it.- Route handlers replayed their first database read even when marked
force-dynamic, because Next’s data cache still caches the underlying Supabasefetch. Pages getno-storeimplicitly fromforce-dynamic; route handlers do not. This silently broke/blog/rss.xmltoo — it was observed serving a post deleted four hours earlier, withX-Vercel-Cache: MISS(the origin itself, not the CDN, was returning the stale body).
dynamic = 'force-dynamic' alone is not enough for a route handler that reads the DB. Pages
get implicit no-store; route handlers need it spelled out. Fix applied to sitemap.ts,
llms.txt/route.ts, and blog/rss.xml/route.ts:
export const dynamic = 'force-dynamic';
export const fetchCache = 'force-no-store';
export const revalidate = 0;llms.txt itself changed shape as part of this fix: it used to be a static public/llms.txt file
(no blog awareness, hand-edited only) and is now the generated route above. A file under public/
is served before the App Router and would silently shadow the route, so public/llms.txt had to be
deleted. The route sets Cache-Control: public, s-maxage=60, stale-while-revalidate=300 so the
edge still absorbs crawler bursts while a new post still shows up within a minute.
Proof: 05_tasks/proof/97/PROOF.md and the live harness
02_app/shopify-app/playwright-debug/proof-97-seo-freshness.js (14/14 against deployed dev). The
harness writes a post directly into the deployed environment’s DB — the only way to reproduce
“published long after the build” — and appends a unique query string to each request so its
assertions measure the origin, not the CDN.
Scheduled posting (#77)
A post can be published at a future minute, with no cron and no deploy — the same
force-dynamic property that makes publishing instant makes scheduling free.
Model. “Scheduled” is not a new status. It is status = 'published' with a published_at in
the future. No migration was needed: the column and blog_posts_published_idx (status, published_at DESC) have existed since 20260722_blog_posts.sql.
The rule lives in exactly three helpers (src/lib/server/supabase-admin.ts) — every public read
of the blog goes through them, so gating there covers all surfaces at once:
| Helper | Feeds |
|---|---|
listPublishedBlogPosts | index, “keep reading” rail, sitemap.xml, blog/rss.xml, llms.txt, llms-full.txt |
getPublishedBlogPost | /blog/[slug], /[lang]/blog/[slug] (404 while scheduled) |
listBlogCategories | index filter chips (so a scheduled post leaks no category) |
Each one now adds .lte("published_at", new Date().toISOString()) next to
.eq("status", "published") — inclusive, so published_at == now is live. The clock is read per
call, which is what makes the post appear at its minute. listAllBlogPosts (admin) is unchanged:
the editor sees scheduled posts.
<= never matches NULL, so a published row must carry a published_at. createBlogPost /
updateBlogPost always stamp one — only a hand-edited row could end up published-but-dateless, and
it would be invisible.
Write path. BlogPostWrite.publishedAt?: string | null (additive — an old payload that omits it
behaves exactly as before: publish now on a first publish, keep the stored date on a re-publish). The
admin API validates it with parsePublishAt (src/lib/blog/schedule.ts) before any write: parseable,
not before 2020, not more than 2 years out → 400 with an inline message otherwise.
Timezone contract. Storage and transport are always a UTC ISO instant. Only the
<input type="datetime-local"> in components/admin-blog.tsx speaks the author’s local wall clock;
localInputToIso / isoToLocalInput convert at that boundary and every label names the timezone.
The editor sends publishedAt only when the author actually changed the picker, so an ordinary
“Update (live)” never rewrites the publish date.
Proof: 05_tasks/proof/77/PROOF.md + playwright-debug/proof-77-blog-schedule.js (asserts all six
surfaces on deployed dev, including a 65 s “goes live at its minute” wait).
Tests & proof
- Unit:
src/lib/blog/toc.test.ts,ar-check.test.ts,cta-state.test.ts,schedule.test.ts,src/lib/server/blog-schedule-reads.test.ts, plus route tests for the admin CRUD/upload APIs. - Playwright proof (28 assertions, run against dev):
02_app/shopify-app/playwright-debug/proof-44-blog-redesign.js. - Scheduling proof (19 assertions, run against dev):
02_app/shopify-app/playwright-debug/proof-77-blog-schedule.js.