CMS integration
This page is deliberately self-contained. It repeats things that also appear in the CMS guide so that it can be handed to someone — or to a coding assistant — as the only thing they need to read to wire a front end to a site.
A plain-text version for language models is at
/llms-cms.txt.
The shape of the integration, in three sentences
Section titled “The shape of the integration, in three sentences”Publishing writes plain JSON files to public object storage. Your site reads those files while it builds, and renders them however you like. When content is published, a webhook tells your host to build again.
Nothing about serving the site involves us. That is what makes traffic free and our outages survivable — and it is also the constraint that decides every question below.
What you need before you start
Section titled “What you need before you start”Three values, all from the site’s Delivery tab in Minion:
| Value | Looks like | Where |
|---|---|---|
| Delivery base URL | https://.../ | Delivery tab, top. Copy the real one — do not guess the host |
| Site ID | a UUID | Same tab, and in the dashboard URL /cms/{siteId} |
| Content type API ID | news, posts | The name you gave the content type. It is the JSON filename |
Keep the base URL and site ID in environment variables. They do not change when a site is transferred to another workspace, so a handover needs no code change.
The URLs
Section titled “The URLs”{base}/{siteId}/api/index.json every content type on the site{base}/{siteId}/api/{apiId}.json a list type's entries, or an object type's single entry{base}/{siteId}/api/{apiId}/{entryId}.json one entry, by id{base}/{siteId}/api/{apiId}/{slug}.json the same entry, by slugAll four are plain files. GET only, no headers, no authentication.
The response shapes
Section titled “The response shapes”A list type is a page-shaped response, so the same code works against the read API:
{ "contents": [ /* entries */ ], "totalCount": 12, "offset": 0, "limit": 12}An object type and a single entry file are the entry itself, not wrapped.
Every entry carries the same system keys, followed by your fields keyed on their API IDs:
{ "id": "1d5f21ef-…", "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…"}slug is null when the entry has none — fall back to id when building a URL.
Field types, and what they look like in JSON
Section titled “Field types, and what they look like in JSON”| Field type | JSON |
|---|---|
| Text, Text area | "…" |
| Rich text | "…" — Markdown, not HTML |
| Number | 0 |
| Boolean | true |
| Date | "2026-01-01" |
| Select | "news" — the option’s label, not an internal value |
| Image / File | an object, below |
| Content reference | { "id": …, "slug": … } — the referenced entry, expanded one level |
| Repeat | [ { … } ] — an array of objects |
| Embed URL | "https://…" — a YouTube or Vimeo URL. Video is not hosted here |
A field marked multiple becomes an array of the same shape.
A media field expands to the whole 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 resized copies generated at upload time. Map them into srcset rather than
serving the original to every device.
What is guaranteed
Section titled “What is guaranteed”You do not need to defend against these in your templates:
- Order is already applied. Entries come out in the order shown in the editor — manual ordering first, then newest published. Do not re-sort.
- References are expanded one level deep. A referenced entry is an object. If it is
unpublished or deeper than one level you get
{ "id": "…" }rather thannull, so readingref.idnever throws. - Drafts are never present. Unpublished entries are not written to these files at all.
- Individual files exist for every listed entry. They are written before the list file,
so an entry that appears in
{apiId}.jsonalways has its own file. - Media URLs are absolute and safe to put straight into
src.
Complete examples
Section titled “Complete examples”---const CMS_BASE = process.env.CMS_BASEconst SITE_ID = process.env.CMS_SITE_IDconst API_ID = 'news'
const res = await fetch(`${CMS_BASE}/${SITE_ID}/api/${API_ID}.json`)if (!res.ok) throw new Error(`CMS fetch failed: ${res.status} ${API_ID}.json`)const { contents } = await res.json()---
<ul> {contents.map((post) => ( <li> <a href={`/news/${post.slug ?? post.id}`}>{post.title}</a> <time>{new Date(post.publishedAt).toLocaleDateString()}</time> </li> ))}</ul>---import { marked } from 'marked'
export async function getStaticPaths() { // getStaticPaths runs on its own at build time and cannot see variables declared // elsewhere in this frontmatter, so read the environment here. const CMS_BASE = process.env.CMS_BASE const SITE_ID = process.env.CMS_SITE_ID
const res = await fetch(`${CMS_BASE}/${SITE_ID}/api/news.json`) if (!res.ok) throw new Error(`CMS fetch failed: ${res.status} news.json`) const { contents } = await res.json()
// The list already contains every field, so pass the entry through as a prop. // Fetching each entry's own file here would add one request per article for nothing. return contents.map((entry) => ({ params: { slug: entry.slug ?? entry.id }, props: { entry }, }))}
const { entry } = Astro.propsconst body = marked.parse(entry.body ?? '', { async: false })const cover = entry.cover ?? null---
<article> <h1>{entry.title}</h1> {cover && ( <img src={cover.url} srcset={cover.variants.map((v) => `${v.url} ${v.width}w`).join(', ')} alt={cover.alt ?? ''} /> )} <div set:html={body} /></article>process.env rather than import.meta.env: only the latter’s PUBLIC_-prefixed values and
.env file entries are exposed, while CI usually supplies these as real environment
variables.
Next.js (App Router)
Section titled “Next.js (App Router)”export const dynamic = 'force-static'
const CMS_BASE = process.env.CMS_BASE!const SITE_ID = process.env.CMS_SITE_ID!
export default async function NewsIndex() { const res = await fetch(`${CMS_BASE}/${SITE_ID}/api/news.json`) if (!res.ok) throw new Error(`CMS fetch failed: ${res.status} news.json`) const { contents } = await res.json()
return ( <ul> {contents.map((post: any) => ( <li key={post.id}> <a href={`/news/${post.slug ?? post.id}`}>{post.title}</a> </li> ))} </ul> )}export const dynamic = 'force-static'export const dynamicParams = false
const CMS_BASE = process.env.CMS_BASE!const SITE_ID = process.env.CMS_SITE_ID!
async function list() { const res = await fetch(`${CMS_BASE}/${SITE_ID}/api/news.json`) if (!res.ok) throw new Error(`CMS fetch failed: ${res.status} news.json`) return res.json()}
export async function generateStaticParams() { const { contents } = await list() return contents.map((entry: any) => ({ slug: entry.slug ?? entry.id }))}
export default async function NewsEntry({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params const res = await fetch(`${CMS_BASE}/${SITE_ID}/api/news/${slug}.json`) if (!res.ok) throw new Error(`CMS fetch failed: ${res.status} news/${slug}.json`) const entry = await res.json()
return <article><h1>{entry.title}</h1></article>}dynamicParams = false makes a request for an unknown slug a 404 instead of a runtime
render — which is what you want, because a slug that is not in the list does not exist.
export default defineNuxtConfig({ runtimeConfig: { cmsBase: process.env.CMS_BASE, cmsSiteId: process.env.CMS_SITE_ID, }, // Prerender the index and follow its links, so every entry page is generated. nitro: { prerender: { crawlLinks: true, routes: ['/news'] } },})<script setup lang="ts">const config = useRuntimeConfig()const { data } = await useAsyncData('news', () => $fetch(`${config.cmsBase}/${config.cmsSiteId}/api/news.json`),)</script>
<template> <ul> <li v-for="post in data.contents" :key="post.id"> <NuxtLink :to="`/news/${post.slug ?? post.id}`">{{ post.title }}</NuxtLink> </li> </ul></template>Build with nuxi generate.
Errors
Section titled “Errors”Let a failed fetch fail the build. Every example above throws rather than returning an empty array, and that is deliberate: if the CMS is briefly unreachable during a build, a caught error deploys a site with all of its articles missing, while a thrown one leaves the previous deployment in place.
The same applies to a missing entry file: throw, do not render a blank page.
Rebuilding when content changes
Section titled “Rebuilding when content changes”A statically built site does not notice a publish. Create a build hook on your host — Vercel: Settings → Git → Deploy Hooks, Netlify: Site configuration → Build & deploy → Build hooks — and paste its URL under Delivery → Webhooks in Minion. Then press Test: a webhook that was never delivered is easiest to notice now, not next week.
Events are entry.published, entry.unpublished, entry.deleted, content_type.updated
and site.published. Subscribing to none means all of them. The payload is JSON with an
x-cms-event header; enable Sign payload to also get x-cms-signature, the
HMAC-SHA256 of the raw body.
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.
Previewing drafts
Section titled “Previewing drafts”Drafts are not in the static files, so previewing them is the one case that talks to us directly:
curl -H "X-CMS-API-KEY: YOUR_READ_KEY" \ "https://minionworkspace.com/api/public/cms/{siteId}/news?draftKey=YOUR_DRAFT_KEY"Both keys are on the Delivery tab. The endpoint takes a microCMS-compatible subset of
query parameters — limit, offset, orders, fields, filters, depth, q — and is
rate limited to 300 reads per minute per site.
Set Preview URL under Settings to a route on your own site that reads this endpoint, and the editor’s Preview button opens your page instead of raw JSON.
Use it for previews and for updates from CI. Do not use it to render published pages.
Who can read the published files
Section titled “Who can read the published files”Anyone with the URL. There is no authentication on published content, and there cannot be — that is what keeps visitor traffic away from us.
The site ID is not a secret either: it is part of every media URL, and those end up in the
HTML you ship. Anyone who sees a page can read index.json and enumerate every content type
and every published entry, including ones you never linked. “Published but not linked yet”
is not private — leave such content unpublished and use Publish at instead.
The API key protects drafts and writes. It is not access control over what is already published.
Do not do this
Section titled “Do not do this”| ❌ | ✅ | Why |
|---|---|---|
Fetch the read API (/api/public/cms/…) to render pages | Fetch the static JSON at build time | Our latency becomes your client’s latency, and our outage becomes their outage |
try { … } catch { return [] } around the fetch | Throw and fail the build | A caught error deploys a site with no content |
Re-sort contents in the template | Render in the order given | Ordering is already applied; re-sorting silently ignores the editor’s manual order |
Use cover.url for every image | Build srcset from variants | The original can be 1200px wide and is sent to phones too |
| Hardcode the delivery base URL | Read it from an environment variable | The delivery domain is the one thing that may move |
| Derive JSON keys from field labels | Use the field’s API ID | Labels are renamed freely; API IDs are fixed after publishing |
| Treat the API key as access control | Treat published content as public | Static files have no authentication |
Fetch each entry’s own file from getStaticPaths | Pass the list entry through as a prop | The list already contains every field; per-entry fetches add requests for nothing |