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 bilingual (EN/BG) blog CMS. Posts publish instantly (no deploy) since the public pages are force-dynamic and read straight from Postgres.

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.

Public pages

All force-dynamic — a publish in the admin editor is live immediately, no deploy:

  • src/app/blog/page.tsx — listing.
  • src/app/blog/[slug]/page.tsx (EN) and src/app/blog/bg/[slug]/page.tsx (BG, gated on hasBulgarian for that post) — post pages, both rendered by the shared src/components/blog/post-view.tsx.

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.

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 en/bg, OG article metadata.
  • JSON-LD: BlogPosting + BreadcrumbList, plus FAQPage when the post has faq entries.
  • src/app/blog/rss.xml/route.ts — RSS feed.
  • src/app/sitemap.ts — blog posts included in the sitemap.
  • 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 the /blog/bg/<slug> variant only when the post has a Bulgarian body), 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.

Tests & proof

  • Unit: src/lib/blog/toc.test.ts, ar-check.test.ts, cta-state.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.