PocketBase gains optional, manually-authored English fields on articles and categories (same record, same slug), and the frontend serves an /en counterpart of every dynamic route via thin page wrappers around shared view components — no i18n library, consistent with the project's existing minimalism stance for a two-locale site with ~20 UI strings. An article/category with no translation 404s cleanly on its own /en detail page and is filtered out of /en listings, and the header's language-switch link falls back to the English blog index rather than a dead link; resolving that requires a global route middleware, since the layout's header renders before the page content in document order and so can't react to state a page component sets during its own async setup.
6.2 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, max 10000 — 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), titleEn/contentEn/coverAltEn (text, optional — see English content below) |
| categories | name (text, required, unique), slug (text, required, unique, kebab-case pattern), nameEn (text, optional — see English content below) |
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).
English content
The site is now bilingual (Italian, the source language, and English), but PocketBase has no
localization feature and no separate translation workflow was introduced for it: titleEn,
contentEn, coverAltEn on articles and nameEn on categories are plain, optional text
fields living on the same record as their Italian counterparts — same slug, same cover, same
category relation, same publishedAt. Added by migration
pocketbase/pb_migrations/1757500002_add_english_fields.js, which alters the two existing
collections (app.findCollectionByNameOrId + collection.fields.add(...) /
.removeByName(...) on the down-migration) rather than recreating them — the first example of
that pattern in this repo, since until now every migration only ever created a collection.
Translations are written manually by the editor in the admin UI, at their own pace — there is no auto-translation step. Consequently:
- An article/category with empty
titleEn/contentEn/nameEnsimply has no English version yet. This is normal, not an error state. listRule/viewRuleare unchanged: whether a record is published is still governed solely bypublishedAt, independent of translation completeness. Whether it's offered in English is decided by the Nitro layer (frontend/server/routes/content/*), not by a PocketBase rule — see frontend.md.- There's no
publishedAtEnor similar: translation is all-or-nothing at the field level, not a separate publish gate. If partial/staged English publishing is ever needed, that's the natural next field to add — don't build it speculatively now.
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.