Blog (CMS + Redesign)

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. slug is 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 when body_bg is 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 bucket blog-images.
  • 20260722_blog_body_format.sql — adds body_format (markdown default, or html); one format per post, applies to both EN and BG bodies.
  • 20260723_blog_cover_mobile_url.sql — adds nullable cover_image_mobile_url (4:5 cover); falls back to the desktop cover_image_url when null. Additive, existing rows unaffected.
  • 20261002b_blog_translations.sql (#548) — adds translations jsonb (every non-English language, keyed by locale) and copies existing Bulgarian into translations.bg. The *_bg columns 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) and src/app/[lang]/blog/page.tsx (/bg/blog, /ro/blog, …) — listing, both rendered by src/components/blog/blog-index.tsx.
  • src/app/blog/[slug]/page.tsx (English) and src/app/[lang]/blog/[slug]/page.tsx — post pages, both rendered by the shared src/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. One slugify + 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:auto so wide tables don’t break layout on mobile.
  • ChatGPT / Perplexity “summarize this” links.
  • Related posts via scoreRelatedPosts, rendered with the same post-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 &lt;source&gt; and explicit width/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 (*_en columns; the slug stays English in every language). Every other language is translations.<locale> = title, description, body, SEO title/description, cover alt, FAQ answers (same index as the English questions), origin (ai / human) and translatedAt. 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>/blog for a blog language and /blog for 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, follow and 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 is noindex too. 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 · AI in 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 seam scripts/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 bucket blog-images (max 8MB; jpeg/png/webp/gif/avif).

SEO

  • buildPostMetadata in post-view.tsx — canonical URL, hreflang for every language the post exists in plus x-default, OG article metadata in the page’s language.
  • JSON-LD: BlogPosting + BreadcrumbList, plus FAQPage when the post has faq entries.
  • 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 builder src/lib/blog/llms.ts. Emits a ## Blog section listing published posts (EN, plus one line per other language the post exists in, tagged (DE) etc.), inserted above ## Optional since 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.ts had no dynamic export, so Next prerendered it at build time — prod served it with X-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 Supabase fetch. Pages get no-store implicitly from force-dynamic; route handlers do not. This silently broke /blog/rss.xml too — it was observed serving a post deleted four hours earlier, with X-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:

HelperFeeds
listPublishedBlogPostsindex, “keep reading” rail, sitemap.xml, blog/rss.xml, llms.txt, llms-full.txt
getPublishedBlogPost/blog/[slug], /[lang]/blog/[slug] (404 while scheduled)
listBlogCategoriesindex 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.