Status: Design complete. Implementation gated on two infra checks (§9). Created: 2026-06-02 Scope: Sequenced spec to execute or hand off. Related vision:
vision/hosted-blog-vision.md,vision/layouts-vision.md,vision/blog-engine-vision.md
1. Context — why this exists
Symptom: publishing a post via dashboard/MCP did nothing on vlozi.app until someone ran next build && wrangler pages deploy (and a stale Next data cache even hid it after that). For a product whose audience is non‑technical creators who do everything from the dashboard, "you must redeploy to publish" is a dealbreaker.
Core issue — it's not vlozi.app, it's a missing product surface. The only ways a customer blog can reach the public web today are: (a) install the @vlozi/blog React SDK into their own Next.js site, or (b) call the API themselves. A Webflow / Carrd / "no website yet" customer — the actual target audience — cannot publish a visible blog at all. apps/website (vlozi.app) is a static, single‑tenant marketing site with a hardcoded API key; it was never the customer template.
Architecture problem: there is no dynamic, multi‑tenant delivery surface that turns a published post into live HTML on request. Rendering is locked to build time on a single‑tenant static export.
Intended outcome: a customer publishes in the dashboard → their blog is live within seconds, SEO‑indexable, on their own subdomain (later their own domain), zero technical activity, zero redeploy — for any tenant.
Decision: prioritize now, override the vision's "validate layouts first" gate, ship with a single default layout.
2. Goals / non‑goals
Goals
- Any tenant's published content is live on the public web within seconds of publish, no rebuild.
- SEO‑ready server‑rendered HTML (meta, OG, canonical, RSS, sitemap).
<tenant-slug>.vlozi.app/blogfor every tenant, free.- Reuse the existing blog-service rendering + public API; no duplicated query/render logic.
- Incremental, low‑risk rollout (no flag‑day; degrade gracefully if the new worker is absent).
Non‑goals (designed‑in seams, deferred)
- The 6‑layout themable catalog (
layouts-vision.md) — ship one default layout. - Custom domains via Cloudflare for SaaS + cert provisioning.
- Dynamic OG image generation (MVP redirects to featured image).
- Migrating vlozi.app's own blog onto the new surface (Phase 4; needs custom‑domain support).
3. Solution overview
Build apps/blog-host — a Cloudflare Worker that renders any tenant's blog as SEO‑ready HTML at the edge on every request, reading from the existing blog-service public API over a service binding, caching at the edge, and purging that cache on publish via a fan‑out from blog-service (mirroring the existing newsletter waitUntil pattern). Strict subset of hosted-blog-vision.md.
Dashboard/MCP ──publish──▶ blog-service (DB flip)
│ waitUntil fan-out (x-internal-key)
▼
blog-host /internal/invalidate ──▶ Cloudflare purge_by_tag(tenant:{id}:posts)
Reader ──▶ <slug>.vlozi.app/blog ──▶ blog-host (edge)
│ cache hit → HTML
│ miss → BLOG_SERVICE binding (x-gateway-key + x-tenant-id)
▼ render default layout + cache w/ Cache-Tag4. Ground truth that shapes the design (verified)
- Tenant slug already exists.
packages/core-database/src/schema.ts:64-73—tenants.slugisunique().notNull()("url-friendly");statusandplanIdon the same row.<slug>.vlozi.app→ tenant is one indexed lookup. No new table for Phase 1. - blog-service trusts
x-gateway-key+x-tenant-id(apps/blog-service/src/index.ts:106-164); it does not re-derive tenant from an API key. blog-host calls it over a service binding with those two headers and reuses public routes unchanged. - Post body is already server-rendered HTML (
apps/blog-service/src/routes/public/posts.ts:189viautils/tiptap-renderer.ts). blog-host wraps it in a page shell → no React/RSC in the worker for MVP (plain HTML template strings; removes the biggest bundling risk). - Publish fan-out to mirror:
apps/blog-service/src/routes/admin/posts.ts:611-646(c.executionCtx.waitUntil(NEWSLETTER_SERVICE.fetch(...x-internal-key...))). Idempotent early-return at:599-605is the one place to skip emitting. - SDK feed helpers are worker-safe —
packages/blog-sdk/src/feeds.tsgenerateRSS/generateSitemaptake a client, return XML; reuse via a thin adapter. (The SDKVloziClientis NOT reused for fetching — it hardcodesAuthorization: Bearer pk_…against the public gateway URL, wrong over a binding.)
5. Component design — apps/blog-host
Framework: Hono, compatibility_date = "2024-04-01", compatibility_flags = ["nodejs_compat"] (matches the other workers).
Module layout (apps/blog-host/src/):
index.ts Hono app: middleware chain + route table
bindings.ts Env type: BLOG_SERVICE (Fetcher), CORE_DATABASE_URL,
GATEWAY_SECRET, INTERNAL_KEY, CF_PURGE_TOKEN, CF_ZONE_ID
middleware/
tenant.ts Host → slug → {tenantId, plan, status}; 404 unknown/inactive
cache.ts Cache API read-through + Cache-Tag + Cache-Control
data/
blog-client.ts Thin fetcher over BLOG_SERVICE binding (NOT the SDK client)
tenant-repo.ts tenants.slug lookup (Drizzle, per-isolate 5-min cache)
routes/
index.ts GET /blog (post list, paginated)
post.ts GET /blog/:slug
category.ts GET /blog/category/:slug
tag.ts GET /blog/tag/:slug
feed.ts GET /feed.xml (SDK generateRSS via adapter)
sitemap.ts GET /sitemap.xml (SDK generateSitemap via adapter)
og.ts GET /og/:slug.png (MVP stub → featured image)
internal.ts POST /internal/invalidate (binding-only, x-internal-key)
render/
layout.ts renderPage({head, body, tenant, plan}) → full <!doctype> string
surfaces.ts renderList / renderPost / renderCategory / renderTag
seo.ts title/desc/OG/Twitter/canonical/RSS-alternate
styles.ts inlined CSS — MUST cover tiptap-renderer classes
hydrate.client.ts separate esbuild entry → /blog/_hydrate.js
wrangler.tomlwrangler.toml essentials:
name = "logicspike-blog-host"
main = "src/index.ts"
compatibility_date = "2024-04-01"
compatibility_flags = ["nodejs_compat"]
routes = [
{ pattern = "*.vlozi.app/blog", zone_name = "vlozi.app" },
{ pattern = "*.vlozi.app/blog/*", zone_name = "vlozi.app" },
{ pattern = "*.vlozi.app/feed.xml", zone_name = "vlozi.app" },
{ pattern = "*.vlozi.app/sitemap.xml", zone_name = "vlozi.app" },
]
[[services]]
binding = "BLOG_SERVICE"
service = "logicspike-blog-service"
# secrets: GATEWAY_SECRET, CORE_DATABASE_URL, INTERNAL_KEY, CF_PURGE_TOKEN, CF_ZONE_IDRoutes are path-scoped to /blog, /feed.xml, /sitemap.xml and host-scoped to *.vlozi.app (does not match bare apex), so the marketing site and api./mcp./go. subdomains are never intercepted.
5.1 Tenant resolution (middleware/tenant.ts)
- Read
Host; for*.vlozi.app,slug = host.split(".")[0]. - Deny reserved labels:
www, api, app, go, mcp, admin→ 404. - Look up
tenantsby slug via@repo/core-database(createDb,eq(tenants.slug, slug)), per-isolateMapcache w/ 5-min TTL — mirrorsapps/gateway/src/middleware/api-key.middleware.ts. - 404 if missing or
status !== "active". Setc.set("tenant", {tenantId, plan, slug}).
5.2 Data fetching (data/blog-client.ts) — service binding, not the SDK:
this.svc.fetch(`https://blog-service.internal${path}`, {
headers: { "x-gateway-key": GATEWAY_SECRET, "x-tenant-id": tenantId },
})Methods: listPosts({page,limit,category?,tag?}), getPost(slug), listCategories(), listTags() → existing /public/* routes. GATEWAY_SECRET must be one of blog-service's comma-split accepted keys (index.ts:114) — add a dedicated blog-host key to that list for independent rotation.
5.3 Rendering (render/) — MVP = HTML template strings (no RSC):
layout.ts→ full page shell with inlined<style>and one<script type="module" src="/blog/_hydrate.js">.styles.tsmust style the classes the tiptap renderer emits (.callout-*,.task-list,.task-item,figure/figcaption,.video-embed,pre code.language-*) — seetiptap-renderer.ts. This coupling is documented and load-bearing.surfaces.ts:renderPostinjectspost.content(already-sanitized HTML) in<article>; list/category/tag render cards.- Free-tier "Powered by Vlozi" footer when
plan === "free"(one conditional; plan already loaded).
5.4 SEO (render/seo.ts) — per surface: <title>=seoTitle||title; description=seoDescription||excerpt; OG (og:image=featuredImageUrl, og:type=article, og:url); Twitter card; <link rel="canonical">=siteUrl+path where siteUrl=https://${host}; RSS alternate. Canonical builder takes verifiedCustomDomain ?? requestHost (one conditional, wired when custom domains ship → SEO equity accrues to the customer's domain).
5.5 Client JS — page fully indexable with zero JS; _hydrate.js lazy-enhances mermaid/carousel + hover-prefetch only; ≤30 KB gz budget; separate esbuild entry, immutable cache.
6. Publish → live invalidation pipeline
6.1 Emit (blog-service) — new helper apps/blog-service/src/services/cache-invalidate.ts:
export function invalidateTenantCache(c, { tenantId, slug }) {
if (!c.env.BLOG_HOST || !c.env.INTERNAL_KEY) return // optional → degrade silently
c.executionCtx.waitUntil(
c.env.BLOG_HOST.fetch("http://internal/internal/invalidate", {
method: "POST",
headers: { "content-type": "application/json", "x-internal-key": c.env.INTERNAL_KEY },
body: JSON.stringify({ tenantId, slug }),
}).then(logIfNotOk).catch(logErr)
)
}Call at all 5 status-change sites in apps/blog-service/src/routes/admin/posts.ts, each guarded by the existing success check (*.length !== 0):
| Route | Note |
|---|---|
publish (~:646) |
inside publishRes.length !== 0; skip the idempotent return at :599-605 |
| unpublish | inside success check |
| schedule | always emit on successful flip (scheduling hides a live post; avoids a prior-status SELECT) |
| unschedule | always emit on successful flip |
| delete | easy to miss — must include (a deleted post must drop from cache) |
Add slug to the .returning()/SELECT on routes that lack it (one column). Fallback tag if slug unavailable: per-id tenant:{id}:post-id:{postId} + tenant:{id}:posts.
6.2 Binding — add optional [[services]] BLOG_HOST to apps/blog-service/wrangler.toml and BLOG_HOST?: Fetcher to the Bindings types. Optional ⇒ blog-service boots without blog-host ⇒ emit code can land inert first; blog-host deploy flips it on (no flag-day).
6.3 Receive (blog-host /internal/invalidate) — constant-time x-internal-key check; body {tenantId, slug?}; call Cloudflare purge_by_tag for tenant:{id}:posts (+ tenant:{id}:post:{slug}); one retry on non-2xx; idempotent; mounted only over the binding, never the public host; validate tenantId shape (a leaked key can only force cache misses, scoped token can only purge).
6.4 Edge cache (blog-host middleware/cache.ts) — Cache API keyed by full URL (host-scoped, no cross-tenant collision); tag every response tenant:{id}:posts (+ per-post tag on post/OG). Cache-Control:
| Surface | Cache-Tag | Cache-Control |
|---|---|---|
| Post | :posts, :post:{slug} |
public, max-age=60, s-maxage=604800, swr=86400 |
| List/category/tag | :posts |
public, max-age=30, s-maxage=3600, swr=86400 |
| RSS / sitemap | :posts |
public, max-age=300, s-maxage=3600, swr=86400 |
| OG | :posts, :post:{slug} |
public, max-age=300, s-maxage=2592000, swr=86400 |
Low browser max-age so a publishing user sees fresh content even before purge propagates; 1 h edge cap on lists/RSS/sitemap so a failed purge self-heals within an hour; purge is the primary freshness lever.
6.5 Secrets — blog-host: scoped CF token (Zone → Cache Purge only) CF_PURGE_TOKEN + CF_ZONE_ID. INTERNAL_KEY = existing fleet secret. blog-service never sees the CF token.
7. vlozi.app strategy
7.1 Interim stopgap (vlozi tenant only) — create a Cloudflare Pages deploy hook for vlozi-website; from the same 5 emit sites, if tenantId === VLOZI_TENANT_ID also waitUntil(fetch(PAGES_DEPLOY_HOOK_URL)). Must fix the stale Next data cache: set the Pages build command to rm -rf .next/cache && next build, and/or make apps/website/lib/blog.ts fetches cache:"no-store". Trade-off: ~1–3 min build vs seconds; fine at vlozi's volume; explicitly temporary.
7.2 Long-term — migrate vlozi.app's blog onto blog-host (best dogfooding; removes build cost, latency, and the .next gotcha; deletes the tenant-specific branch). apps/website keeps marketing pages, drops app/blog/** + the build-time fetch. Gated on custom-domain support (apex vlozi.app isn't a *.vlozi.app subdomain → needs the same host→tenant mapping).
8. Phasing & estimates
| Phase | Scope | Est. | Independent? |
|---|---|---|---|
| 1a — host scaffold | worker, *.vlozi.app/blog* routes, BLOG_SERVICE binding, tenant resolution, /blog stub |
~1 day | — |
| 1b — data + render | BlogClient, default layout, list/post/category/tag, SEO |
~2 days | — |
| 1c — feeds + hydrate | /feed.xml, /sitemap.xml, _hydrate.js |
~1 day | — |
| 1d — edge cache | Cache API + Cache-Tag + Cache-Control | ~1 day | ships value w/o purge (30s SWR) |
| 1e — purge pipeline | /internal/invalidate + blog-service helper + 5 emit sites + BLOG_HOST binding |
~1 day | emit lands inert first |
| 2 — vlozi interim | Pages deploy hook + .next/cache fix |
~0.5 day | yes (no blog-host dep) |
| 3 — validate/tune | purge latency, TTLs, observability | ~1 day | — |
| 4 — layouts + custom domains, then migrate vlozi | catalog, Cloudflare for SaaS, cert flow, delete interim | ~2.5 wk | future |
Phase 1 total ≈ 6 working days for a real hosted blog at <slug>.vlozi.app/blog, publish→live in seconds, for every tenant.
9. Pre-build verification gates (do FIRST — read-only)
- Cloudflare plan for tag purge.
purge_by_tagis classically Enterprise-only. Verify vlozi.app's zone plan. If not Enterprise: keep the/internal/invalidatecontract but purge by explicit URL list (post page +/blog+ feed + sitemap + affected category/tag) or purge-everything — only the CF call inside the endpoint changes. - Wildcard cert + DNS for
*.vlozi.app. Worker routes don't fire on a host without a matching edge cert. Confirm the universal/ACM cert covers*.vlozi.appand a wildcard DNS record exists. (Quick external check: TLS handshake to a randomxyz.vlozi.app.) - Account pinning. All wrangler/CF calls must force
CLOUDFLARE_ACCOUNT_ID=72fe41e9f39ad60cfb6937bb26ab50c3(wrangler defaults to a stale wrong account here).
10. Critical files
apps/blog-service/src/routes/admin/posts.ts— 5 emit sites; mirror:611-646, skip:599-605.apps/blog-service/src/index.ts— Bindings type, gateway-key gate (:114), status-route list (:178).apps/blog-service/src/routes/public/posts.ts— routes blog-host consumes (:189body-as-HTML).apps/blog-service/src/utils/tiptap-renderer.ts— CSS class contract blog-hoststyles.tsmust match.apps/blog-service/wrangler.toml— add optionalBLOG_HOSTbinding + secrets.packages/core-database/src/schema.ts:64-73—tenants.slugresolution source (@repo/core-database).apps/gateway/src/utils/proxy.utils.ts:83-92+middleware/api-key.middleware.ts— service-binding header + per-isolate cache precedents.packages/blog-sdk/src/feeds.ts— reusegenerateRSS/generateSitemap.apps/website/lib/blog.ts,next.config.ts,package.json— vlozi interim + eventual blog removal.- New:
apps/blog-host/**,apps/blog-service/src/services/cache-invalidate.ts.
11. Risks & mitigations
- Tag-purge plan gate (§9.1) — biggest unknown; clean URL-purge fallback.
- Wildcard cert (§9.2) — can block 1a; verify first.
- Cache stampede on hot-tenant purge —
stale-while-revalidate+ per-post tagging; consider CF Tiered Cache. - Multi-region DB freshness — emit in
waitUntil(post-commit) and/or read primary for blog-host; low risk at current scale. - Next build-cache gotcha (proven) — clear
.next/cache; disappears after Phase 4. /internal/invalidatesecurity — constant-time key, binding-only mount,tenantIdvalidation; worst case is forced cache misses, not data loss.- CSS/renderer coupling —
styles.tsmust track tiptap-renderer node classes; documented. - Slug rename — old subdomain 404s, old cache self-expires; acceptable for MVP.
12. Verification (prove it end-to-end)
- Local:
wrangler devblog-host withBLOG_SERVICEbound to local blog-service; seed a tenant + slug; request withHost: <slug>.vlozi.app→ list + post render with correct SEO + styled content. - Pipeline: publish via MCP (
blog_create_draft+blog_publish_post); assert/internal/invalidatefired (log) and the post appears within the browsermax-agewindow with no deploy; repeat unpublish/schedule/delete. - Edge cache: confirm
Cache-Tag+Cache-Controlper surface; force a purge → next request re-renders. - SEO: view-source shows server-rendered
<article>, canonical, OG;/feed.xml+/sitemap.xmlvalid. - Regression: blog-service tests pass; add a test asserting the emit helper fires on each of the 5 status changes and is skipped on idempotent re-publish.
- vlozi interim: publish on vlozi's tenant → deploy hook fires → rebuild with
.next/cachecleared → post appears on vlozi.app/blog and in the index (not just the detail page).
13. Open questions (for pricing/product, not blocking Phase 1)
- Free-tier request cap per tenant (abuse/DDoS-by-popularity); "Powered by Vlozi" footer removal as paid feature.
- Custom-domain tier mapping (free=0, starter=1, pro=5, …) — pricing-team decision before Phase 4.
- Which
tenant_blog_config(layout/tokens) table owns layout choice when the catalog ships (recommend it lives in blog-service, exposed via a newGET /public/config).