Claude CodeでContentful CMS統合を実装する実践ガイド
Claude CodeでContentful CMSをNext.js/Astroへ統合する手順を、API・型・プレビュー・ISRまで実装例で解説。
Claude CodeでContentful統合を作る前に
Contentfulは、記事やLP、商品説明、FAQなどをコードから切り離して管理できるヘッドレスCMSです。ヘッドレスCMSとは、管理画面と表示画面が一体になったCMSではなく、APIでコンテンツだけを配信する仕組みのことです。Next.jsやAstroの画面は自分で作り、編集者はContentfulの管理画面で本文や画像を更新します。
ただし、Contentful統合は「SDKを入れて記事を取る」だけでは終わりません。Content Delivery API、Content Preview API、Content Management API、locale、環境、権限、プレビュー、ISR、型定義、Webhookが絡みます。ここを曖昧にしたままClaude Codeへ「Contentfulをつないで」と頼むと、公開記事しか見えない、下書きプレビューができない、日本語だけ404になる、再生成されず古い記事が残る、といった実務で痛い失敗になります。
この記事では、Claude Codeを単なるコード生成役ではなく、要件を整理し、実装し、レビューするエージェントの足場として使います。題材はブログCMSですが、構成はドキュメントサイト、採用サイト、プロダクトLPにも流用できます。関連する全体像はClaude CodeブログCMS構築、API境界の設計はClaude Code API開発、公開後の収益導線はコンテンツファネル監査も合わせて確認してください。
まず使い分けるAPIを決める
ContentfulのAPIを混ぜると、権限事故とキャッシュ事故が起きます。Claude Codeに渡す前に、どのAPIをどこで使うかを表にしておきます。
| API | 用途 | 使うトークン | 使ってよい場所 |
|---|---|---|---|
| Content Delivery API | 公開済みコンテンツの取得 | Delivery token | サーバー側のSSG/ISR、Astro build、Next.js Server Component |
| Content Preview API | 下書きや未公開変更の確認 | Preview token | プレビュー用のサーバー処理だけ |
| Content Management API | content type作成、entry更新、publish | Management tokenまたはPAT | ローカルの移行スクリプト、CIの管理ジョブ |
| Images API | 画像のリサイズ、format変換 | Delivery/Previewと同じ文脈 | 画像URL生成、OGP、サムネイル |
公式ドキュメントでは、Contentful JavaScript SDKはDelivery APIとPreview APIに使え、Preview APIではhost: "preview.contentful.com"とpreview access tokenを指定します。CMAのPersonal Access Tokenはユーザー権限に紐づくため、パスワードに近い扱いで環境変数に置くべきです。参考リンクはContentful JavaScript SDK、ContentfulのPAT解説、AstroのContentful連携ガイドです。
Claude Codeへの最初の指示は、次のように境界を明確にします。
このリポジトリにContentfulブログ統合を追加してください。
- 公開取得はContent Delivery APIだけを使う
- 下書き確認はContent Preview APIだけを使う
- Content Management API tokenはscripts配下のモデル作成に限定する
- localeはja-JPを既定、en-USも取得できるようにする
- Next.jsはApp RouterのdraftModeとrevalidatePath、AstroはSSGを想定する
- 既存の未コミット変更には触れず、追加/変更ファイルを最後に一覧化する
ユースケースを3つ以上に分ける
同じContentful統合でも、用途によって設計は変わります。Claude Codeに「ブログCMS」とだけ言うより、ユースケースごとに必要なフィールド、公開タイミング、キャッシュを指定したほうが出力が安定します。
| ユースケース | 必要なContentfulフィールド | キャッシュ方針 | 収益導線 |
|---|---|---|---|
| 技術ブログ | title、slug、excerpt、body、heroImage、author、tags、publishedAt | SSG + Webhookで再検証 | 無料PDF、関連記事、研修相談 |
| プロダクトLP | headline、sections、pricingCopy、faq、ctaLabel | ISR短め、公開直後は手動revalidate | 購入ボタン、Gumroad、問い合わせ |
| ドキュメントサイト | category、version、body、relatedDocs、updatedAt | SSG中心、検索indexも同期 | 有料テンプレート、サポート契約 |
| 多言語ニュース | locale別title/body、canonicalSlug、region | locale別のビルドとプレビュー | 地域別CTA、現地語の問い合わせ |
Masaが実際に試したとき、最初に失敗したのはLPとブログを同じcontent typeにしたことでした。LPにはセクション単位のCTAや価格表が必要なのに、ブログ用のbodyだけで押し切ると、編集者がRich Text内にボタン文言を直接埋め込み始めます。後から計測イベントやA/Bテストを入れるときに、CTAの場所が構造化されておらず、Claude Codeに修正させても検証しづらくなりました。
環境変数とトークンを分ける
最初に.env.exampleを作り、Claude Codeに「どの変数がどのAPI向けか」を理解させます。公開用、プレビュー用、管理用、再検証用のsecretを分けるのが重要です。
# .env.example
CONTENTFUL_SPACE_ID=your_space_id
CONTENTFUL_ENVIRONMENT=master
CONTENTFUL_DEFAULT_LOCALE=ja-JP
CONTENTFUL_DELIVERY_TOKEN=delivery_token_for_published_content
CONTENTFUL_PREVIEW_TOKEN=preview_token_for_drafts
CONTENTFUL_MANAGEMENT_TOKEN=management_token_for_local_scripts
CONTENTFUL_PREVIEW_SECRET=random_secret_for_next_draft_mode
CONTENTFUL_REVALIDATE_SECRET=random_secret_for_webhooks
NEXT_PUBLIC_SITE_URL=http://localhost:3000
ここでやりがちな失敗は、NEXT_PUBLIC_を付けた変数にPreview tokenやManagement tokenを入れることです。NEXT_PUBLIC_はブラウザに露出します。ContentfulのDelivery tokenも読み取り専用とはいえ、基本はサーバー側の取得で閉じたほうが安全です。特にPreview tokenは未公開記事を読めるため、クライアントに出してはいけません。
もう1つの落とし穴は、環境名です。Contentfulの既定環境は多くのプロジェクトでmasterですが、本番運用ではproductionやenvironment aliasを使うことがあります。権限が「masterだけ」に限定されたユーザーやPATで、productionを読みに行くと403になります。Contentfulの環境権限は細かく設定できるため、Contentfulのenvironment permissionsを確認し、CI用トークンが対象環境を読めるかを先に確認してください。
CMAで空のSpaceにcontent modelを作る
Content Management APIは、Contentful内のcontent typeやentryを作成・更新・publishする管理APIです。記事の取得には使いません。空の検証Spaceを用意し、ローカルで次のスクリプトを実行すると、ブログ用の最小モデルを作れます。
// scripts/setup-contentful-model.ts
import "dotenv/config";
import * as contentfulManagement from "contentful-management";
function must(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
const client = contentfulManagement.createClient({
accessToken: must("CONTENTFUL_MANAGEMENT_TOKEN"),
});
async function main() {
const space = await client.getSpace(must("CONTENTFUL_SPACE_ID"));
const environment = await space.getEnvironment(
process.env.CONTENTFUL_ENVIRONMENT ?? "master",
);
const author = await environment.createContentTypeWithId("author", {
name: "Author",
displayField: "name",
fields: [
{ id: "name", name: "Name", type: "Symbol", required: true },
{ id: "slug", name: "Slug", type: "Symbol", required: true },
{ id: "bio", name: "Bio", type: "Text", localized: true },
{ id: "avatar", name: "Avatar", type: "Link", linkType: "Asset" },
],
});
await author.publish();
const blogPost = await environment.createContentTypeWithId("blogPost", {
name: "Blog Post",
displayField: "title",
fields: [
{ id: "title", name: "Title", type: "Symbol", required: true, localized: true },
{ id: "slug", name: "Slug", type: "Symbol", required: true },
{ id: "excerpt", name: "Excerpt", type: "Text", localized: true },
{ id: "body", name: "Body", type: "RichText", required: true, localized: true },
{ id: "heroImage", name: "Hero image", type: "Link", linkType: "Asset" },
{
id: "author",
name: "Author",
type: "Link",
linkType: "Entry",
validations: [{ linkContentType: ["author"] }],
},
{ id: "tags", name: "Tags", type: "Array", items: { type: "Symbol" } },
{ id: "publishedAt", name: "Published at", type: "Date", required: true },
],
});
await blogPost.publish();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
このコードは空のSpace向けです。既存のcontent typeがある環境で実行するとID重複で失敗します。本番では、既存モデルを取得して差分を確認し、破壊的な変更はmigrationとして扱います。Claude Codeに頼むときも「既存モデルを消さない」「field IDを変えない」「削除前にdiffを出す」と指示してください。Contentfulではfield名を変えてもAPI identifierは残せますが、field IDを変えると既存コードとGraphQL schemaが壊れます。
Delivery/Preview共通の型付きクライアントを作る
次はNext.jsでもAstroでも使える取得層を作ります。TypeScriptのEntryFieldTypesを使うと、Contentfulのentry skeletonをコード上で表現できます。型生成ツールを入れる前でも、手書きの型から始めるとClaude Codeの修正範囲が小さくなります。
// src/lib/contentful.ts
import { createClient, type EntryFieldTypes, type EntrySkeletonType } from "contentful";
type BlogPostFields = {
title: EntryFieldTypes.Symbol;
slug: EntryFieldTypes.Symbol;
excerpt: EntryFieldTypes.Text;
body: EntryFieldTypes.RichText;
heroImage: EntryFieldTypes.AssetLink;
tags: EntryFieldTypes.Array<EntryFieldTypes.Symbol>;
publishedAt: EntryFieldTypes.Date;
};
export type BlogPostSkeleton = EntrySkeletonType<BlogPostFields, "blogPost">;
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
export function getContentfulClient(options: { preview?: boolean } = {}) {
const preview = options.preview ?? false;
return createClient({
space: required("CONTENTFUL_SPACE_ID"),
environment: process.env.CONTENTFUL_ENVIRONMENT ?? "master",
accessToken: preview
? required("CONTENTFUL_PREVIEW_TOKEN")
: required("CONTENTFUL_DELIVERY_TOKEN"),
host: preview ? "preview.contentful.com" : "cdn.contentful.com",
});
}
export async function getBlogPosts(options: { locale?: string; preview?: boolean } = {}) {
const locale = options.locale ?? process.env.CONTENTFUL_DEFAULT_LOCALE ?? "ja-JP";
const response = await getContentfulClient(options).getEntries<BlogPostSkeleton>({
content_type: "blogPost",
order: ["-fields.publishedAt"],
include: 2,
locale,
});
return response.items;
}
export async function getBlogPostBySlug(
slug: string,
options: { locale?: string; preview?: boolean } = {},
) {
const locale = options.locale ?? process.env.CONTENTFUL_DEFAULT_LOCALE ?? "ja-JP";
const response = await getContentfulClient(options).getEntries<BlogPostSkeleton>({
content_type: "blogPost",
"fields.slug": slug,
limit: 1,
include: 2,
locale,
});
return response.items[0] ?? null;
}
この例ではimport.meta.envも見ていますが、Next.jsだけで使うならprocess.envだけで十分です。AstroとNext.jsの両方に同じ関数を共有する場合は、ビルドツールごとの差を吸収する場所を1つにします。Claude Codeに別々のファイルで同じSDK初期化を書かせると、片方だけpreview hostを忘れるようなズレが出ます。
Next.jsでSSG、プレビュー、再検証をつなぐ
Next.jsでは、公開ページは静的生成、下書き確認はDraft Mode、ContentfulのWebhookはrevalidatePathで再検証、という分け方が扱いやすいです。Next.js 15以降ではdraftModeがasync関数なので、古い同期コードをそのまま使わない点に注意してください。公式の説明はNext.js draftModeとNext.js revalidatePathを確認します。
// app/blog/[slug]/page.tsx
import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import { documentToReactComponents } from "@contentful/rich-text-react-renderer";
import { getBlogPostBySlug, getBlogPosts } from "@/lib/contentful";
export async function generateStaticParams() {
const posts = await getBlogPosts({ locale: "ja-JP" });
return posts.map((post) => ({ slug: post.fields.slug }));
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const { isEnabled } = await draftMode();
const post = await getBlogPostBySlug(slug, {
locale: "ja-JP",
preview: isEnabled,
});
if (!post) notFound();
return (
<article>
<p>{isEnabled ? "Preview" : "Published"}</p>
<h1>{post.fields.title}</h1>
{post.fields.excerpt && <p>{post.fields.excerpt}</p>}
<div>{documentToReactComponents(post.fields.body)}</div>
</article>
);
}
// app/api/draft/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET(request: Request) {
const url = new URL(request.url);
const secret = url.searchParams.get("secret");
const slug = url.searchParams.get("slug");
if (secret !== process.env.CONTENTFUL_PREVIEW_SECRET) {
return new Response("Invalid preview secret", { status: 401 });
}
if (!slug) {
return new Response("Missing slug", { status: 400 });
}
const draft = await draftMode();
draft.enable();
redirect(`/blog/${slug}`);
}
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import type { NextRequest } from "next/server";
type ContentfulWebhookPayload = {
fields?: {
slug?: Record<string, string>;
};
};
export async function POST(request: NextRequest) {
const secret = request.headers.get("x-contentful-webhook-secret");
if (secret !== process.env.CONTENTFUL_REVALIDATE_SECRET) {
return Response.json({ revalidated: false }, { status: 401 });
}
const payload = (await request.json()) as ContentfulWebhookPayload;
const slug = payload.fields?.slug?.["ja-JP"] ?? payload.fields?.slug?.["en-US"];
revalidatePath("/blog");
if (slug) revalidatePath(`/blog/${slug}`);
return Response.json({ revalidated: true, slug: slug ?? null });
}
失敗しやすいのは、ContentfulのWebhookを「保存時」に発火させるか「公開時」に発火させるかです。公開ページの再検証はpublish/unpublishで十分です。保存のたびにrevalidateすると、下書き中の変更で本番キャッシュを何度も消すことになります。プレビューはDraft ModeとPreview tokenで見る、本番はDelivery tokenと公開Webhookで更新する、と分けてください。
AstroでSSG取得する
AstroはContentfulと相性が良く、ビルド時にentryを取得して静的HTMLにできます。公式のAstroガイドもcontentful SDKとEntryFieldTypesを使う構成を紹介しています。Astroで重要なのは、プレビューよりも「build時にどのlocaleとslugを出すか」を明確にすることです。
---
// src/pages/blog/[slug].astro
import { documentToHtmlString } from "@contentful/rich-text-html-renderer";
import { getBlogPostBySlug, getBlogPosts } from "../../lib/contentful";
export async function getStaticPaths() {
const posts = await getBlogPosts({ locale: "ja-JP" });
return posts.map((post) => ({
params: { slug: post.fields.slug },
props: { slug: post.fields.slug },
}));
}
const { slug } = Astro.props;
const post = await getBlogPostBySlug(slug, { locale: "ja-JP" });
if (!post) return Astro.redirect("/404");
---
<article>
<h1>{post.fields.title}</h1>
{post.fields.excerpt && <p>{post.fields.excerpt}</p>}
<div set:html={documentToHtmlString(post.fields.body)} />
</article>
AstroのSSGでは、ビルド後にContentfulを更新してもHTMLは変わりません。Netlify、Vercel、Cloudflare PagesなどでContentful Webhookから再ビルドするか、Next.jsのようなISRが必要なページだけNext.js側へ寄せるかを決めます。記事メディアならAstroで高速に作り、公開頻度が高いLPだけISRにする分割も現実的です。
locale、preview token、権限で詰まりやすい失敗例
Contentful統合の不具合は、コードの構文よりも設定のズレから起きます。Claude Codeにレビューさせるときは、次の失敗例をそのままチェックリストに入れてください。
| 失敗例 | 症状 | 原因 | 修正 |
|---|---|---|---|
| Preview tokenをDelivery API hostに投げる | 401、下書きが見えない | hostがcdn.contentful.comのまま | preview時はpreview.contentful.comに切り替える |
| Delivery tokenで下書きを読む | 記事がnullになる | Delivery APIは公開済みentryだけ返す | Draft ModeではPreview APIを使う |
| locale指定を忘れる | 英語だけ出る、日本語が空 | default localeとコードの想定が違う | CONTENTFUL_DEFAULT_LOCALEを置き、URLと言語を対応させる |
locale=*を雑に使う | 型が複雑になり表示で落ちる | 全locale取得はfield形状が変わる | ページ生成では必要localeだけ取得する |
| CMA tokenをブラウザに出す | 重大な漏えい | NEXT_PUBLIC_に入れた | 管理tokenはscripts/CIだけに限定する |
| 画像assetをpublishし忘れる | 本文は出るが画像が404/空 | entryだけpublishした | asset処理とpublishを確認する |
| Webhookが保存時に走る | 本番cacheが頻繁に消える | event条件が広すぎる | publish/unpublishに絞る |
特にlocaleは初心者がつまずきます。Contentfulのfieldがlocalizedでない場合、localeを変えても同じ値が返ります。一方でlocalized fieldに日本語値が入っていなければ、fallback設定に依存します。「日本語記事を作ったつもりなのに英語が出る」場合は、コードより先にContentful側のlocale設定、fieldのlocalized設定、entry内のlocale値を確認します。
型生成とClaude Codeレビューの運用
content modelが増えるほど、手書きの型はズレます。最初はEntryFieldTypesで十分ですが、プロダクト運用ではContentfulのモデルから型を生成し、CIで差分を検知するのが安全です。Claude Codeには「型生成後の差分を見て、表示側の型エラーを直す」「content type IDとfield IDを勝手に変えない」と指示します。
実務で効いたプロンプトは次の形です。
Contentfulのcontent model変更をレビューしてください。
観点:
1. 既存のfield IDが変わっていないか
2. localized fieldの追加で既存localeが空にならないか
3. Next.jsとAstroの取得関数に型エラーが出ないか
4. Preview APIとDelivery APIのtoken/hostが混ざっていないか
5. Webhook再検証の対象pathがslugとlocaleを含んでいるか
出力:
- 問題点
- 変更してよいファイル
- 実行した検証コマンド
Claude Codeは、モデル、コード、テスト、ドキュメントを同じ文脈で見られるのが強みです。ただし、Contentful管理画面の状態はリポジトリだけでは見えません。モデルID、locale、API key名、Webhook event、Preview URLは、スクリーンショットや設定メモとして渡すとレビュー精度が上がります。
マネタイズCTAまで設計する
CMS統合の記事は、実装だけで終わると収益につながりにくいです。読者は「Contentfulを入れたい」だけでなく、「編集者が自走できる運用にしたい」「公開後に問い合わせや購入につなげたい」と考えています。そのため、Contentfulの構造化フィールドにCTAも含めます。
たとえばブログ記事にはrelatedPosts、ctaKind、ctaLabel、ctaUrlを持たせます。技術記事なら無料チートシートへ、チーム導入ならClaude Code研修・導入相談へ、テンプレートを探す読者なら教材一覧へ自然につなげます。Contentful側にCTAを持たせると、開発者がコードを触らなくても訴求を変えられます。
ただし、CTAをRich Text内に自由入力させると、計測IDやデザインが揺れます。収益導線は構造化したfieldにし、本文中の自然な内部リンクとは分けてください。Claude Codeには、CTA component、analytics event、Contentful fieldの対応表まで作らせると、AdSense向けの品質改善と有料導線の改善を同時に回しやすくなります。
公開前チェックリスト
最後に、公開前のチェックを短く固定します。
CONTENTFUL_DELIVERY_TOKENとCONTENTFUL_PREVIEW_TOKENが別の値になっている- preview時だけ
preview.contentful.comへ接続している - CMA tokenが
NEXT_PUBLIC_に入っていない ja-JPとen-USなど、対象localeのentry値が入っている- assetもpublish済み
- Next.jsのDraft Mode URLにsecretがある
- Contentful Webhookはpublish/unpublishだけで本番再検証する
- AstroのSSGはWebhook再ビルドが設定されている
- 公式ドキュメントへの外部リンクと、関連する内部リンクが記事内にある
- CTAが無料PDF、教材、相談のどれにつながるか明確になっている
この記事で紹介した内容を実際に試した結果、Claude Codeに最初から「Delivery、Preview、Managementを混ぜない」と指示したときが一番安定しました。逆に、トークン名を曖昧にしたまま実装させると、Preview tokenを公開取得に使ったり、localeを固定値で埋めたりする差分が出ました。Contentful統合は、コード量よりも境界設計が品質を決めます。小さなブログモデルで検証し、プレビューと再検証が動くところまで確認してから、LP、ドキュメント、多言語へ広げるのが現実的です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。