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.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.
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) andsrc/app/blog/bg/[slug]/page.tsx(BG, gated onhasBulgarianfor that post) — post pages, both rendered by the sharedsrc/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. 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.
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,hreflangen/bg, OGarticlemetadata.- JSON-LD:
BlogPosting+BreadcrumbList, plusFAQPagewhen the post hasfaqentries. 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 buildersrc/lib/blog/llms.ts. Emits a## Blogsection listing published posts (EN, plus the/blog/bg/<slug>variant only when the post has a Bulgarian body), 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.
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.