Files
blog/docs/architecture.md
T
davide 33b49c5dbb Add hand-rolled English support (/en/*) alongside the Italian site
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.
2026-09-11 13:53:17 +02:00

6.6 KiB

Architecture

Services

Three containers (production, see docker-compose.yml):

Browser → Caddy ─┬─ /_/* and /api/*   → PocketBase (pocketbase)
                 └─ everything else   → Nuxt 4 SSR (frontend) → PocketBase REST (internal)
                                          ("/admin" redirects to /_/, handled by Nitro)
  • pocketbase — a single PocketBase binary, the sole source of editorial truth. Embedded SQLite, no separate database service. No other backend framework exists in this repo; any server-side logic that isn't content management belongs in Nuxt's Nitro server, not in a new service.
  • frontend — Nuxt 4 in SSR mode. Renders public pages and exposes its own REST-like endpoints under /content/* (Nitro), which are the only code in the repo allowed to call PocketBase.
  • caddy — single reverse proxy, single TLS certificate, single public domain (PUBLIC_DOMAIN). Routes by path, not subdomain or port.

docker-compose.dev.yml is a local-only override (publishes ports on localhost, drops Caddy) and must always be passed explicitly with -f docker-compose.yml -f docker-compose.dev.yml — it's not an auto-merged override.yml, precisely so it can't be picked up by accident in production.

Path routing (Caddy)

See caddy/Caddyfile. One site block on {$PUBLIC_DOMAIN}:

  • @pocketbase path /_/* /api/*pocketbase:8090, unprefixed (100MB body limit, for uploads).
  • everything else → frontend:3000.

This means the English routes under /en/* (frontend.md) need no Caddy changes at all: they're plain Nuxt pages like any other, so they already fall under "everything else."

/admin is not a Caddy rule: it's a Nitro route (frontend/server/routes/admin.get.ts) that redirects to ${pocketbaseUrl}/_/ (PocketBase's own fixed dashboard route, since it can't be told to serve elsewhere). Handling it in Nitro rather than Caddy means it works identically in dev, where Caddy isn't part of the stack — /admin redirects to http://localhost:8090/_/ there — and in production, where it redirects to the same origin's /_/, which Caddy then proxies to PocketBase.

Why unprefixed, not a stripped /admin/* prefix: PocketBase's admin dashboard references its own assets and API with paths rooted at /_/ and /api/. A reverse-proxy rule that rewrites /admin/foo/foo before forwarding would serve the dashboard's HTML fine, but every asset and API call the dashboard's own JS makes afterwards targets /_/...//api/... directly — those requests would then miss the /admin prefix and never reach the rewrite rule, landing on Nuxt instead and breaking the dashboard. Routing /_/* and /api/* at the domain root, unprefixed, is the only configuration PocketBase's own code is written to expect (confirmed against a live container: dashboard HTML, its JS/CSS assets under /_/assets/..., and REST calls under /api/... all resolve correctly this way). This is also PocketBase's own documented recommendation for reverse-proxy deployments.

/api/* is reserved for PocketBase here — the inverse of the old Strapi setup. Nuxt's own Nitro endpoints live under /content/* instead (frontend/server/routes/content/, not frontend/server/api/, since Nitro auto-prefixes anything under server/api/ with /api). PocketBase's public REST API is reached two ways: from inside the Docker network by the Nitro server, over POCKETBASE_URL=http://pocketbase:8090; and directly by the browser for the two things that don't go through Nitro — the admin UI and cover images.

Request flow: reading an article

  1. Browser requests /blog/my-slug → Caddy → Nuxt SSR.
  2. frontend/app/pages/blog/[slug].vue calls useFetch('/content/articles/my-slug') — a same-origin call to Nuxt's own Nitro endpoint, resolved server-side during SSR (no round trip over the network in production).
  3. frontend/server/routes/content/articles/[slug].get.ts calls pbFetch() (in frontend/server/utils/pocketbase.ts), which hits POCKETBASE_URL (internal Docker address) with a PocketBase filter/fields query built by frontend/server/utils/queries.ts. PocketBase's listRule/viewRule on the articles collection already exclude unpublished entries — the endpoint doesn't need to check that itself.
  4. The endpoint converts the article's Markdown content to HTML server-side (marked, via renderMarkdown()) and derives a meta description (summarise()). The response shape is Article from frontend/shared/types/blog.ts.
  5. Nuxt renders the page with the HTML already embedded (v-html) — no Markdown parser ships to the client, and the article body is present in the server-rendered HTML for SEO/crawlers.
  6. The cover image <img> tag points at PUBLIC_POCKETBASE_URL/api/files/... — the only asset the browser fetches straight from PocketBase.

Request flow: publishing content

  1. Editor goes to PUBLIC_SITE_URL/admin (redirects to /_/, PocketBase's admin UI — authenticated superuser only, no public sign-up, see content-model.md).
  2. Editor writes/edits an Article (Markdown body) or Category, and sets publishedAt to publish it.
  3. PocketBase writes to its embedded SQLite database (on the pocketbase-data volume). No cache to invalidate: the next public request for that slug hits PocketBase live through the Nitro endpoint.

Environment variables

Defined in .env.example (root, drives docker-compose).

Variable Consumed by Purpose
PUBLIC_DOMAIN caddy Domain Caddy serves and requests a TLS cert for.
ACME_EMAIL caddy Contact email for Let's Encrypt.
POCKETBASE_ADMIN_EMAIL pocketbase Bootstraps (and keeps up to date, on every restart) the initial superuser account.
POCKETBASE_ADMIN_PASSWORD pocketbase Password for the superuser above. Rotating it is a credential change — see CLAUDE.md.
POCKETBASE_URL frontend (server-only) Internal Docker address of PocketBase (http://pocketbase:8090); mapped to NUXT_POCKETBASE_URL. Never sent to the browser.
PUBLIC_SITE_URL frontend Canonical public site URL for SEO/OG tags; mapped to NUXT_PUBLIC_SITE_URL.
PUBLIC_POCKETBASE_URL frontend, browser Public-facing PocketBase origin (same domain as PUBLIC_SITE_URL; Caddy proxies /_/* and /api/* there) for building absolute cover-image URLs; mapped to NUXT_PUBLIC_POCKETBASE_URL.

Never commit .env files or real secret values — keep .env.example sanitized (placeholders only).