コンテンツにスキップ

Next.jsでコンテンツを取得して表示する

このチュートリアルでは、Craft Cross CMSとNext.js(App Router)を使って、コンテンツの一覧ページと詳細ページを作る手順を説明します。ページはビルド時にプリレンダリング(静的レンダリング)する構成です。

このチュートリアルで作るもの

Section titled “このチュートリアルで作るもの”
  • 一覧ページ: 公開済みのマガジン記事を一覧で表示し、各記事の詳細ページへリンクします
  • 詳細ページ: スラッグごとに、記事のタイトルと本文を表示します

クイックスタートに従って、次の準備を済ませてください。

  • マガジン記事用のモデルを作成する
  • 記事を何件か作成し、公開する
  • CDN APIのアクセストークンを発行する(スコープに beta.cdn.cms.content.list を設定する)

このチュートリアルでは、マガジンのモデルに次のフィールドを定義している前提で進めます。フィールドIDを合わせておいてください。

フィールド名フィールドIDフィールドタイプ
タイトルtitleテキスト
スラッグslugテキスト(スラッグ)
本文bodyリッチテキスト

あわせて、次の3つの値を控えておきます。確認方法はクイックスタートを参照してください。

  • サブドメイン: [すべてのメニュー]>[Craft]>[CMS設定]>[APIエンドポイント] で確認できます
  • モデルID: コンテンツの詳細画面で確認できます
  • アクセストークン: 発行時に控えた値を使います

記事内で使用している主なソフトウェアのバージョン

Section titled “記事内で使用している主なソフトウェアのバージョン”
  • Next.js(next): 16.2.9

1. Next.jsプロジェクトを作成する

Section titled “1. Next.jsプロジェクトを作成する”

create-next-app でプロジェクトを作成します。TypeScriptを有効、App Routerを有効にし、src/ ディレクトリは使わない構成を選びます。

Terminal window
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を利用した場合、次のように表示されます。

Terminal window
$ 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

作成したディレクトリに移動します。

Terminal window
cd cross-coffee-magazine
$ pnpm dev

http://localhost:3000 にアクセスして、Next.jsの初期画面が表示されることを確認します。

アクセストークンは第三者に渡ると非公開のコンテンツを取得される可能性があるため、サーバー側だけで扱います。プロジェクトのルートに .env.local を作成し、控えておいた値を設定します。

CRAFT_SUBDOMAIN=xxxxxxxx
CRAFT_MODEL_ID=yyyyyyyyyyyy
CRAFT_CDN_API_TOKEN=your-access-token

.env.local は、create-next-app が生成する .gitignore に .env* として記載されているため、Gitの管理対象から外れます。これにより、アクセストークンがリポジトリに含まれることはありません。

Craft Cross CMSには専用のクライアントライブラリはないため、CDN APIを fetch で呼び出してコンテンツを取得します。アクセストークンをクライアントに渡さないよう、この処理はサーバー側だけで実行します。

CDN APIを呼び出す処理をまとめたモジュールを作成します。まず、必要なパッケージをインストールします。

  • server-only: モジュールがサーバー側だけで読み込まれることを保証します
  • qs: filter[slug][$eq] のようなブラケット記法のクエリ文字列を、オブジェクトから組み立てます
Terminal window
pnpm add server-only qs
pnpm add -D @types/qs

lib/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リファレンスを参照してください。クエリパラメータの詳細は、クエリを参照してください。

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 を使います。

開発サーバーを起動して、記事の一覧が表示されることを確認します。

Terminal window
pnpm dev

ブラウザで http://localhost:3000 を開くと、公開済みの記事のタイトルが一覧で表示されます。

スラッグごとの詳細ページを作成します。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 で表示します

一覧ページの記事をクリックすると、対応する詳細ページが表示されます。