Skip to content

CMS

CMS lets you hand a website’s content over to the person who owns it. Your client edits here; the site you built keeps rendering the pages.

It is a headless CMS: it has no themes, no templates and no front end of its own. When content is published, Minion writes plain JSON files to public storage. Your site reads those files — usually at build time — and renders them however you like.

That split is the whole design:

  • Visitors never reach us. Your site reads static files, so a spike in traffic does not change your bill, and an outage on our side does not take your client’s site down.
  • Traffic is not metered. There is no per-request pricing, because the requests do not come to us.
  • You can leave. What is published is already a set of plain JSON files. Export gives you those files, the schema and the images in one ZIP.

CMS is experimental and off by default. Ask us to enable it for your workspace if you do not see it in the sidebar.

1. Create a site. One site per project. It is the unit you can later duplicate, hand over, or delete.

2. Create a content type. A content type is one kind of content — blog posts, news, staff members. Pick its shape:

KindUse it forPublished as
ListMany entries of the same shape: posts, news, productsAn array of entries
ObjectExactly one entry: site settings, the home pageA single object

You also give it an API ID (posts, news). It becomes the filename of the published JSON, so it cannot be changed later.

3. Define its fields. Each field has a label and an API ID. The label is what the editor sees and you can rename it whenever you like. The API ID is the key in the published JSON, so it is fixed once the schema is published. The schema builder shows a live preview of the JSON your fields produce.

4. Write an entry and publish it. Saving a draft never changes what is live. Publishing pins that version and writes the files.

5. Turn on publishing for the site. Publishing writes to public storage, so we ask for a payment method on the workspace first. We do not charge you for it — it exists so that free anonymous hosting cannot be used for phishing and malware.

6. Read it from your site. The Delivery tab lists the exact URLs for your content types, with a copy button and a snippet for fetch, Next.js and Astro:

const res = await fetch('https://…/{siteId}/api/posts.json')
const { contents } = await res.json()

Publishing writes one file per content type, plus one file per entry, plus a manifest:

FileContains
/api/index.jsonEvery content type on the site, so the front end can discover them
/api/{apiId}.jsonA list type’s entries, or an object type’s single entry
/api/{apiId}/{entryId}.jsonOne entry, by id
/api/{apiId}/{slug}.jsonThe same entry, by slug

A list file is a page-shaped response, so the same code works against the read API:

{
"contents": [ /* entries */ ],
"totalCount": 12,
"offset": 0,
"limit": 12
}

Every entry carries the same system keys, followed by your fields keyed on their API IDs:

{
"id": "",
"slug": "hello-world",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"publishedAt": "2026-01-01T00:00:00.000Z",
"sortOrder": 0,
"title": "Hello world",
"body": "# Hello\n"
}

Two things are worth knowing before you write your templates:

  • Ordering is already applied. Entries come out in the order shown on the entry list — the manual ordering (the ↑ / ↓ buttons) first, then newest published.
  • References are expanded one level deep. A referenced entry appears as an object. If it is unpublished or deeper than one level, you get { "id": "…" } instead of null, so a template that reads ref.id never crashes.

TypeJSONNotes
Text"…"Up to 1,000 characters
Text area"…"Up to 20,000 characters
Rich text"…"Markdown, up to 200,000 characters
Number0
Booleantrue
Date"2026-01-01"
Select"news"The option’s label, not its internal value
Image / File{ … }A media object, see below
Content reference{ "id": …, "slug": … }Another entry, expanded one level
Repeat[ { … } ]An array of objects. Nests one level, up to 200 rows
Embed URL"https://…"A YouTube or Vimeo URL. We do not host video

Any field marked multiple becomes an array of the same shape.

Media fields expand to the full file:

{
"id": "",
"url": "https://…",
"object_key": "",
"filename": "cover.jpg",
"mime_type": "image/jpeg",
"byte_size": 148213,
"width": 1200,
"height": 630,
"alt": "",
"variants": [{ "width": 800, "format": "webp", "url": "https://…" }]
}

variants are the resized copies generated on upload. Use them directly, or use srcset.

Fields are stored against an immutable internal id, and select options against an immutable value. That means:

  • Renaming a label is always safe. No published site changes.
  • Renaming an API ID is not possible once the schema is published, because it is a key your front end reads.
  • Renaming a select option changes the published JSON, because the label is what gets published. Treat it as an edit to your content.
  • Changing a schema never breaks existing entries. Each entry is interpreted with the schema version it was written against. A field you delete simply stops appearing.

Drafts are never written to the static files — that is what makes “editing cannot break the live site” true. To show unpublished content, read the API instead:

Terminal window
curl -H "X-CMS-API-KEY: YOUR_READ_KEY" \
"https://minion-agent.com/api/public/cms/{siteId}/posts?draftKey=YOUR_DRAFT_KEY"

Create the read key under Delivery → API keys. The draft key is on the same tab and can be regenerated if it leaks. (X-MICROCMS-API-KEY is accepted as well, so a front end written against microCMS needs no change here.)

Set Preview URL under Settings to your site’s preview route, and the editor’s Preview button opens your page instead of raw JSON.

The read API takes a microCMS-compatible subset of query parameters:

ParameterExample
limit / offsetlimit=10&offset=20Default 10, max 100
ordersorders=-publishedAt,title- for descending
fieldsfields=id,titleTrim the response
filtersfilters=category[equals]news[and]title[contains]saleequals, not_equals, contains, begins_with, exists, not_exists, joined with [and] / [or], evaluated left to right
depthdepth=2How far to expand references, max 3
qq=keywordFree text over the entry

Reads are limited to 300 requests per minute per site, writes to 60.

For a statically built site, publishing content is only half of the job — the site has to rebuild. That is what webhooks are for.

Create a build hook on your host (Vercel: Settings → Git → Deploy Hooks; Netlify: Site configuration → Build & deploy → Build hooks), then paste its URL under Delivery → Webhooks. Every publish then triggers a build. Use Test right after adding it: a webhook that was never delivered is easiest to notice now, not next week.

You can subscribe to specific events, or leave the list empty to receive all of them:

entry.published · entry.unpublished · entry.deleted · content_type.updated · site.published

The payload is JSON, delivered with x-cms-event:

{
"event": "entry.published",
"site_id": "",
"content_type": "posts",
"entry_id": "",
"entry_slug": "hello-world",
"occurred_at": "2026-01-01T00:00:00.000Z"
}

Enable Sign payload and we add an x-cms-signature header — the HMAC-SHA256 of the raw body with the secret shown once at creation. Verify it if your endpoint does anything more interesting than triggering a build.

A failed webhook never rolls back a publish. If your build breaks, the content is still correctly published; fix the build and trigger it again.

An entry can carry Publish at and Unpublish at times. A scheduler runs every five minutes, so treat the times as “within five minutes of”. A scheduled publish goes through the same checks as a manual one: if a required field is empty, the schedule stays and reports the failure rather than quietly dropping it, so fixing the entry is enough to make the next tick publish it.

Limits, and what happens when you hit them

Section titled “Limits, and what happens when you hit them”

Sites and media storage are counted per workspace:

PlanSitesMedia storage
Free21 GB
Starter510 GB
Team2050 GB
Business100200 GB
EnterpriseUnlimited1 TB

Going over the quota stops new uploads only. Files that are already published keep being served, and publishing keeps working. We never take a live client site down over a quota.

Everyone in the workspace can see a site. Nobody can change it until you say so.

RoleCan
AdminEverything: publishing, API keys, webhooks, access, deleting the site
Can editContent types and their schema, plus everything a writer can do
Can writeEntries and media — writing, publishing, uploading
View onlyRead. The default for every workspace member

Admin is automatic: workspace owners and admins, plus whoever created the site. The other two are granted per site under Settings → Access. Removing a grant puts that person back to view-only.

Grant Can write to the people who write the content, and Can edit only to the people who should be able to change its structure. Deleting a field takes it out of the published JSON the next time that entry is published, which is not something a writer should be able to do by accident.

Once a site is wired up, the Delivery and Schema tabs stop being useful and start being noise for whoever writes the content. Settings → Integration mode folds them away, leaving only the screens needed for writing. Turn it off before you hand a site over, and turn it back on whenever you need to change the wiring.

It changes what is shown, not what anyone is allowed to do — the roles above are what actually protect the site. Someone with write or view-only access never sees those tabs either way.

Duplicate creates a new site with the same content types, the same schema and the same API IDs — optionally with the entries and images too. The copy always starts unpublished with its entries as drafts, and API keys and webhooks are deliberately not carried over. Use it to try a risky schema change, or to reuse the last project’s structure on the next one.

Transfer moves a site to another workspace: enter their workspace slug, and the site moves once one of their admins accepts. Nothing about the site’s public identity changes — the media URLs, the API paths, the API keys and the webhooks are all bound to the site, not to the workspace. A live site keeps running through the handover, and your client’s CI needs no new environment variables.

Two consequences worth planning around. Per-site access is cleared, because it pointed at members of the old workspace — and since view-only is the default, only the receiving workspace’s admins can edit until they grant access to their own people. And if the site is bigger than the receiving plan’s quota, the transfer still succeeds: existing files keep being served, and only new uploads are blocked until they upgrade.

Export produces a single ZIP with everything: all content including drafts, the schema definitions in a neutral format, and the media.

The published/ folder holds exactly the JSON that was being served. That matters during a migration: you can drop those files onto any static host and the site keeps working while you move.

Media is bundled up to 200 MB. Anything beyond that is listed in the manifest as a URL instead — the cut-off is stated in the README, the manifest and the response headers, never silently applied.