Direction for Quire Blog beyond the current single-owner, Vercel-hosted blog. This is
a planning document — nothing here is built yet unless its status says so. Operational
detail for shipped features lives in CLAUDE.md; the why of the
current design is in ARCHITECTURE.md.
Make Quire Blog something other people can actually run and live in - not just the author's personal instance. Near-term tracks:
- Run anywhere: Vercel or Docker, from one codebase.
- Publish from a Markdown note app (Obsidian, then Craft).
- Optional AI assist in the editor (titles, tags, drafting, images).
Long horizon (after Docker ships): a free multi-tenant SaaS at quire.app
(see Phase 7). The whole point of the SaaS is the opposite of lock-in - it is "the
same open-source app, hosted for you", and any blog exports to a single snapshot that
re-installs on Docker or a Vercel + Supabase stack with one button. Hosted is a
convenience, never a trap.
Verified against the code: the only hard Vercel coupling is @vercel/blob.
Everything else already ports to a plain Node/Docker runtime:
sharpdoes all image work (runs natively on Node — no Vercel image service).- ISR +
revalidatePath, the OG route, NextAuth and Markdown (gray-matter) all run undernext start/ standalone output. - Text content lives in Postgres via PostgREST — the app uses only Supabase's REST
layer, so self-host bundles Postgres + PostgREST and supabase-js is unchanged;
binaries are store-relative on Blob (
collapseBlob/expandBlob), so swapping the binary backend is mostly resolving a different base URL.
Done (2026-06-21, P1.5): migrated all text from the old no-DB
_index.json+.md-on-Blob model to Supabase Postgres (src/lib/db.ts). Binaries stay on Blob. The Phase 1 storage adapter below now concerns the BINARY store only.
So the roadmap is feature work on a sound base, not a rewrite.
- No lock-in (hard principle). Portability is a first-class feature, not an
afterthought. A blog's full state is one portable snapshot (the existing Backup
.tar.gz=db.json+ all binaries + manifest), and that same format is the import path on every target. The SaaS must always offer one-button export to self-host, with no proprietary data trapped server-side. Transparency over retention - never hold a user's content hostage. - Storage is pluggable. Self-host is fully independent of Vercel — default S3- compatible (MinIO / Cloudflare R2 / Backblaze) or local filesystem; Vercel Blob stays the default on Vercel.
- Note app: Obsidian first (Markdown-native, real plugin API). Craft is best- effort afterward (no comparable plugin API).
- AI: bring-your-own key, owner-only, server-side. Text via Claude (Anthropic); image generation via a separate provider (fal.ai / Replicate) since Claude does not generate images.
src/lib/blob.ts is now a facade selecting a driver by STORAGE_DRIVER:
- Vercel Blob (current behaviour, default on Vercel) ✅
- Local filesystem (single volume, served under
/uploads) ✅ — default for self-host - S3-compatible (MinIO / R2 / B2) — still planned; same interface, drop-in driver
Public-URL resolution was the main work: Vercel Blob has public URLs; the local driver
serves files via app/uploads/[...path]/route.ts. scripts/checks/no-direct-blob.mjs
keeps the SDK contained so a self-host build can't silently reach Vercel Blob. The S3
driver later reused per-tenant in the SaaS as "bring your own bucket" (Phase 7).
output: 'standalone'+Dockerfile+docker-compose.yml(app + db + rest + cron). ✅ The image builds with no backend env (data layer degrades to empty), so it is portable; env is supplied at runtime via.env.docker.- No cloud: bundled Postgres + PostgREST (replaces Supabase) + local FS store
(replaces Blob). supabase-js unchanged —
db.tsstrips the/rest/v1prefix whenPOSTGREST_DIRECT=1.scripts/docker/gen-keys.mjsmints DB password + JWT. ✅ - Cron: a sidecar pings
/api/cronhourly (Vercel Cron has no off-platform equivalent). ✅ - Still planned: a GitHub Action that builds + publishes a versioned image to GHCR on
each release tag, so updating is
docker compose pull && up -d; optional bundled MinIO once the S3 driver lands.
One codebase, one CI: the same source produces both the Vercel deploy and the Docker image — there is no second version to maintain.
Done (2026-06-22, v1.0.0): token auth + external publishing landed as the MCP server (
/api/mcp) — a single full-accessMCP_TOKEN(+ thin OAuth for connectors) lets an agent create/update/delete posts & pages, manage media/files, and read settings, all through the same data layer.add_media_from_urlrehosts an image URL to Blob.
Still planned: a plain HTTP ingest endpoint that takes Markdown + frontmatter and maps
it to post fields (for the note-app plugins below), rehosting embedded images.
(scripts/legacy/rehost-images.mjs and legacy/import-wordpress.mjs are existing patterns to build on.)
- Obsidian plugin: a command that POSTs the active note (frontmatter + body) and its attachments to the ingest API. Quire Blog already stores exactly this format.
- Craft: best-effort — Markdown export → paste-import in admin, or pull via the Craft API where possible.
Owner-gated /api/ai/* routes; key in env, never client-exposed:
- Text (Claude): suggest title, tags/categories, excerpt; draft / rewrite a selection.
- Image (fal.ai / Replicate): generate, then upload to storage as featured image.
Independent of Phases 1–4 — could be done first as a quick win.
Reader comments with no third-party login (giscus was rejected for exactly this —
it forces a GitHub account). Fully self-hosted on the existing Supabase Postgres,
owner-moderated, spam-guarded by Cloudflare Turnstile. A features.comments toggle
gates the whole thing (re-added; removed when the giscus spike was dropped).
Data model — new comments table:
id(uuid),post_slug(text, references a post),author_name(text, required),author_email(text, required, never shown publicly — used for a Gravatar hash, dedup, and optional owner notify),body(text, plain/lightly-formatted),status(pending|approved|spam, defaultpending),created_at,ip+user_agent(abuse triage). Optional v2:parent_idfor threaded replies.- Index on
(post_slug, status, created_at).
Public flow:
- Comment form at the end of a post: name + email + body, plus a hidden honeypot field and the Turnstile widget.
POST /api/comments→ verify Turnstile (server-sidesiteverify) + honeypot empty + per-IP rate-limit → insert aspending. Reader sees "awaiting moderation".- Only
approvedrows render. Keep the post page SSG by loading comments through a small client component (GET /api/comments?slug=) instead of server-reading them — the article HTML stays ISR/static; comments hydrate after.
Moderation (admin):
- New
/admin/commentspage (force-dynamic): pending + approved lists, newest first, filter by status / post. Actions: approve, unapprove, mark spam, delete, with bulk select. A pending-count badge in the admin nav. - Log each action to the activity log (
comment.approve/comment.spam/comment.delete).
Spam protection:
- Cloudflare Turnstile (free, privacy-friendly, no puzzle) — env
TURNSTILE_SITE_KEY(public) +TURNSTILE_SECRET_KEY(server). Verified before every insert. - Honeypot field + minimum time-on-page + per-IP rate-limit. Optional link/keyword
heuristics auto-flag obvious spam straight to
spam.
Out of scope for v1 (later): email-notify the owner on a new pending comment (Resend or similar); threaded replies; reactions.
i18n: form labels, validation/awaiting-moderation messages, and the moderation UI
go through src/locales/ (+ admin) like everything else.
A free, hosted Quire Blog at quire.app: same open-source app, run for you. Built
only AFTER Docker ships, so every hosted blog has a guaranteed eject path - hosted is a
convenience, not a trap (see "No lock-in" in Decisions locked). This is the model-A
choice: one shared stack, many blogs, isolated by tenant_id (true multi-tenant, not
deploy-per-user). It is a large rewrite of the data layer, accepted deliberately.
Tenancy (the foundation, biggest lift):
- New
tenants(id, owner_user_id, subdomain, custom_domain, plan, status) +users(auth identity, owner of a tenant). Addtenant_idto EVERY content table (postspagespost_revisionsmediafilessettingsmcp_tokensbackup_stateactivity_loganalytics_*). settingsdrops the hardcodedid=1→ one row per tenant.- Cache tags go per-tenant (
db:<tenantId>not the globaldb), or one user's save purges everyone's cache. - Blob paths get a tenant prefix (
t/<tenantId>/...); URLs stay deterministic from the one store token. Easiest part - blob I/O is already centralized. - Security: the app uses
service_roletoday (bypasses RLS). Multi-tenant requires either per-request clients scoped by JWT claims, or enforcingtenant_idin every query. This is the most sensitive surface - get it wrong and tenants read each other.
Auth & routing:
- Open signup, owner-per-tenant (replaces the single
AUTHORIZED_EMAIL). - Wildcard
*.quire.app; middleware resolves the tenant from the host. Admin atapp.quire.app. Custom domains via the Vercel Domains API + automated SSL.
Portability backbone (the headline promise):
- Reuse Backup/Restore as the universal interchange format. Export = the tenant's
slice as a
.tar.gz; Import = restore that snapshot into a fresh Docker or Vercel + Supabase install. Make import a first-class onboarding step on self-host. - One button to leave, no proprietary state left behind.
Plans (this is a hobby, never for profit):
- Free, for everyone: 1GB storage AND custom domain - no questions, no upsell games, no feature held back. The free tier is the product; the operator's own salary covers its running cost. Custom domain is obviously free.
- Paid, only for heavy/professional users who outgrow 1GB: buy more storage for yourself when you genuinely need it. Priced later, just enough to cover cost, kept super cheap - it offsets storage, it does not make money.
- Bring your own bucket (BYOS): a tenant can connect their own Cloudflare R2 / S3 bucket; their binaries live there, on their dime, effectively unlimited and entirely outside our quota. This just applies the Phase 1 storage adapter per-tenant (the same interface that powers self-host). It reinforces no-lock-in - the media sits literally in the user's own bucket - and a heavy user need not pay us at all.
Why "free" stays sustainable: text in Postgres is tiny (a blog's DB stays well under 1GB); binaries are the only real cost driver. With BYOS offloading the heavy media to users' own buckets and the shared DB staying small, the operator's running cost barely grows with users - the free tier holds almost indefinitely.
Cost & abuse guardrails (make-or-break for "free"):
- Per-blog storage quota + rate limits; reap/flag long-dead free blogs.
- Hosting user content = real liability: ToS, content reporting, a per-tenant kill switch, DMCA path. Plan this in, not after.
- Single author (one
AUTHORIZED_EMAIL) per instance. No multi-user / roles in the self-host build - multi-tenant arrives only in the SaaS (Phase 7), which lifts this. _index.jsonis read in full per list regeneration — fine to the low hundreds of posts; sharding would be needed well beyond that.- Related-posts box on other posts can lag up to the ISR window after a new post (see CLAUDE.md caching notes); the "Clear all cache" button is the instant fix.