A headless CMS with versioned, localised content and a cached delivery API.
492
Quillet is a headless CMS: content is authored per locale, versioned on every
save, and published explicitly, so what a site serves changes only when someone
says so. It has two HTTP surfaces — a management API under /api/v1 for
authoring, keys and publishing, and a cached public read API under /d/v1 that
serves published content with locale fallback and ETag-based conditional
requests.
This repository is the API, its OpenAPI contract and the dashboard
(apps/dashboard). The browser SDK and the CLI are separate.
.nvmrc, so nvm use picks it up.nvm use # Node 26
docker compose up -d # Mongo (a single-node replica set), Redis and MinIO
npm install
cp .env.example .env # then put a real SESSION_SECRET in it
SESSION_SECRET has to be at least 32 characters; openssl rand -base64 48
produces something suitable. It signs the session cookie, so rotating it
invalidates every session at once. npm run dev reads .env for you; the API
itself reads only the process environment, so a deployment passes real variables
in and needs no file:
npm run dev
The API is then on http://localhost:3000:
| Path | What it is |
|---|---|
/ | The dashboard, when apps/dashboard has been built |
/api/v1/* | Management API: auth, projects, keys, content, publish |
/api/v1/about | Which build is running: version and, if known, commit |
/d/v1/:project/:env | Delivery API: published content, cached |
/healthz | Liveness. 200 while the process runs, drain included |
/readyz | Readiness. 503 while draining, or a dependency is down |
/api/docs | Swagger UI, in development only |
Other scripts, all from the repository root:
npm test # every suite (needs `docker compose up -d`)
npm run lint # eslint per package, then a repo-wide prettier check
npm run build # compile every workspace
npm run migrate # build every index, idempotently (see Deployment notes)
npm run openapi # regenerate openapi.json from the running route decorators
npm run format # apply prettier
npm run e2e -w apps/dashboard # the browser suite (see Browser verification)
npm test boots an in-memory Mongo replica set per Jest worker but uses the
real Redis from docker-compose.yml, so the containers have to be up.
The asset store's contract suite is the one exception to "the containers have
to be up": it runs the disk adapter always and the s3 adapter only when
TEST_S3_ENDPOINT names a bucket, logging one line and skipping that half
otherwise, so a checkout without Docker still passes. Prove the S3 adapter
with:
TEST_S3_ENDPOINT=http://localhost:9002 npm test -w apps/api -- store.contract
It writes under a random contract-… prefix of the compose bucket and deletes
it afterwards. Point it at a real bucket and it would do the same there, so
do not.
docker-compose.yml publishes Redis on 6381, not 6379, and MinIO on
9002 (console 9003), not 9000: the machines this was built on already
had unrelated containers on 6379, 6380 and 9000. REDIS_URL and
ASSET_S3_ENDPOINT in .env.example match. Tests take the same defaults and
can be pointed elsewhere with TEST_REDIS_URL and TEST_S3_ENDPOINT.
Two suite runs can share one Redis. Everything Quillet writes there —
sessions, rate-limit counters, the delivery cache, the transform locks — lives
under REDIS_KEY_PREFIX, and the test harness gives each run a namespace of
its own (q:t<random>w<worker>:) rather than a database. Cleanup between suites
deletes that namespace and nothing else, so a second run started beside the
first cannot touch it, whatever database either was pointed at.
This used to be the other way round, and the advice here used to be wrong in a
way worth knowing about if you are reading an older branch: the harness replaced
the database in TEST_REDIS_URL with the Jest worker number and then flushed
it, so "give the other run a different database number" did nothing at all —
worker 1 of both runs landed on database 1 regardless. It never failed as a
visible collision; it failed as spurious 401s and 429s in suites with
nothing to do with either run, because a session had been flushed out from under
a request or a counter reset by the other one.
TEST_REDIS_URL is still honoured in full, database included, if you would
rather keep runs on separate databases or separate servers anyway.
MinIO's images come from quay.io/minio/* rather than Docker Hub, which no
longer serves minio/minio anonymously. The credentials are fixed at
quillet / quillet-local and are not secrets: the contract suite has to run
from a fresh checkout. A one-shot minio-init sidecar creates the
quillet-test bucket, the way mongo-init initialises the replica set.
A walk through the dashboard in a browser, about half an hour from a fresh
checkout. Every address, button and sentence below is the one on screen; each
step is one action, followed by what you should see. Two curl steps use the
key a step above prints once.
docker compose up -d, then npm run dev — the API answers on http://localhost:3000 (curl http://localhost:3000/readyz prints {"status":"ok","mongo":"up","redis":"up"}) and the dashboard on http://localhost:5173./login).Handbook, Slug handbook. Locales is a search over the catalogue rather than a text field: type en-US and press Enter — the chip English (United States) en-US appears and Default locale follows the first pick — then type de-DE and press Enter for German (Germany) (typing aims at the first match, and German alone offers six countries). Environments below it already holds one chip, Prod — type staging into the box beside it and press Add for a second, and leave Default environment on Prod (prod). Press Create project — /p/handbook/content. Across the top is the project header: the mark (a link to All projects), the project switcher reading Handbook, the Environment select on Prod with Staging in its list, Search content (Ctrl/Cmd+K), the theme toggle and a round button with your initials, the Account menu, which holds your name and email, Account, Sign out and, at the bottom, which build of Quillet is running (Quillet 1.1.1 · 0b5b73f: the version and the commit, when the image recorded one). Under it are the section tabs Content, Media, Components, Promote and Settings; the left panel holds the Content tree, and the page is Content, with Start here and a New menu. Below 1024 px the panel folds behind a Show Content button at the start of the tabs and opens as a sheet; below 640 px Search content moves into the account menu, and the project's and environment's names truncate to share the row.landing/hero, Type Structured, press Create document — the editor opens at /p/handbook/content/landing/hero on {}; the tree opens the landing folder (an Up one level link over its one row, hero), and the page bar across the top of the editor holds, left to right, the breadcrumb "landing / hero", a Draft pill whose tooltip reads "Version 1", the locale switcher on en-US, then History, Preview, Save and Publish. Below 1280 px History and Preview show only their icons, with their names as tooltips.{"title": "Hello", "cta": "Go"} and press Save (or Ctrl/Cmd+S) — "Saved." above the editor, and the pill's tooltip reads "Version 2" (version 1 is the empty document the dialog created)."title" — a red sentence above the editor, "Line 2, column 11: a colon was expected after the property name.", a marker under the spot in the text, and Save unavailable. Put the colon back and press Save — "Saved.", and the tooltip still reads "Version 2": reformatting is not a change to the content, so no version is added.guides/intro, Type Markdown, Create document — a Visual editor with a formatting toolbar, and a Source tab beside it. Type # Introduction and press Enter: the # becomes a heading as you type. Press Image to put a picture in from the media library or to upload one into it. Switch to Source to see the Markdown that will be stored — a Preview pane sits beside it (two tabs of the same names on a window 1024 px wide or narrower), and Expand gives either the whole width. Save.notes/plain, Type Plain text — a monospaced text area and nothing else; type a line and Save.landing/hero again and press the locale switcher in the page bar (it reads en-US), then choose de-DE (not translated) — "This document has no de-DE translation yet." over the en-US document, read-only.{} and the panel 2 fields inherited from en-US lists title and cta, each with an Inherited from en-US badge.title in that panel — it leaves the list (1 field inherited from en-US) and "title": "Hello" is in the text; change it to "Hallo" and Save — "Saved."guides/intro in de-DE the same way — the same offer, this time with an Inherited from en-US badge over the read-only body: a Markdown or text document falls back as a whole./p/handbook/components/new on an empty field list under the component's own settings, with the Name box enabled and focused. Type cta and Save — nothing was written until then, and the address is now /p/handbook/components/cta, at version 1.CTA. Add field — Name label, Type Text, tick Required. Add field — Name link, Type Link, tick Required. Preview field label. Save — "Saved.", and the page bar's pill has "Version 2" in its tooltip (version 1 is the empty component the first Save wrote). Publish — Published.hero, Save — version 1 at /p/handbook/components/hero. Title Hero. Add field title (Text, Required), cta (Text), blurb (Rich text), image (Image, Accept image/* then Add type), sku (Text, untick Translatable). Preview field title. Save, Publish — Published, at version 2._schemas/hero (en-US), a From / To pair already reading "From v1 to v2": Settings: title "" → "Hero" · preview none → title, Added title (Text), Added cta (Text), Added blurb (Rich text), Added image (Image), Added sku (Text), with Show JSON under them. Each row has the same disclosure — What changed in v2 — and Restore as new draft is at the foot. Close it.landing/hero (en-US). Under Details the Schema select reads None and lists cta and hero; choose hero and Save — the editor gains a Form / JSON toggle and opens on Form, with a title box, a cta box, a rich text area for blurb, an image field reading Choose an image, and a sku box. The version does not move: a binding is not a change to the content.HB-001 in sku and Save — the pill reads Changed since published, its tooltip "Version 3 · version 2 is published". Publish — Published, at version 3.landing in the breadcrumb, then New → Document — the Key box is pre-filled with landing/; make it landing/_index, Create document — the editor says "Folder settings. Set defaultSchema to a schema name to validate every JSON document in this folder."; type {"title": "Landing", "defaultSchema": "hero"} and Save — at the root of the tree the folder is now called Landing. Back in landing/hero, the Schema select's first option reads Inherit from folder; choose it and Save — it reads Inherited from folder: hero and the form is still there.hero row reads 5 fields, 2 instances and Published. Press the count — the builder opens on its Instances tab, "Every document this component is in force for, and whether it still matches.", with a Draft / Published toggle and both rows of landing/hero — en-US labelled Hello by its preview field, de-DE labelled by its key because its body was written before the component existed — each Valid.landing/hero, press JSON and delete the "title" line — 1 problem under the editor: title "Missing title.", with a marker in the text; press the row and the cursor goes to it. Press Save anyway — the API refuses it and the same sentence stays in the list, with no banner.title box and Save — "Missing title." under the box. Type the title back.page, Save. Title Page. Add field title (Text, Required); Add field hero, Type Reference, Target Hero (the select also offers Any component, and page itself — a reference is a pointer, so it may point at its own kind); Add field sections, Type Block list, Allowed components Hero then CTA, Maximum items 20. Preview field title. Save, Publish — the library lists three components and the page row reads 3 fields, Not used yet and Published.pages/_index (Structured), type {"defaultSchema": "page"}, Save. Up one level, New → Document pages/home — it opens on the Form rather than on {}, because the dialog seeded it from the folder's component: the JSON tab reads {"title": ""}. Type Home in title, press the hero picker and choose landing/hero.title, cta, blurb, image and sku. Type Everything is 20% off and Shop now, write a line in blurb, press Choose an image → Upload, pick a PNG and press Choose, and type SPR-2026 in sku. Press Add block → CTA: label Questions?, then Link to A document in this project and choose landing/hero (the picker lists json documents only — a link's ref has to name one the API can check). Save, Publish — the JSON tab shows each block carrying _component, the image as {"$asset": "…"} and the link as {"ref": "landing/hero"}.landing/hero and switch the locale to de-DE — the form draws title, cta, blurb and image, and where sku would be it says Shared across locales. The panel beside it reads 1 field inherited from en-US and lists cta alone: sku is not untranslated, it is shared. Publish — Published, at version 1.pages/home with hero and sections.1.link under it: the slot the page holds it in, and the block whose link points at it. Hover the hero row in the tree and press its ⋮ (Actions for hero) — Rename (F2) and Delete (Del) are unavailable, each carrying the reason "Used by 1 document"; a rename is a copy and a delete, and the API refuses both with a 409 rather than leave a page pointing at nothing.Website, Kind Delivery, Environment Prod (prod), press Create key — Your new key shows the Secret once, with Website · qk_… under it. Copy both halves, then press I have copied the secret — the key is in the table with a Revoke button and no secret.curl -s -u 'qk_…:qs_…' http://localhost:3000/d/v1/handbook/prod/content/pages/home — content.hero is the hero's body inlined, {"title":"Hello","cta":"Go","sku":"HB-001"}, and _meta.refs[0].key is landing/hero; each section carries _component, the image is inlined with its url and variants and is listed in _meta.assets as sections.0.image, and the CTA block's link.ref is still the plain key landing/hero — a link wants the target's address, so delivery leaves it alone and keeps it out of _meta.refs. Add ?locale=de-DE — content.hero.title is Hallo and _meta.refs[0].resolvedLocale is de-DE, while the page itself reports "fallbackLocale":"en-US" and "fallbacks":["*"]: it has no de-DE version of its own.curl -s -u 'qk_…:qs_…' 'http://localhost:3000/d/v1/handbook/prod/content/landing/hero?locale=de-DE' — the body is {"title":"Hallo","cta":"Go","sku":"HB-001"} and _meta.fallbacks is ["cta"]: sku came from en-US and is not listed, because it is not untranslated, it is shared. Then press Components and open the hero row's ⋮ — Delete is unavailable, reading "Used by 2 instances".landing/hero in two tabs, both on JSON. In the first, change cta and Save. In the second, change title and Save — a red banner, "This document moved on — Someone else saved this document — reload to see their version, then reapply your change.", with Reload inside it.landing/hero; press Discard and reload — the first tab's text, and a notice "Your previous edits were discarded." with Restore them beside it; press that — your text is back, unsaved, and Save now lands.v1 — "Viewing v1 — the current draft is v5" with Back to draft and Restore as new draft; press Restore — refused, "Could not restore this version — title: Missing title.": v1 is the empty document the dialog created, and the folder's schema now requires a title.v2 — "Viewing v2 — the current draft is v5"; press Restore as new draft — back on the draft at v6, whose History entry reads "Restored v2"._schemas among them): ↓ and ↑ move between rows, → opens a folder (focus lands on its first row), ← goes up a level, Enter opens a document (its row stays focused and is marked as the current page), F2 opens Rename this document, Delete opens Delete this document?, and Shift+F10 opens the row's menu — Rename (F2) and Delete (Del); Escape closes each and puts focus back on the row.…/content/landing/hero?locale=fr-FR — "This project has no fr-FR locale." with an Open the en-US version link, and nothing fetched behind it.viewer, Add member — the person is listed with a viewer badge. Signed in as them: Settings says "Only an owner can change this", there is no Keys in the Settings panel, Members says "You can't see the member list", and editing a document then pressing Save answers with a banner above the editor, "Could not save this document — This needs the editor role."fr- — the list narrows to the sixteen French regions (the bare fr also matches a few names that contain those letters, such as "Suid-Afrika" and "Frysk", because the search reads English name, native name and tag) — choose French (France) and press Save changes — "Saved." above the form, and GET /api/v1/projects/handbook now lists fr-FR. Then press the ✕ on the German (Germany) chip and Save changes again — refused under the box, "Remove the 1 document in de-DE first. Among them: landing/hero." (the translation of step 14), with no banner and nothing changed; choose German (Germany) from the list again to put the chip back (no save needed: the refused save changed nothing, so the form is back to what the API holds).staging, Name Staging, Create environment — the row is in the list and Staging is in the header's Environment select. Switch the select to Staging, mint a delivery key on it (Settings → Keys → New key, Environment Staging (staging)), then Settings → Environments and press Delete on the Staging row — Delete staging? names Staging · staging and counts what goes, "Its 0 documents and 1 key go with it. This cannot be undone.", with Delete environment held until it can say so. Press it — the row is gone, the address is back on ?env=prod, the select reads Prod, and in Keys the staging key is Revoked, its Environment column reading Environment deleted — it reached one environment that no longer exists, not every one of them.Everything below is a real transcript against a fresh npm run dev. Each
request is one curl; the only state carried between them is the session
cookie in cookies.txt and the key secrets the API prints once.
1. Register, and sign in. Registration creates the user; login sets the session cookie the management API authenticates with.
curl -s -X POST http://localhost:3000/api/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"correct horse battery staple","name":"Ada Lovelace"}'
# {"id":"6aa1ffdd3469a49719765199","email":"[email protected]","name":"Ada Lovelace"}
curl -s -X POST http://localhost:3000/api/v1/auth/login -c cookies.txt \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"correct horse battery staple"}'
# {"id":"6aa1ffdd3469a49719765199","email":"[email protected]","name":"Ada Lovelace"}
2. Create a project. The caller becomes its owner, and its environments are created with it in the same transaction.
curl -s -X POST http://localhost:3000/api/v1/projects -b cookies.txt \
-H 'Content-Type: application/json' \
-d '{"slug":"handbook","name":"Handbook","defaultLocale":"en-US","locales":["en-US","de-DE"]}'
# {"id":"...","slug":"handbook","name":"Handbook","default
Content type
Image
Digest
sha256:5bcfd1aa3…
Size
88.6 MB
Last updated
1 day ago
docker pull iegork/quillet