Next.jsで作る — App Router と ISR
このガイドでは、Next.js の App Router(Server Component)で「お知らせ一覧」と「お知らせ詳細」の2ページを作ります。ビルド時ではなくリクエスト時にサーバー側でSDKを呼び、投稿の更新は
On-Demand ISR(revalidatePath)で反映する構成です。
1. サーバー専用クライアント
環境変数は CMS_API_KEY / CMS_SITE_CODE
とし、NEXT_PUBLIC_ は付けません。ブラウザに送られる接頭辞を付けないことで、APIキーがクライアントバンドルに混入するのを防ぎます。クライアントは
lib/cms.ts に1つだけ作ります。
import 'server-only';
import { ShareDanCmsClient } from '@sharedan/cms-sdk';
export const cms = new ShareDanCmsClient({
baseUrl: 'https://api.cms.sharedan.co.jp',
apiKey: process.env.CMS_API_KEY!,
siteCode: process.env.CMS_SITE_CODE!,
}); import 'server-only' を先頭に付けておくと、このモジュールをClient Componentから誤ってimportした際にビルドエラーで検知できます。
2. 一覧ページ(Server Component)
app/news/page.tsx を async な Server Component として書き、
cms.posts.list() の結果をそのままレンダリングします。export const revalidate = 3600 を付けると、1時間ごとに再生成される時間ベースISRになります。
import Link from 'next/link';
import { cms } from '@/lib/cms';
export const revalidate = 3600; // 1時間ごとに再生成(時間ベースISR)
export default async function NewsPage() {
const { items } = await cms.posts.list('news', { perPage: 20 });
return (
<ul>
{items.map((p) => (
<li key={p.slug}>
<Link href={`/news/${p.slug}`}>{p.title}</Link>
<small>{p.category.name}</small>
</li>
))}
</ul>
);
} cms.posts.list() / cms.posts.get() に localeを渡すと言語別の公開版を取得できます(例:/[lang]/news の動的セグメントから言語を決める)。未翻訳は原文へフォールバックします。詳しくは多言語サイトを作るを参照してください。
3. 詳細ページと generateMetadata
app/news/[slug]/page.tsx では、generateStaticParams で一覧からスラッグを列挙します。存在しない・未公開のスラッグは isNotFound が真になるので、notFound() を呼んで404ページに委ねます。
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { renderProseMirror, ShareDanCmsApiError } from '@sharedan/cms-sdk';
import { cms } from '@/lib/cms';
// 詳細ページも時間ベースISRで追従(Webhookは投稿slugを含まないため、即時再検証は一覧のみ)
export const revalidate = 3600;
type Props = { params: { slug: string } };
export async function generateStaticParams() {
const { items } = await cms.posts.list('news', { perPage: 100 });
return items.map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
try {
const post = await cms.posts.get('news', params.slug);
return {
title: post.meta?.meta_title ?? post.title,
description: post.meta?.meta_description ?? undefined,
openGraph: post.meta?.og_image_url ? { images: [post.meta.og_image_url] } : undefined,
};
} catch {
return {};
}
}
export default async function NewsDetailPage({ params }: Props) {
let post;
try {
post = await cms.posts.get('news', params.slug);
} catch (e) {
if (e instanceof ShareDanCmsApiError && e.isNotFound) notFound();
throw e;
}
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: renderProseMirror(post.body) }} />
</article>
);
} generateMetadata では post.meta
(meta_title / meta_description /
og_image_url)を使い、未設定(null)の場合は
post.title などにフォールバックします。本文は
renderProseMirror(post.body) でHTML化し、
dangerouslySetInnerHTML で差し込みます。
4. On-Demand ISR で更新を反映
管理画面の「サイト設定 › 外部連携」でWebhookを有効にすると、投稿の公開・更新イベントが
X-ShareDan-Signature(sha256= に続く HMAC-SHA256)付きで通知されます。ペイロードは全イベント共通のエンベロープで、個別の投稿slugは含まれません。署名検証とペイロードの詳細は
Webhook / GitHub Actions でビルドを起動を参照してください。
ここでは、そのWebhookを受けて一覧ページ /news を再検証するRoute Handlerを作ります(詳細ページは時間ベースISRで追従させます)。
import { NextRequest, NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import crypto from 'node:crypto';
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get('x-sharedan-signature') ?? '';
// 署名は "sha256=" + 生ボディの HMAC-SHA256(16進)。プレフィックスまで含めて比較する
const expected =
'sha256=' +
crypto.createHmac('sha256', process.env.CMS_WEBHOOK_SECRET!).update(rawBody).digest('hex');
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) {
return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
}
// ペイロードは全イベント共通のエンベロープ({ event, resource, ... })。個別の投稿slugは
// 含まれないため、一覧を再検証する。詳細ページは page.tsx の revalidate(時間ベースISR)で追従する
const { event } = JSON.parse(rawBody) as { event: string };
if (event.startsWith('post.')) revalidatePath('/news');
return NextResponse.json({ revalidated: true });
} revalidate(時間ベースISR)を併用します。Webhookが個別slugを持たないぶん、詳細の反映はrevalidateの間隔に依存します。
このページはお役に立ちましたか?
改善要望・不具合・ドキュメントの分かりにくい点など、お気軽にお寄せください。いただいた内容は今後の改善に役立てます。
お寄せいただきありがとうございます。今後の改善に役立てます。