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.
Connect a site in five minutes
Section titled “Connect a site in five minutes”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:
| Kind | Use it for | Published as |
|---|---|---|
| List | Many entries of the same shape: posts, news, products | An array of entries |
| Object | Exactly one entry: site settings, the home page | A 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()
The JSON you get
Section titled “The JSON you get”Publishing writes one file per content type, plus one file per entry, plus a manifest:
| File | Contains |
|---|---|
/api/index.json | Every content type on the site, so the front end can discover them |
/api/{apiId}.json | A list type’s entries, or an object type’s single entry |
/api/{apiId}/{entryId}.json | One entry, by id |
/api/{apiId}/{slug}.json | The 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 ofnull, so a template that readsref.idnever crashes.
Who can read these files
Section titled “Who can read these files”Anyone with the URL. Published content carries no authentication, and it cannot — that is exactly what keeps your client’s traffic away from us, so a busy month does not change your bill and an outage on our side does not take their site down.
Two consequences worth planning around:
- The site ID is not a secret. Media URLs contain it and end up in the HTML you ship, so
anyone looking at a page can read
/api/index.jsonand from there list every content type and every published entry — including ones your front end never links to. - “Published but not linked yet” is not private. If something must stay unreadable until a date, leave it unpublished and set Publish at rather than publishing early and linking later.
Drafts are unaffected: unpublished entries are never written to these files. The API key protects the read API — drafts and writes — not published content. Do not treat it as access control over what you have already published.
Field types
Section titled “Field types”| Type | JSON | Notes |
|---|---|---|
| Text | "…" | Up to 1,000 characters |
| Text area | "…" | Up to 20,000 characters |
| Rich text | "…" | Markdown, up to 200,000 characters |
| Number | 0 | |
| Boolean | true | |
| 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.
Putting images in the body
Section titled “Putting images in the body”Rich text is Markdown, so an image in the body is written as . Press Insert image below the field and pick one from your media (or upload it right there) — it lands at the cursor. You never copy a URL by hand.
If the image has alt text saved, it is filled in. If it does not, you get  with the cursor back inside the brackets, ready for you to describe it. We deliberately do not fill it with the filename: in a diagram that explains a step, the alt text is part of the writing.
Unlike an image field, what goes into the body is the URL as text. That is why the Markdown you export reads correctly anywhere else. Images used only in the body still count as in use — deleting that media warns you first.
Renaming things safely
Section titled “Renaming things safely”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.
Previewing drafts
Section titled “Previewing drafts”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:
curl -H "X-CMS-API-KEY: YOUR_READ_KEY" \ "https://minionworkspace.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. The key exists because this endpoint can return drafts — it is not what protects published content, which is served as plain files to anyone with the URL. (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.
One site often needs more than one. If your blog and your portfolio are built as different pages, open the content type and set Settings → Preview target to a URL of its own — it overrides the site-wide one for that type only. Content that has no page of its own, such as site settings or a footer, can be set to No preview page instead, so the button shows the read API response rather than following the site URL into a 404.
Handing out a link for review
Section titled “Handing out a link for review”To get sign-off on something before it goes live, use Share link in the entry editor. Pick how long it should last (1, 7 or 30 days) and hand over the URL. Whoever receives it can open the page without a Minion account.
That link opens the draft of that entry only. Other languages of the same entry open too, but the rest of your unpublished content stays hidden. The draft key shown in Settings unlocks every draft on the site, so never hand that one out — use a share link instead.
Individual links cannot be revoked. To invalidate all of them at once, reissue the draft key under Delivery → API keys.
The read API takes a microCMS-compatible subset of query parameters:
| Parameter | Example | |
|---|---|---|
limit / offset | limit=10&offset=20 | Default 10, max 100 |
orders | orders=-publishedAt,title | - for descending |
fields | fields=id,title | Trim the response |
filters | filters=category[equals]news[and]title[contains]sale | equals, not_equals, contains, begins_with, exists, not_exists, joined with [and] / [or], evaluated left to right |
depth | depth=2 | How far to expand references, max 3 |
q | q=keyword | Free text over the entry |
Reads are limited to 300 requests per minute per site, writes to 60.
Rebuilding on publish
Section titled “Rebuilding on publish”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.
Scheduling
Section titled “Scheduling”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.
Generate Open Graph images (experimental)
Section titled “Generate Open Graph images (experimental)”Making an Open Graph image for every article is tedious work. The CMS can render one from the entry’s own content. The supported languages are currently Japanese and English.
- Open the “Open Graph” tab on the site screen
- Press “Add template” — it starts from a working example
- Further down the same tab, under “Where generated images go”, check whether the content type has an image field. If it does not, press “Add an image field”
- Open an entry: a “Generate image” button is now inside that image field. Press it, then save
Step 3 exists because a generated image goes into an ordinary image field. There is no dedicated Open Graph field type — the shape of the delivered JSON is meant to be decided entirely by the schema you define. An image you provide yourself goes into the same field.
Nothing is generated on its own. It runs only when you press “Generate image” on the entry screen — publishing never triggers it. To use an image you made yourself, skip this and pick one in the image field directly. Generated images are ordinary media too, so replacing one later is the same operation.
A generated image is only placed in the field; it is not committed until you save. If you do not like it, leave without saving.
Writing a template
Section titled “Writing a template”The design differs from site to site, so you write the template yourself. Edit it under the “Open Graph” tab on the site screen — the actual rendered result appears next to the editor as you type.
<div style="width:100%;height:100%;display:flex;flex-direction:column;justify-content:space-between;background-color:#0F172A;padding:72px 80px"> <div style="display:flex;width:120px;height:10px;background-color:#5ABFBC;border-radius:999px"></div> <div style="display:flex;font-size:64px;font-weight:700;color:#F1F5F9;line-height:1.25">{{fields.title}}</div> <div style="display:flex;font-size:30px;color:#94A3B8">example.com</div></div>Available variables:
| Variable | What it holds |
|---|---|
{{fields.title}} | An entry value, where title is the field’s API ID. Select fields use the display label; dates become YYYY-MM-DD |
{{slug}} / {{locale}} | The entry’s slug / language |
{{media.<ID>}} | A fixed image such as a logo. The “Insert image” button writes the correct form for you |
Rich text, repeat, content reference, embed URL and file fields come out empty. The card is a single flat image, not a place to pour body text into.
What you can and cannot write
Section titled “What you can and cannot write”The rendering engine supports a narrower slice of CSS than a browser does.
- Flexbox only.
display: gridis unavailable - Any element with more than one child needs
display: flex - Images may only come from this site’s media. External URLs and data URIs are rejected
- WebP and AVIF cannot be decoded by the rendering engine, so they are converted to PNG or JPEG when embedded (the media itself is left untouched)
- Tailwind-style
tw="..."attributes work as well
When a rule is broken, the error appears directly in the preview, so you can fix it as you write.
Separate templates per language
Section titled “Separate templates per language”Text that comes from the entry — the title, for one — is already written per language, so it arrives already translated. The only text that is not translated is what you typed into the template itself (a heading like “Blog”). When you need that to differ, assign a language to the template. A template with no language is used for every language.
After you change the content
Section titled “After you change the content”Editing an entry does not re-render its image. When the content has changed since the image was made, the entry screen says so, and you can regenerate it if you want to. Generation uses the saved content, so save your changes first.
Who can touch templates
Section titled “Who can touch templates”Editing templates requires the editor role. Once integration is finished and members are limited to writers, templates freeze along with content types and served languages. Running the generation itself only needs the writer role.
Hand it to a minion
Section titled “Hand it to a minion”A template is HTML. You do not have to write it — ask a minion to. A minion can write the template, render it to see how it actually came out, and go on to generate the image for each entry and place it in the entry. Your part is to look at the finished card and decide whether it is good.
“Write an Open Graph template for the blog. Dark navy background, large title, site name in the bottom right.”
Before you ask, grant that minion the editor role under the site’s Settings → Access — that is the role template editing needs. Once the template exists, the writer role is enough to keep generating images with it.
A minion placing an image in an entry does not publish it. When it replaces the image on an entry that is already live, publishing is still a separate step, as it is for any other change.
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. Generated images count toward the same storage:
| Plan | Sites | Media storage |
|---|---|---|
| Free | 2 | 1 GB |
| Starter | 5 | 10 GB |
| Team | 20 | 50 GB |
| Business | 100 | 200 GB |
| Enterprise | Unlimited | 1 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.
Who can do what
Section titled “Who can do what”Everyone in the workspace can see a site. Nobody can change it until you say so.
| Role | Can |
|---|---|
| Admin | Everything: publishing, API keys, webhooks, access, deleting the site |
| Can edit | Content types and their schema, plus everything a writer can do |
| Can write | Entries and media — writing, publishing, uploading |
| View only | Read. 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.
Folding away the integration screens
Section titled “Folding away the integration screens”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.
Handing updates to a minion
Section titled “Handing updates to a minion”A headless CMS rarely fails for want of features. It fails when the client never gets around to updating anything. Minions reach this CMS through the same door people do, so “add next month’s three announcements” can go to a minion instead of a person.
Access works the same way it does for people. Settings → Access lists the minions in the workspace alongside everyone else, and they start as view-only. Until you grant something, a minion cannot write anything.
A minion with Can write creates, updates and publishes entries — except that anything a minion creates starts as a draft, and publishing only happens when it is explicitly told to publish. Nothing a minion writes reaches the live site without someone asking for it.
Can edit adds content types and their schema. This is the grant that lets you hand over “set up the content types this build needs” while wiring a site up. When the integration is done, put it back to Can write. The structure freezes there, and the minion keeps doing the day-to-day updates. Deleting a field takes it out of the published JSON on the next publish — not a permission to leave switched on for whoever writes your weekly posts.
Images can be handed over too. With Can write or above, a minion uploads the images an article needs and puts them into the body or an image field itself — which is why “grab a screenshot of this screen and work it into the article” is a single request. Alt text is set at upload time.
Two things a minion cannot do. A minion never becomes an admin — API keys, webhooks, deleting a site and granting access stay with people. And a minion cannot delete images: that removes a file a live site may be using, so a person confirms it first.
Duplicating and handing over a site
Section titled “Duplicating and handing over a site”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.
Exporting everything
Section titled “Exporting everything”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.