CMS の組み込み
このページは意図的に 1 ページで完結させてあります。CMS ガイドと重複する 内容も繰り返しているのは、これだけを渡せばフロントを繋げる状態にするためです (人に渡す場合も、コーディング支援の AI に渡す場合も)。
言語モデル向けのプレーンテキスト版は
/ja/llms-cms.txt にあります。
この構成を 3 行で
Section titled “この構成を 3 行で”公開すると、公開オブジェクトストレージにただの JSON ファイルが書き出されます。制作した サイトはビルド中にそのファイルを読み、好きなように描画します。コンテンツが公開されたら、 Webhook がホスティング側にビルドをやり直させます。
サイトの配信に私たちは一切関与しません。だからアクセスが増えても料金が変わらず、私たちの 障害がクライアントのサイトを止めません。そして以下のすべての判断は、この一点から決まります。
始める前に必要な値
Section titled “始める前に必要な値”3 つ。すべて Minion のサイトの 配信 タブにあります。
| 値 | 見た目 | どこから |
|---|---|---|
| 配信ベース URL | https://.../ | 配信タブの先頭。推測せず実物をコピーする |
| サイト ID | UUID | 同じタブと、ダッシュボードの URL /cms/{siteId} |
| コンテンツ型の API ID | news、posts | コンテンツ型に付けた名前。JSON のファイル名そのもの |
ベース URL とサイト ID は環境変数に置いてください。サイトを別のワークスペースへ譲渡しても この 2 つは変わらないので、引き渡しでコードを触る必要がありません。
{base}/{siteId}/api/index.json サイトのコンテンツ型の一覧{base}/{siteId}/api/{apiId}.json リスト型のエントリ一覧、またはオブジェクト型の単体{base}/{siteId}/api/{apiId}/{entryId}.json 個別エントリ (id で引く){base}/{siteId}/api/{apiId}/{slug}.json 同じエントリ (slug で引く)4 つともただのファイルです。GET のみ、ヘッダ不要、認証なし。
レスポンスの形
Section titled “レスポンスの形”リスト型はページング形式です (読み取り API と同じ形なので、同じコードがどちらにも使えます)。
{ "contents": [ /* エントリ */ ], "totalCount": 12, "offset": 0, "limit": 12}オブジェクト型と個別エントリのファイルは、エントリそのものです (包まれません)。
すべてのエントリが共通のシステムキーを持ち、そのあとに定義したフィールドが API ID を キーとして並びます。
{ "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": "こんにちは", "body": "# こんにちは\n…"}slug は未設定なら null です。URL を組むときは id にフォールバックしてください。
フィールド型と JSON の対応
Section titled “フィールド型と JSON の対応”| フィールド型 | JSON |
|---|---|
| テキスト / テキストエリア | "…" |
| リッチテキスト | "…" — Markdown です (HTML ではありません) |
| 数値 | 0 |
| 真偽値 | true |
| 日付 | "2026-01-01" |
| セレクト | "news" — 選択肢のラベルが入ります (内部の値ではありません) |
| 画像 / ファイル | オブジェクト (下記) |
| コンテンツ参照 | { "id": …, "slug": … } — 参照先を 1 段展開したもの |
| 繰り返し | [ { … } ] — オブジェクトの配列 |
| 埋め込み URL | "https://…" — YouTube / Vimeo の URL。動画は預かりません |
複数値にしたフィールドは、同じ形の配列になります。
画像フィールドはファイル全体に展開されます。
{ "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 はアップロード時に生成済みの縮小版です。原本を全端末に配らず、srcset に
並べてください。
保証されていること
Section titled “保証されていること”テンプレート側で身構える必要はありません。
- 並び順は適用済みです。 編集画面と同じ順序 (手動の並び替え → 公開日の新しい順) で 出ます。並べ替え直さないでください。
- 参照は 1 段だけ展開されます。 参照先はオブジェクトとして入ります。未公開や 1 段より
深い場合は
nullではなく{ "id": "…" }になるので、ref.idを読んでも落ちません。 - 下書きは入りません。 未公開のエントリはこれらのファイルに一切書き出されません。
- 一覧に出ているエントリには必ず個別ファイルがあります。 個別ファイルは一覧ファイルより 先に書かれます。
- メディアの URL は絶対 URLなので、そのまま
srcに入れられます。
---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('ja-JP')}</time> </li> ))}</ul>---import { marked } from 'marked'
export async function getStaticPaths() { // getStaticPaths はビルド時に単独で実行されるため、この frontmatter の他の場所で // 宣言した変数を見られない。環境変数はこの中で読み直す。 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()
// 一覧に全フィールドが入っているので、そのまま props で渡す。ここで個別ファイルを // 取りに行くと、記事数ぶんリクエストが増えるだけで得るものが無い。 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>import.meta.env ではなく process.env を使っているのは、前者に出るのは PUBLIC_ 付きの
値と .env ファイルの内容だけで、CI はたいてい本物の環境変数として渡してくるためです。
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 にすると、知らない slug へのリクエストが実行時レンダリングではなく
404 になります。一覧に無い slug は存在しないので、こちらが正しい挙動です。
export default defineNuxtConfig({ runtimeConfig: { cmsBase: process.env.CMS_BASE, cmsSiteId: process.env.CMS_SITE_ID, }, // 一覧をプリレンダリングし、そこからのリンクを辿って各記事ページを生成する。 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>ビルドは nuxi generate です。
エラーの扱い
Section titled “エラーの扱い”取得に失敗したらビルドを失敗させてください。 上の例がすべて空配列ではなく例外を投げて いるのは意図的です。ビルド中に CMS が一時的に届かなかったとき、例外を握ると記事が全部 消えたサイトがデプロイされます。投げれば前回のデプロイがそのまま残ります。
個別エントリのファイルが無い場合も同じです。空白のページを描かず、投げてください。
公開されたら作り直す
Section titled “公開されたら作り直す”静的にビルドしたサイトは、公開に気づきません。ホスティング側でビルドフックを作り (Vercel なら Settings → Git → Deploy Hooks、Netlify なら Site configuration → Build & deploy → Build hooks)、その URL を Minion の 配信 → Webhook に貼ってください。 貼ったらその場で テスト を押すこと。届かない Webhook は、いま気づくのが一番簡単です。
イベントは entry.published / entry.unpublished / entry.deleted /
content_type.updated / site.published。ひとつも選ばなければ全部が届きます。ペイロードは
JSON で x-cms-event ヘッダが付きます。署名を付けるを有効にすると
x-cms-signature (生のボディの HMAC-SHA256) も付きます。
Webhook が失敗しても公開は巻き戻りません。ビルドが壊れてもコンテンツは正しく公開されて いるので、ビルドを直して回し直せば済みます。
下書きをプレビューする
Section titled “下書きをプレビューする”下書きは静的ファイルに出ないので、プレビューだけが私たちに直接問い合わせる唯一の経路です。
curl -H "X-CMS-API-KEY: YOUR_READ_KEY" \ "https://minionworkspace.com/api/public/cms/{siteId}/news?draftKey=YOUR_DRAFT_KEY"どちらのキーも 配信 タブにあります。クエリは microCMS 互換のサブセット
(limit / offset / orders / fields / filters / depth / q) で、読み取りは
サイトあたり毎分 300 リクエストに制限されています。
「設定」の プレビュー URL に自分のサイトのプレビュー用ページを設定しておくと、編集画面の プレビューから生の JSON ではなくそのページが開きます。
プレビューと CI からの更新にだけ使ってください。公開ページの描画には使いません。
公開されたファイルを読めるのは誰か
Section titled “公開されたファイルを読めるのは誰か”URL を知っている人は誰でもです。公開済みのコンテンツに認証はありませんし、付けられません。 訪問者のトラフィックが私たちに来ないのはそのためです。
サイト ID も秘密ではありません。メディアの URL に含まれ、制作したサイトの HTML にそのまま
入ります。ページを見た人は index.json を読めるので、リンクしていない公開済みエントリを
含めて全部列挙できます。「公開したがまだリンクしていない」は非公開ではありません。
そういうコンテンツは未公開のままにして、予約公開を使ってください。
API キーが守るのは下書きと書き込みです。すでに公開したものへのアクセス制御ではありません。
やってはいけないこと
Section titled “やってはいけないこと”| ❌ | ✅ | なぜ |
|---|---|---|
ページの描画に読み取り API (/api/public/cms/…) を使う | ビルド時に静的 JSON を読む | 私たちの遅延がクライアントの遅延に、私たちの障害がクライアントの障害になる |
fetch を try { … } catch { return [] } で囲む | 投げてビルドを落とす | 握るとコンテンツの無いサイトがデプロイされる |
テンプレートで contents を並べ替える | 返ってきた順に描く | 並び順は適用済み。並べ替えると編集者の手動並び替えが黙って無視される |
すべての画像に cover.url を使う | variants から srcset を組む | 原本は 1200px 幅のこともあり、それをスマホにも配ることになる |
| 配信ベース URL を直書きする | 環境変数から読む | 配信ドメインは動きうる唯一の値 |
| フィールドのラベルから JSON のキーを推測する | フィールドの API ID を使う | ラベルは自由に変わる。API ID は公開後に固定される |
| API キーをアクセス制御として扱う | 公開済みは公開だと扱う | 静的ファイルに認証は無い |
getStaticPaths から個別ファイルを取りに行く | 一覧のエントリを props で渡す | 一覧に全フィールドが入っている。個別取得はリクエストが増えるだけ |