MODULES / CONTENT

Headless CMS

Prov0.1.0

Define content types in an admin UI, write and publish entries with drafts, scheduling, locales and version history, and deliver published content to your pages through a cached JSON API or typed server helpers.

terminalnpx ajolla add cms

What you get

  • Content types built in the admin: text, Markdown, number, yes/no, date, select, references, media, slug and JSON fields
  • Drafts, publish, unpublish, archive and scheduled publishing (no cron needed)
  • Version history with one-click restore, and edits that stay invisible until republished
  • Optional locales per content type, with unique slugs per type and locale
  • Delivery API at /api/ajolla/cms with filters, pagination, ETags and CDN caching
  • Typed getEntries()/getEntry() helpers for React Server Components
  • Signed preview links for drafts, and a Markdown preview in the editor
  • Media library with uploads to Supabase Storage (size and type limited)

Routes it adds

/admin/cms/admin/cms/content/admin/cms/content/[typeKey]/admin/cms/content/[typeKey]/[entryId]/admin/cms/content/[typeKey]/new/admin/cms/media/admin/cms/types/admin/cms/types/[typeId]/admin/cms/types/new

/api/ajolla/cms/entries/[type]/api/ajolla/cms/entries/[type]/[slug]/api/ajolla/cms/preview/api/ajolla/cms/types

Plus 1 Supabase migration with row-level security, and an AGENTS.md your coding agent reads after install.

Installs with

Kernel (added automatically).

Configuration

CMS_PREVIEW_SECRET
Secret (32+ random characters) that signs draft preview links. Falls back to one derived from SUPABASE_SECRET_KEY.
CMS_DELIVERY_TOKEN
Set to make the delivery API private: requests must send Authorization: Bearer <token>. Unset, published content is public.
CMS_PREVIEW_URL
Where preview links open, e.g. https://example.com/blog/{slug}?preview={token} ({type}, {slug}, {locale}, {token} are filled in). Defaults to the JSON preview endpoint.
CMS_CACHE_SECONDS
CDN cache lifetime (s-maxage) for public delivery responses. Default 60.

Events

Other modules (and your own code) can react to these:

cms.entry_publishedcms.entry_unpublishedcms.entry_deleted

Use it from any stack

Ajolla Hosted runs the same code as a multi-tenant API. Create an app in your dashboard, enable Headless CMS, and call /api/m/cms/v1 with your app key.

OpenAPI document — feed it to Ajolla to generate SDKs in 7 languages and an MCP server.

AGENTS.md

Read the instructions installed with this module
markdown# Ajolla Headless CMS

A headless CMS inside your app. Editors define **content types** in the admin, then write **entries** as drafts, publish or schedule them, and restore old versions. Your pages read **published content only**, through a JSON API or typed server helpers.

Permissions:
- `cms.manage`: edit content types, entries and media.
- `cms.publish`: publish, schedule, unpublish and archive.

Until you install Roles & Permissions, admins (`app_metadata.role = "admin"`) hold both.

## What was installed

**Admin** (`/admin/cms`)
- `/admin/cms`: content types with entry counts, and recently edited entries.
- `/admin/cms/types/new`, `/admin/cms/types/[typeId]`: the field builder. Field types are text, rich text (Markdown), number, yes/no, date, select, reference (one or several entries, optionally of one type), media URL, slug and JSON. Each field can be marked as required, and you choose a title field and optional locales. A type can't be deleted while it still has entries.
- `/admin/cms/content/[typeKey]`: entries, with status (draft, published, scheduled, archived) and locale filters, plus title search.
- `/admin/cms/content/[typeKey]/new`, `/admin/cms/content/[typeKey]/[entryId]`: the type-aware editor.
  - Markdown preview.
  - Save draft, publish, or schedule for a date and time.
  - Unpublish, archive, or delete.
  - A signed preview link for the draft.
  - Version history with restore.
- `/admin/cms/media`: the media library. Upload to Supabase Storage, or add a URL that's hosted elsewhere.

**Delivery API** (published entries only; JSON errors are `{ error: { code, message } }`)
- `GET /api/ajolla/cms/types`: content types and their fields.
- `GET /api/ajolla/cms/entries/<type>`: query parameters:
  - `locale`;
  - `order` (`newest`, `oldest`, `title` or `updated`);
  - `search` (matches titles);
  - `fields.<name>=<value>` equality filters (at most 5);
  - `include=1` adds referenced entries in `includes`;
  - `limit` (max 100) and `cursor`.

  Returns `{ data, nextCursor, includes? }`.
- `GET /api/ajolla/cms/entries/<type>/<slug>`: query parameters are `locale`, `fallback=0` (don't fall back to the default locale) and `include=1`. With `?preview=<token>`, it returns that entry's draft instead.
- `GET /api/ajolla/cms/preview?token=<token>`: the draft behind a preview link.
- Public responses send `Cache-Control: public, s-maxage=<CMS_CACHE_SECONDS>, stale-while-revalidate` and an `ETag`. A matching `If-None-Match` gets a `304`. Previews are never cached.

**Server helpers** (`lib/ajolla/cms/delivery.ts`, for Server Components and route handlers)

```tsx
import { getEntries, getEntry } from "@/lib/ajolla/cms/delivery";
import { renderMarkdown } from "@/lib/ajolla/cms/markdown";

type Post = { title: string; body?: string; category?: string };

export default async function BlogPost({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<{ preview?: string }> }) {
  const post = await getEntry<Post>("post", (await params).slug, { preview: (await searchParams).preview });
  if (!post) notFound();
  return <article><h1>{post.fields.title}</h1><div dangerouslySetInnerHTML={{ __html: renderMarkdown(post.fields.body) }} /></article>;
}
// const { data, nextCursor } = await getEntries<Post>("post", { filters: { category: "news" }, limit: 10 });
```

`renderMarkdown` escapes all HTML and allows only http(s), mailto and relative links, so its output is safe to inject.

**Code**
- `lib/ajolla/cms/core.ts`: all logic, framework-free (shared with Ajolla Hosted).
- `fields.ts`: field definitions, and the zod schema built from them (`buildEntrySchema`).
- `markdown.ts`: the renderer.
- `server.ts`: access checks, preview links and events.
- `http.ts`: delivery auth, caching and errors.
- `delivery.ts`: the helpers above.

**Tables** (`ajolla_cms_`)
- `types`: `fields` is JSON; `key` is unique.
- `entries`:
  - `data` is the working copy and `published_data` is the live snapshot;
  - `status` is `draft`, `published` or `archived`, and `publish_at` holds the go-live time;
  - `version` and `published_version` record which saved version is live;
  - `(type_id, locale, slug)` is unique;
  - data is capped at 256 KB.
- `versions`: every saved working copy. The newest 50 are kept per entry.
- `media`: library URLs, plus a `storage_path` for uploads.
- **Storage:** a public bucket, `ajolla-cms`, limited to 10 MB per file and to PNG, JPEG, GIF, WebP and AVIF images, PDF, and MP4 and WebM video. SVG is excluded because it can carry scripts.

**RLS**
- **Editors and publishers:** read types, entries and versions.
- **Editors:** write types, drafts, versions and media.
- **Publishers:** change what is live. A guard trigger enforces the split between the two roles.
- **Deleting a live entry:** needs both permissions.
- **Everyone else, anonymous visitors included:** no access. Delivery reads on the server with the secret key and returns published data only, because the rows also hold drafts.

**Events** (payload `{ entryId, typeId, type, slug, locale, … }`)
- `cms.entry_published`: adds `version`, `publishAt` and `scheduled`. A scheduled publish emits the event when it's scheduled, not when it goes live.
- `cms.entry_unpublished`: adds `reason` (`unpublished` or `archived`).
- `cms.entry_deleted`: adds `wasPublished`.

Search and Workflows can subscribe to these to index content or react to publishing.

**Env** (all optional)
- `CMS_PREVIEW_SECRET`: signs preview links. It defaults to a value derived from `SUPABASE_SECRET_KEY`. Changing it invalidates existing links.
- `CMS_DELIVERY_TOKEN`: makes the delivery API private. Requests must send `Authorization: Bearer <token>`, and responses become `private, no-cache`. The server helpers don't need the token.
- `CMS_PREVIEW_URL`: where the editor's "Preview draft" button goes, e.g. `https://example.com/blog/{slug}?preview={token}`. It defaults to the JSON preview endpoint.
- `CMS_CACHE_SECONDS`: the CDN `s-maxage` for public responses (default 60). Scheduled entries appear within this many seconds of going live.

## Setup
1. Apply the migration: `supabase db push`. It also creates the `ajolla-cms` storage bucket.
2. For uploads larger than 1 MB, raise the Server Actions body limit in `next.config.ts`: `experimental: { serverActions: { bodySizeLimit: "10mb" } }`.
3. Open `/admin/cms`, create a content type, and write an entry.
4. Read content with `getEntries`/`getEntry`, or from any client with the delivery API.
5. Optionally set `CMS_PREVIEW_URL`, so that preview links open your own page, and pass `searchParams.preview` to `getEntry`.

## Customising and integrating
- **Limits:** `MAX_ENTRY_BYTES`, `MAX_VERSIONS`, `MAX_TYPES`, `MAX_PAGE_SIZE`, `PREVIEW_TOKEN_TTL_SECONDS` and `MAX_UPLOAD_BYTES`/`UPLOAD_MIME_TYPES` in `core.ts`. Keep the upload limits in sync with the bucket in the migration.
- **Field types:** `fields.ts`. Add one to `FIELD_TYPES`, give it a case in `fieldValueSchema` and `parseFormValues`, and render it in `components/ajolla/cms/entry-editor.tsx`.
- **Files module:** if you also run Ajolla Files, paste its public file URLs into media fields or the library's "Add by URL". The CMS has no hard dependency on it.
- **Styles:** `components/ajolla/cms/cms.module.css`, plus the kit's `--aj-*` variables.