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.
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()infrontend/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'screatedBy.
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 thepocketbasecontainer (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/deleteRuleisnull— 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/viewRuleonarticlesarepublishedAt != '' && publishedAt <= @now(public read of published content only); oncategoriesthey'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
- Log into
PUBLIC_SITE_URL/admin(redirects to/_/). - Create/edit a Category if needed.
- Create/edit an Article: title, slug, Markdown content, cover image + alt text, category, author name.
- 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. - 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.