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.
This commit is contained in:
+6
-6
@@ -6,7 +6,7 @@ the root [README.md](../README.md); for coding conventions and constraints, see
|
||||
depth than either of those.
|
||||
|
||||
- [architecture.md](./architecture.md) — services, routing, request flow, environment variables.
|
||||
- [content-model.md](./content-model.md) — Strapi content types, admin panel, permissions, editorial workflow.
|
||||
- [content-model.md](./content-model.md) — PocketBase collections, admin panel, permissions, editorial workflow.
|
||||
- [frontend.md](./frontend.md) — Nuxt routes, server (Nitro) endpoints, data flow, SEO.
|
||||
|
||||
## What this repository is
|
||||
@@ -15,9 +15,9 @@ A blog website with two faces:
|
||||
|
||||
- **Public site** — anonymous visitors read articles and browse categories. Fully server-rendered,
|
||||
no login, no client-side calls to the CMS.
|
||||
- **Admin panel** — the site owner logs into Strapi's admin UI (`/admin`) to write, edit and
|
||||
publish articles and categories. This is the only way content changes; there is no other CMS
|
||||
and no public user accounts.
|
||||
- **Admin panel** — the site owner logs into PocketBase's admin UI (`/admin`, redirects to `/_/`)
|
||||
to write, edit and publish articles and categories. This is the only way content changes; there
|
||||
is no other CMS and no public user accounts.
|
||||
|
||||
One codebase, three runtime components (Nuxt frontend, Strapi CMS, PostgreSQL), fronted by a
|
||||
single Caddy reverse proxy on one domain.
|
||||
One codebase, two runtime components (Nuxt frontend, PocketBase — CMS and database in one
|
||||
process), fronted by a single Caddy reverse proxy on one domain.
|
||||
|
||||
+58
-53
@@ -2,22 +2,22 @@
|
||||
|
||||
## Services
|
||||
|
||||
Four containers (production, see [docker-compose.yml](../docker-compose.yml)):
|
||||
Three containers (production, see [docker-compose.yml](../docker-compose.yml)):
|
||||
|
||||
```text
|
||||
Browser → Caddy ─┬─ /admin and Strapi plugin paths → Strapi 5 (cms) → PostgreSQL (database)
|
||||
└─ everything else → Nuxt 4 SSR (frontend) → Strapi REST (internal)
|
||||
Browser → Caddy ─┬─ /_/* and /api/* → PocketBase (pocketbase)
|
||||
└─ everything else → Nuxt 4 SSR (frontend) → PocketBase REST (internal)
|
||||
("/admin" redirects to /_/, handled by Nitro)
|
||||
```
|
||||
|
||||
- **database** — `postgres:17-alpine`. Not exposed publicly; only reachable by `cms` on the
|
||||
Docker network. Data on the `postgres-data` volume.
|
||||
- **cms** — Strapi 5, the sole source of editorial truth. 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.
|
||||
- **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 `/api/*` (Nitro), which are the only code in the repo allowed to call Strapi.
|
||||
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.
|
||||
(`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` —
|
||||
@@ -26,74 +26,79 @@ production.
|
||||
|
||||
## Path routing (Caddy)
|
||||
|
||||
See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`, one path matcher
|
||||
`@cms` listing every Strapi/plugin top-level prefix that must bypass Nuxt:
|
||||
See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`:
|
||||
|
||||
```
|
||||
/admin* /content-manager* /content-type-builder* /upload* /i18n*
|
||||
/email* /content-releases* /review-workflows* /users-permissions* /cloud*
|
||||
```
|
||||
- `@pocketbase path /_/* /api/*` → `pocketbase:8090`, **unprefixed** (100MB body limit, for
|
||||
uploads).
|
||||
- everything else → `frontend:3000`.
|
||||
|
||||
Everything matching `@cms` goes to `cms:1337` (100MB body limit, for media uploads). Everything
|
||||
else — including `/api/*` — goes to `frontend:3000`.
|
||||
`/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.
|
||||
|
||||
**`/api/*` is reserved for Nuxt's own Nitro endpoints, never for Strapi.** Strapi's public REST
|
||||
API (`/api/articles`, `/api/categories`) is reached only from inside the Docker network, by the
|
||||
Nitro server, over `STRAPI_URL=http://cms:1337`. The browser never sees a Strapi URL for content
|
||||
— only for cover images (`PUBLIC_STRAPI_URL/uploads/...`, read-only, unauthenticated).
|
||||
**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.
|
||||
|
||||
If you add a Strapi plugin that mounts its own admin API path, add its prefix to `@cms` in the
|
||||
Caddyfile — this is the one place that list is maintained.
|
||||
**`/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('/api/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/api/articles/[slug].get.ts` calls `strapiFetch()` (in
|
||||
`frontend/server/utils/strapi.ts`), which hits `STRAPI_URL` (internal Docker address) with a
|
||||
Strapi-specific query built by `frontend/server/utils/queries.ts`.
|
||||
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 directly at `PUBLIC_STRAPI_URL/uploads/...` — the only
|
||||
asset the browser fetches straight from Strapi.
|
||||
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 logs into `/admin` (Strapi admin panel, authenticated, editor/admin only — no public
|
||||
sign-up, see [content-model.md](./content-model.md)).
|
||||
2. Editor writes/edits an Article (Markdown body) or Category, and publishes it (Draft & Publish).
|
||||
3. Strapi writes to PostgreSQL. No cache to invalidate: the next public request for that
|
||||
slug hits Strapi live through the Nitro endpoint.
|
||||
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](./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](../.env.example) (root, drives docker-compose) and
|
||||
[cms/.env.example](../cms/.env.example) (standalone Strapi dev, e.g. `npm run develop` outside
|
||||
Docker).
|
||||
Defined in [.env.example](../.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. |
|
||||
| `POSTGRES_DB/USER/PASSWORD` | database, cms | Postgres credentials. |
|
||||
| `APP_KEYS` | cms | Strapi session/cookie signing keys (comma-separated). |
|
||||
| `API_TOKEN_SALT` | cms | Salt for Strapi API token hashing. |
|
||||
| `ADMIN_JWT_SECRET` | cms | Signs Strapi admin panel JWTs. |
|
||||
| `TRANSFER_TOKEN_SALT` | cms | Salt for Strapi data-transfer tokens. |
|
||||
| `JWT_SECRET` | cms | Signs users-permissions (public API) JWTs. |
|
||||
| `ENCRYPTION_KEY` | cms | Strapi's encrypted-field key. |
|
||||
| `STRAPI_URL` | frontend (server-only) | Internal Docker address of Strapi (`http://cms:1337`); mapped to `NUXT_STRAPI_URL`. Never sent to the browser. |
|
||||
| `NUXT_STRAPI_TOKEN` | frontend (server-only) | Optional Bearer token for Strapi requests; unset by default. |
|
||||
| `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_STRAPI_URL` | frontend, browser | Public-facing Strapi origin for building absolute cover-image URLs; mapped to `NUXT_PUBLIC_STRAPI_URL`. |
|
||||
|
||||
All Strapi secrets have standalone copies in `cms/.env.example` for local development without
|
||||
Docker (defaults to SQLite there, since `DATABASE_CLIENT` is unset).
|
||||
| `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).
|
||||
|
||||
+51
-40
@@ -1,63 +1,74 @@
|
||||
# Content model & admin panel
|
||||
|
||||
## Content types
|
||||
## Collections
|
||||
|
||||
Defined in `cms/src/api/*/content-types/*/schema.json`. Both use plain Strapi factory
|
||||
defaults (`createCoreRouter`/`createCoreController`/`createCoreService`) — no custom controllers,
|
||||
routes, services, or policies exist for either type.
|
||||
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.
|
||||
|
||||
| Type | Fields |
|
||||
| Collection | Fields |
|
||||
|---|---|
|
||||
| **Article** | `title` (string, required, max 160), `slug` (UID generated from title), `content` (richtext — Markdown source), `cover` (single media/image), `category` (many-to-one relation to Category) |
|
||||
| **Category** | `name` (string, required, unique), `slug` (UID from name), `articles` (inverse one-to-many) |
|
||||
| **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 Author (the only author is the admin account), no tags, no separate SEO
|
||||
fields. Don't add these back without a concrete need — see `CLAUDE.md`:
|
||||
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/strapi.ts`).
|
||||
- Publish date is Draft & Publish's `publishedAt`.
|
||||
`frontend/server/utils/pocketbase.ts`).
|
||||
- Publish date is `publishedAt`.
|
||||
- Social preview image is the cover.
|
||||
- The article byline is the admin user's name (`createdBy`, via `populateCreatorFields: true`),
|
||||
not a content field.
|
||||
- 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`.
|
||||
|
||||
Draft & Publish is enabled on Article (`draftAndPublish: true`) and disabled on Category
|
||||
(`draftAndPublish: false`) — categories aren't drafted. Public URLs always use the slug, never the
|
||||
numeric id.
|
||||
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](./frontend.md)).
|
||||
|
||||
## Admin login & permissions
|
||||
|
||||
- The admin panel lives at `/admin`, routed by Caddy straight to the `cms` container
|
||||
([architecture.md](./architecture.md#path-routing-caddy)). It's Strapi's standard
|
||||
email/password admin authentication — no custom auth code in this repo.
|
||||
- The admin panel lives at `/_/` (also reachable via `/admin`, which just redirects there),
|
||||
routed by Caddy straight to the `pocketbase` container
|
||||
([architecture.md](./architecture.md#path-routing-caddy)). 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.
|
||||
- `cms/src/index.ts` (`bootstrap`) runs two idempotent setup steps on every Strapi start:
|
||||
1. **`grantPublicReadAccess`** — grants the `public` role exactly `find`/`findOne` on Article
|
||||
and Category, and nothing else (no create/update/delete, no other content type). This is
|
||||
what lets the Nitro endpoints read published content without a token.
|
||||
2. **`disablePublicSignUp`** — turns off `users-permissions`' public registration
|
||||
(`allow_register: false`), since no front-end user accounts should ever exist.
|
||||
- Any permission beyond `find`/`findOne` for Public needs to be justified explicitly — this is a
|
||||
deliberate least-privilege boundary, not an oversight.
|
||||
- `cms/config/plugins.ts` further restricts the `upload` plugin's allowed MIME types (images,
|
||||
video, audio, PDF, office docs, text/CSV) and explicitly denies executables.
|
||||
- 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 `/admin`.
|
||||
2. Create/edit a Category if needed (name → slug is generated automatically).
|
||||
3. Create/edit an Article: title (→ slug), Markdown content, cover image, category.
|
||||
4. Publish (Draft & Publish). Unpublished drafts are never served by the `find`/`findOne`
|
||||
permissions above — Strapi's default behavior already excludes non-published entries from the
|
||||
public API.
|
||||
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](./architecture.md#request-flow-reading-an-article)).
|
||||
|
||||
## Why Markdown, not a rich-text/WYSIWYG field
|
||||
|
||||
`content` is a plain richtext (Markdown) field. Strapi stores the raw Markdown; conversion to HTML
|
||||
happens once, server-side, in the Nuxt Nitro endpoint (`renderMarkdown()`, using `marked`) — never
|
||||
in Strapi 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 admin, not arbitrary users.
|
||||
`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.
|
||||
|
||||
+37
-25
@@ -4,44 +4,54 @@
|
||||
|
||||
| Route | File | Behavior |
|
||||
|---|---|---|
|
||||
| `/` | `app/pages/index.vue` | Static hero/intro copy plus the single latest article, fetched via `/api/articles` (page 1), shown as a featured block. |
|
||||
| `/` | `app/pages/index.vue` | Static hero/intro copy plus the single latest article, fetched via `/content/articles` (page 1), shown as a featured block. |
|
||||
| `/blog` | `app/pages/blog/index.vue` | Paginated archive (`PAGE_SIZE = 12`), grid of `ArticleCard`, prev/next via `?page=`. |
|
||||
| `/blog/[slug]` | `app/pages/blog/[slug].vue` | Full article: fetches `/api/articles/:slug`, renders the pre-converted `article.html`, SEO meta, canonical URL, Open Graph, JSON-LD `BlogPosting`, breadcrumb to its category. |
|
||||
| `/blog/[slug]` | `app/pages/blog/[slug].vue` | Full article: fetches `/content/articles/:slug`, renders the pre-converted `article.html`, SEO meta, canonical URL, Open Graph, JSON-LD `BlogPosting`, breadcrumb to its category. |
|
||||
| `/category/[slug]` | `app/pages/category/[slug].vue` | Fetches the category by slug, then a paginated, category-filtered article list; 404s if the category doesn't exist. |
|
||||
| `/come-difendersi-dal-corralito` | `app/pages/come-difendersi-dal-corralito.vue` | Fully static marketing page, no Strapi data. |
|
||||
| `/come-difendersi-dal-corralito` | `app/pages/come-difendersi-dal-corralito.vue` | Fully static marketing page, no PocketBase data. |
|
||||
|
||||
All pages are SSR (`useFetch`/`useSeoMeta`); nothing blog-related is client-only-rendered.
|
||||
|
||||
## Server (Nitro) endpoints — the only Strapi client
|
||||
## Server (Nitro) endpoints — the only PocketBase client
|
||||
|
||||
`frontend/server/api/`:
|
||||
`frontend/server/routes/content/` (not `server/api/` — `/api/*` is reserved for PocketBase itself,
|
||||
see [architecture.md](./architecture.md#path-routing-caddy); Nitro maps `server/routes/**` to the
|
||||
matching path with no added prefix, unlike `server/api/**`):
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/articles` | Paginated list (`page` query, `PAGE_SIZE=12`), optional `category` slug filter. Proxies to Strapi with `ARTICLE_SUMMARY_QUERY`. Returns `Paginated<ArticleSummary>`. |
|
||||
| `GET /api/articles/:slug` | One article: fetches from Strapi with `ARTICLE_DETAIL_QUERY` (includes `content` + `createdBy`), converts Markdown to HTML, builds the meta description, derives the author byline. 400 without a slug, 404 if not found. Returns `Article`. |
|
||||
| `GET /api/categories` | All categories (name + slug only), sorted by name. |
|
||||
| `GET /api/categories/:slug` | One category by slug. 400/404 as above. |
|
||||
| `GET /content/articles` | Paginated list (`page` query, `PAGE_SIZE=12`), optional `category` slug filter. Queries PocketBase with `ARTICLE_SUMMARY_FIELDS`. Returns `Paginated<ArticleSummary>`. |
|
||||
| `GET /content/articles/:slug` | One article: queries PocketBase with `ARTICLE_DETAIL_FIELDS` (includes `content` + `authorName`), converts Markdown to HTML, builds the meta description. 400 without a slug, 404 if not found or unpublished. Returns `Article`. |
|
||||
| `GET /content/categories` | All categories (name + slug only), sorted by name. |
|
||||
| `GET /content/categories/:slug` | One category by slug. 400/404 as above. |
|
||||
|
||||
This is the **single point of contact** with Strapi (`CLAUDE.md` rule): pages never call
|
||||
`$fetch` against Strapi directly, and `NUXT_STRAPI_URL` / any Strapi token never reach the client.
|
||||
If you add a view that needs new data, add the endpoint here and type its return in
|
||||
`frontend/shared/types/blog.ts` — don't scatter Strapi calls into components.
|
||||
This is the **single point of contact** with PocketBase (`CLAUDE.md` rule): pages never call
|
||||
`$fetch` against PocketBase directly, and `NUXT_POCKETBASE_URL` never reaches the client. If you
|
||||
add a view that needs new data, add the endpoint here (under `/content/*`) and type its return in
|
||||
`frontend/shared/types/blog.ts` — don't scatter PocketBase calls into components.
|
||||
|
||||
`GET /admin` (`frontend/server/routes/admin.get.ts`) is the one other top-level server route: a
|
||||
redirect to `${runtimeConfig.public.pocketbaseUrl}/_/`, PocketBase's own fixed dashboard path. It
|
||||
lives in Nitro rather than Caddy so it works the same in dev (no Caddy in that stack) and
|
||||
production — see [architecture.md](./architecture.md#path-routing-caddy).
|
||||
|
||||
## Supporting utilities
|
||||
|
||||
- `frontend/server/utils/strapi.ts`
|
||||
- `strapiFetch<T>(path)` — server-only fetch against `runtimeConfig.strapiUrl`, with optional
|
||||
Bearer token; wraps failures as a 502 so internal details never leak to the client.
|
||||
- `frontend/server/utils/pocketbase.ts`
|
||||
- `pbFetch<T>(path)` — server-only fetch against `runtimeConfig.pocketbaseUrl`; wraps failures
|
||||
as a 502 so internal details never leak to the client.
|
||||
- `toMediaImage(collection, id, filename, alt)` — builds a PocketBase file URL
|
||||
(`/api/files/{collection}/{id}/{filename}`) from a record; returns `null` when there's no
|
||||
file.
|
||||
- `renderMarkdown(source)` — `marked.parse()`, no sanitization (trusted, admin-only content).
|
||||
- `summarise(source, maxLength = 155)` — strips Markdown syntax to build a plain-text meta
|
||||
description, word-boundary clipped.
|
||||
- `frontend/server/utils/queries.ts` — Strapi query-string builders kept intentionally minimal
|
||||
(only the fields each page actually renders): `ARTICLE_SUMMARY_QUERY`, `ARTICLE_DETAIL_QUERY`,
|
||||
`PAGE_SIZE`, `pagination()`, `pageParam()`.
|
||||
- `frontend/app/composables/useMediaUrl.ts` — the only composable; turns a Strapi image object
|
||||
into an absolute browser URL by prefixing `runtimeConfig.public.strapiUrl` unless already
|
||||
absolute.
|
||||
- `frontend/server/utils/queries.ts` — PocketBase query-string builders kept intentionally minimal
|
||||
(only the fields each page actually renders): `ARTICLE_SUMMARY_FIELDS`, `ARTICLE_DETAIL_FIELDS`,
|
||||
`PAGE_SIZE`, `pagination()`, `pageParam()`, `quote()` (escapes a value for PocketBase's `filter`
|
||||
DSL).
|
||||
- `frontend/app/composables/useMediaUrl.ts` — the only composable; turns a `MediaImage` into an
|
||||
absolute browser URL by prefixing `runtimeConfig.public.pocketbaseUrl` unless already absolute.
|
||||
- `frontend/shared/utils/site.ts` — site constants (`SITE_NAME`, `SITE_EMAIL`, `SOCIAL_LINKS`).
|
||||
- `frontend/shared/utils/format.ts` — `it-IT` date formatting (`formatDate`, `formatDateTime`,
|
||||
`isoDate`).
|
||||
@@ -49,15 +59,17 @@ If you add a view that needs new data, add the endpoint here and type its return
|
||||
## Types (`frontend/shared/types/blog.ts`)
|
||||
|
||||
```
|
||||
StrapiImage { url, alternativeText, width, height }
|
||||
MediaImage { url, alt }
|
||||
Category { name, slug }
|
||||
ArticleSummary{ title, slug, publishedAt, cover: StrapiImage | null, category: Category | null }
|
||||
ArticleSummary{ title, slug, publishedAt, cover: MediaImage | null, category: Category | null }
|
||||
Article extends ArticleSummary { html, summary, author: string | null }
|
||||
Paginated<T> { items: T[], page, pageCount, total }
|
||||
```
|
||||
|
||||
`Article` is the detail shape (adds rendered HTML, meta summary, byline); `ArticleSummary` is what
|
||||
listing pages use.
|
||||
listing pages use. `MediaImage` carries no width/height — PocketBase file fields don't store
|
||||
dimensions, and the covers already reserve their aspect ratio via CSS (`aspect-ratio`), so no
|
||||
layout shift results.
|
||||
|
||||
## Layout & shared components
|
||||
|
||||
|
||||
Reference in New Issue
Block a user