Files
blog/docs/content-model.md
T
davide 49c42ecc96 Replace Strapi CMS with PocketBase
Strapi + Postgres are gone in favor of PocketBase: a single Go binary
with embedded SQLite, built-in admin UI and per-collection API rules.
No content existed yet, so this is a clean swap with no data migration.

Collections and rules are defined as code in pocketbase/pb_migrations/
and applied automatically on first boot. Draft & Publish has no native
PocketBase equivalent, so it's reproduced with a nullable `publishedAt`
field enforced by listRule/viewRule, matching the old Strapi semantics.

Routing flips: PocketBase's admin UI and REST/file API are hardwired to
`/_/` and `/api/*` at the domain root (its own dashboard assets and API
calls reference those paths directly, so a stripped path prefix like
`/admin/*` would break them). `/api` is therefore reserved for
PocketBase now, and the frontend's Nitro endpoints move to `/content/*`
(frontend/server/routes/content/, not server/api/). A `/admin` vanity
route in Nitro (not Caddy) redirects to `/_/`, so it works the same in
dev, where Caddy isn't part of the stack, and in production.

frontend/server/utils/strapi.ts becomes pocketbase.ts; queries.ts is
rewritten for PocketBase's filter/sort/fields/expand query syntax.
StrapiImage becomes MediaImage (no width/height — PocketBase file
fields don't store dimensions, and the cover images already reserve
their aspect ratio via CSS, so this is not a regression).

docs/*.md, CLAUDE.md and README.md are updated in the same commit.
2026-09-11 11:25:14 +02:00

4.4 KiB

Content model & admin panel

Collections

Defined as code in pocketbase/pb_migrations/*.js, applied automatically the first time PocketBase boots against an empty data directory (and on every subsequent deploy that adds new migration files). No custom Go/JS hooks exist for either collection — schema and API rules only.

Collection Fields
articles title (text, required, max 160), slug (text, required, unique, kebab-case pattern), content (text — Markdown source, not the WYSIWYG editor field type), cover (single file, images only), coverAlt (text, alt text), category (relation to categories), publishedAt (date, nullable), authorName (text)
categories name (text, required, unique), slug (text, required, unique, kebab-case pattern)

Deliberately minimal: no tags, no separate SEO fields. Don't add these back without a concrete need — see CLAUDE.md:

  • Meta description is derived at request time from the article body (summarise() in frontend/server/utils/pocketbase.ts).
  • Publish date is publishedAt.
  • Social preview image is the cover.
  • The article byline is authorName, a plain field the editor fills in — PocketBase has no built-in "creator" metadata to auto-populate it from, unlike Strapi's createdBy.

There is no native Draft & Publish in PocketBase. publishedAt reproduces its semantics explicitly: empty = draft (never returned by the public API), set and not in the future = published. This is enforced by the collection's listRule/viewRule (publishedAt != '' && publishedAt <= @now), not by application code — an unpublished or future-dated article is invisible to GET /api/collections/articles/records regardless of what Nitro asks for. Categories have no draft state (slug is unique so a category always exists or doesn't). Public URLs always use the slug, never the PocketBase record id.

PocketBase file fields store only a filename, no width/height/alt metadata — that's why coverAlt is a real field rather than upload metadata, and why the frontend's MediaImage type carries no dimensions (see frontend.md).

Admin login & permissions

  • The admin panel lives at /_/ (also reachable via /admin, which just redirects there), routed by Caddy straight to the pocketbase container (architecture.md). It's PocketBase's standard superuser email/password auth — no custom auth code in this repo.
  • Public visitors never authenticate. There is no visitor account system, no comments, no public write access of any kind.
  • Each collection's createRule/updateRule/deleteRule is null — writes are superuser-only, full stop. There is no separate "editor" role: the site owner is the only superuser, matching "no front-end user accounts should ever exist."
  • listRule/viewRule on articles are publishedAt != '' && publishedAt <= @now (public read of published content only); on categories they're '' (public read, unconditional — categories aren't drafted).
  • cover's field config restricts uploads to images (PNG/JPEG/WEBP/AVIF/GIF), 10MB max — the equivalent of Strapi's old upload-plugin MIME allowlist, now expressed per-field instead of globally.
  • Any rule change beyond this is a deliberate least-privilege boundary, not an oversight, and needs to be justified explicitly.

Editorial workflow

  1. Log into PUBLIC_SITE_URL/admin (redirects to /_/).
  2. Create/edit a Category if needed.
  3. Create/edit an Article: title, slug, Markdown content, cover image + alt text, category, author name.
  4. Set publishedAt (to now, or a past/future date) to publish it. Leaving it empty keeps the article a draft, invisible to the public API.
  5. The change is live immediately: the public site has no cache layer to invalidate (see architecture.md).

Why Markdown, not a rich-text/WYSIWYG field

content is a plain PocketBase text field, not the editor field type (which stores HTML). PocketBase stores the raw Markdown; conversion to HTML happens once, server-side, in the Nuxt Nitro endpoint (renderMarkdown(), using marked) — never in PocketBase and never in the browser. This keeps marked out of the client bundle and keeps HTML generation in one place. There is no sanitization step: this is intentional, since the only author is the trusted superuser, not arbitrary users.