コンテンツにスキップ

CMS の組み込み

このページは意図的に 1 ページで完結させてあります。CMS ガイドと重複する 内容も繰り返しているのは、これだけを渡せばフロントを繋げる状態にするためです (人に渡す場合も、コーディング支援の AI に渡す場合も)。

言語モデル向けのプレーンテキスト版は /ja/llms-cms.txt にあります。

公開すると、公開オブジェクトストレージにただの JSON ファイルが書き出されます。制作した サイトはビルド中にそのファイルを読み、好きなように描画します。コンテンツが公開されたら、 Webhook がホスティング側にビルドをやり直させます。

サイトの配信に私たちは一切関与しません。だからアクセスが増えても料金が変わらず、私たちの 障害がクライアントのサイトを止めません。そして以下のすべての判断は、この一点から決まります。

3 つ。すべて Minion のサイトの 配信 タブにあります。

見た目どこから
配信ベース URLhttps://.../配信タブの先頭。推測せず実物をコピーする
サイト IDUUID同じタブと、ダッシュボードの URL /cms/{siteId}
コンテンツ型の API IDnewspostsコンテンツ型に付けた名前。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 のみ、ヘッダ不要、認証なし。

リスト型はページング形式です (読み取り 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
テキスト / テキストエリア"…"
リッチテキスト"…"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 に 並べてください。

テンプレート側で身構える必要はありません。

  • 並び順は適用済みです。 編集画面と同じ順序 (手動の並び替え → 公開日の新しい順) で 出ます。並べ替え直さないでください。
  • 参照は 1 段だけ展開されます。 参照先はオブジェクトとして入ります。未公開や 1 段より 深い場合は null ではなく { "id": "…" } になるので、ref.id を読んでも落ちません。
  • 下書きは入りません。 未公開のエントリはこれらのファイルに一切書き出されません。
  • 一覧に出ているエントリには必ず個別ファイルがあります。 個別ファイルは一覧ファイルより 先に書かれます。
  • メディアの URL は絶対 URLなので、そのまま src に入れられます。
src/pages/news/index.astro
---
const CMS_BASE = process.env.CMS_BASE
const SITE_ID = process.env.CMS_SITE_ID
const 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>
src/pages/news/[slug].astro
---
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.props
const 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 はたいてい本物の環境変数として渡してくるためです。

app/news/page.tsx
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>
)
}
app/news/[slug]/page.tsx
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 は存在しないので、こちらが正しい挙動です。

nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
cmsBase: process.env.CMS_BASE,
cmsSiteId: process.env.CMS_SITE_ID,
},
// 一覧をプリレンダリングし、そこからのリンクを辿って各記事ページを生成する。
nitro: { prerender: { crawlLinks: true, routes: ['/news'] } },
})
pages/news/index.vue
<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 です。

取得に失敗したらビルドを失敗させてください。 上の例がすべて空配列ではなく例外を投げて いるのは意図的です。ビルド中に CMS が一時的に届かなかったとき、例外を握ると記事が全部 消えたサイトがデプロイされます。投げれば前回のデプロイがそのまま残ります。

個別エントリのファイルが無い場合も同じです。空白のページを描かず、投げてください。

静的にビルドしたサイトは、公開に気づきません。ホスティング側でビルドフックを作り (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 が失敗しても公開は巻き戻りません。ビルドが壊れてもコンテンツは正しく公開されて いるので、ビルドを直して回し直せば済みます。

下書きは静的ファイルに出ないので、プレビューだけが私たちに直接問い合わせる唯一の経路です。

Terminal window
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 キーが守るのは下書きと書き込みです。すでに公開したものへのアクセス制御ではありません。

なぜ
ページの描画に読み取り API (/api/public/cms/…) を使うビルド時に静的 JSON を読む私たちの遅延がクライアントの遅延に、私たちの障害がクライアントの障害になる
fetch を try { … } catch { return [] } で囲む投げてビルドを落とす握るとコンテンツの無いサイトがデプロイされる
テンプレートで contents を並べ替える返ってきた順に描く並び順は適用済み。並べ替えると編集者の手動並び替えが黙って無視される
すべての画像に cover.url を使うvariants から srcset を組む原本は 1200px 幅のこともあり、それをスマホにも配ることになる
配信ベース URL を直書きする環境変数から読む配信ドメインは動きうる唯一の値
フィールドのラベルから JSON のキーを推測するフィールドの API ID を使うラベルは自由に変わる。API ID は公開後に固定される
API キーをアクセス制御として扱う公開済みは公開だと扱う静的ファイルに認証は無い
getStaticPaths から個別ファイルを取りに行く一覧のエントリを props で渡す一覧に全フィールドが入っている。個別取得はリクエストが増えるだけ