Next.jsでコンテンツを取得して表示する
このチュートリアルでは、Craft Cross CMSとNext.js(App Router)を使って、コンテンツの一覧ページと詳細ページを作る手順を説明します。ページはビルド時にプリレンダリング(静的レンダリング)する構成です。
このチュートリアルで作るもの
- 一覧ページ: 公開済みのマガジン記事を一覧で表示し、各記事の詳細ページへリンクします
- 詳細ページ: スラッグごとに、記事のタイトルと本文を表示します
前提
Craft Cross CMS側の準備
クイックスタートに従って、次の準備を済ませてください。
- マガジン記事用のモデルを作成する
- 記事を何件か作成し、公開する
- CDN APIのアクセストークンを発行する(スコープに
beta.cdn.cms.content.listを設定する)
このチュートリアルでは、マガジンのモデルに次のフィールドを定義している前提で進めます。フィールドIDを合わせておいてください。
| フィールド名 | フィールドID | フィールドタイプ |
|---|---|---|
| タイトル | title | テキスト |
| スラッグ | slug | テキスト(スラッグ) |
| 本文 | body | リッチテキスト |
あわせて、次の3つの値を控えておきます。確認方法はクイックスタートを参照してください。
- サブドメイン:
[すべてのメニュー]>[Craft]>[CMS設定]>[APIエンドポイント]で確認できます - モデルID: コンテンツの詳細画面で確認できます
- アクセストークン: 発行時に控えた値を使います
記事内で使用している主なソフトウェアのバージョン
- Next.js(
next): 16.2.9
1. Next.jsプロジェクトを作成する
create-next-app でプロジェクトを作成します。TypeScriptを有効、App Routerを有効にし、src/ ディレクトリは使わない構成を選びます。
pnpm create next-appコマンドを入力すると、次の質問を聞かれるので、お好きな設定を選びましょう。
- プロジェクト名を何にするか(ここでは
cross-coffee-magazineとしました) - Next.jsの推奨デフォルト設定を使用するか?(ここでは
No, customize settingsを選択) - TypeScriptを利用するか(ここでは
Yesを選択) - どのリンターを利用するか(ここでは
ESLintを選択) - React Compilerを利用するか(ここでは
Yesを選択) - Tailwind CSSを利用するか(ここでは
Noを選択) - ソースコードをsrc/ ディレクトリに配置するか(ここでは
Noを選択) - App Routerを使用するか(ここでは
Yesを選択) - インポートエイリアスをカスタマイズするか(ここでは
Noを選択) - AIコーディングエージェントが最新のNext.jsコードを書けるようAGENTS.mdを含めるか(ここでは
Yesを選択)
pnpmを利用した場合、次のように表示されます。
$ pnpm create next-app✔ What is your project named? … cross-coffee-magazine✔ Would you like to use the recommended Next.js defaults? › No, customize settings✔ Would you like to use TypeScript? … Yes✔ Which linter would you like to use? › ESLint✔ Would you like to use React Compiler? … Yes✔ Would you like to use Tailwind CSS? … No✔ Would you like your code inside a `src/` directory? … No✔ Would you like to use App Router? (recommended) … Yes✔ Would you like to customize the import alias (`@/*` by default)? … No✔ Would you like to include AGENTS.md to guide coding agents to write up-to-date Next.js code? … Yes
# 依存関係のインストールが実行されます(出力は省略)
Success! Created cross-coffee-magazine at /Users/foo/bar/cross-coffee-magazine作成したディレクトリに移動します。
cd cross-coffee-magazine$ pnpm devhttp://localhost:3000 にアクセスして、Next.jsの初期画面が表示されることを確認します。
2. 環境変数を設定する
アクセストークンは第三者に渡ると非公開のコンテンツを取得される可能性があるため、サーバー側だけで扱います。プロジェクトのルートに .env.local を作成し、控えておいた値を設定します。
CRAFT_SUBDOMAIN=xxxxxxxxCRAFT_MODEL_ID=yyyyyyyyyyyyCRAFT_CDN_API_TOKEN=your-access-token.env.local は、create-next-app が生成する .gitignore に .env* として記載されているため、Gitの管理対象から外れます。これにより、アクセストークンがリポジトリに含まれることはありません。
3. APIクライアントを作成する
Craft Cross CMSには専用のクライアントライブラリはないため、CDN APIを fetch で呼び出してコンテンツを取得します。アクセストークンをクライアントに渡さないよう、この処理はサーバー側だけで実行します。
CDN APIを呼び出す処理をまとめたモジュールを作成します。まず、必要なパッケージをインストールします。
server-only: モジュールがサーバー側だけで読み込まれることを保証しますqs:filter[slug][$eq]のようなブラケット記法のクエリ文字列を、オブジェクトから組み立てます
pnpm add server-only qspnpm add -D @types/qslib/craft.ts を作成し、コンテンツを取得する関数を定義します。
import "server-only";import qs from "qs";
// プロジェクト固有のサブドメイン(プロジェクトで共通)const subdomain = process.env.CRAFT_SUBDOMAIN!;
type ListResponse<T> = { skip: number; limit: number; total: number; items: T[];};
// CDN APIのクライアントを作成するconst createClient = ({ token }: { token: string }) => { const baseUrl = `https://${subdomain}.cdn-api.karte.io/beta/cms/content`;
return { // 任意のモデルのコンテンツ一覧を取得する汎用メソッド async getContents<T>( modelId: string, query?: object, ): Promise<ListResponse<T>> { const queryString = qs.stringify({ modelId, ...query }); const res = await fetch(`${baseUrl}/list?${queryString}`, { headers: { Authorization: `Bearer ${token}` }, });
if (!res.ok) { const body = await res.text(); throw new Error(`Failed to fetch contents: ${res.status} ${body}`); }
return res.json(); }, };};
const client = createClient({ token: process.env.CRAFT_CDN_API_TOKEN! });
const modelId = process.env.CRAFT_MODEL_ID!;
// リッチテキストフィールドの値type RichText = { text: string; html: string; json: object;};
export type Article = { id: string; sys: { createdAt: string; updatedAt: string; modelId: string; customOrder: number; raw: { createdAt: string; updatedAt: string; firstPublishedAt: string; publishedAt: string; }; }; // モデルで定義したフィールド title: string; slug: string; body: RichText;};
export const getArticles = async (): Promise<Article[]> => { const data = await client.getContents<Article>(modelId, { order: ["-sys.createdAt"], }); return data.items;};
export const getArticleBySlug = async ( slug: string,): Promise<Article | null> => { const data = await client.getContents<Article>(modelId, { filter: { slug: { $eq: slug } }, }); return data.items[0] ?? null;};ポイントは次のとおりです。
createClientで、アクセストークンを受け取るクライアントを作成します。サブドメインはプロジェクトで共通なので、モジュール内で固定していますgetContents<T>は汎用メソッドで、必須のmodelIdと、任意のクエリ(filter/orderなど)を受け取ります。<T>には取得するコンテンツの型を渡します- クエリは
qs.stringifyで組み立てます。{ filter: { slug: { $eq: slug } } }のようなオブジェクトが、filter[slug][$eq]=...というブラケット記法に変換されます getArticlesはorder: ["-sys.createdAt"]を指定し、公開日時の新しい順で一覧を取得します。orderは配列で指定します(先頭に-を付けると降順)getArticleBySlugはfilterでスラッグが一致するコンテンツを取得し、先頭の1件を返しますfetchにキャッシュのオプションは付けていません。cookies()などのリクエスト時APIを使わないルートは、ビルド時に1回だけフェッチしてプリレンダリング(静的レンダリング)されます。コンテンツを更新したら再ビルドします
fetch のキャッシュ挙動の詳細は、Next.jsのfetch APIリファレンスを参照してください。クエリパラメータの詳細は、クエリを参照してください。
4. 一覧ページを作成する
app/page.tsx を編集し、記事の一覧を表示します。サーバーコンポーネントなので、getArticles を直接 await で呼び出せます。
import type { Metadata } from "next";import Link from "next/link";import { getArticles } from "@/lib/craft";
export const metadata: Metadata = { title: "Cross Coffee Magazine",};
export default async function Home() { const articles = await getArticles();
return ( <main> <ul> {articles.map((article) => ( <li key={article.id}> <Link href={`/articles/${article.slug}`}>{article.title}</Link> </li> ))} </ul> </main> );}一覧ページはタイトルが固定なので、静的な metadata でページのメタデータを設定します。記事ごとにタイトルが変わる詳細ページでは、後述の generateMetadata を使います。
開発サーバーを起動して、記事の一覧が表示されることを確認します。
pnpm devブラウザで http://localhost:3000 を開くと、公開済みの記事のタイトルが一覧で表示されます。
5. 詳細ページを作成する
スラッグごとの詳細ページを作成します。app/articles/[slug]/page.tsx を作成し、次のように記述します。
import { notFound } from "next/navigation";import type { Metadata } from "next";import { getArticles, getArticleBySlug } from "@/lib/craft";
export const dynamicParams = false;
export async function generateStaticParams() { const articles = await getArticles(); return articles.map((article) => ({ slug: article.slug }));}
export async function generateMetadata({ params,}: { params: Promise<{ slug: string }>;}): Promise<Metadata> { const { slug } = await params; const article = await getArticleBySlug(slug); return { title: article?.title };}
export default async function ArticlePage({ params,}: { params: Promise<{ slug: string }>;}) { const { slug } = await params; const article = await getArticleBySlug(slug);
if (!article) { notFound(); }
return ( <main> <h1>{article.title}</h1> <div dangerouslySetInnerHTML={{ __html: article.body.html }} /> </main> );}ポイントは次のとおりです。
generateStaticParamsで全記事のスラッグを返し、ビルド時に各詳細ページを事前生成しますdynamicParamsをfalseにして、ビルド時に存在しないスラッグへのアクセスは404にします。新しい記事は再ビルドで反映しますgenerateMetadataで記事のタイトルをページのメタデータに設定します- 本文はリッチテキストフィールドの
htmlをdangerouslySetInnerHTMLで表示します
一覧ページの記事をクリックすると、対応する詳細ページが表示されます。