Next.jsで作る — App Router と ISR

入門 約15分 最終更新 2026-09-08 Next 15

このガイドでは、Next.js の App Router(Server Component)で「お知らせ一覧」と「お知らせ詳細」の2ページを作ります。ビルド時ではなくリクエスト時にサーバー側でSDKを呼び、投稿の更新は On-Demand ISR(revalidatePath)で反映する構成です。

前提:APIキーとサイトコードを取得済みで、クイックスタートのステップ2まで完了していること。

1. サーバー専用クライアント

環境変数は CMS_API_KEY / CMS_SITE_CODE とし、NEXT_PUBLIC_ は付けません。ブラウザに送られる接頭辞を付けないことで、APIキーがクライアントバンドルに混入するのを防ぎます。クライアントは lib/cms.ts に1つだけ作ります。

lib/cms.ts
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になります。

app/news/page.tsx
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ページに委ねます。

app/news/[slug]/page.tsx
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で追従させます)。

app/api/revalidate/route.ts
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 });
}
一覧は即時・詳細は時間ベース:Webhookでの一覧の即時再検証と、詳細ページのrevalidate(時間ベースISR)を併用します。Webhookが個別slugを持たないぶん、詳細の反映はrevalidateの間隔に依存します。
FEEDBACK

このページはお役に立ちましたか?

改善要望・不具合・ドキュメントの分かりにくい点など、お気軽にお寄せください。いただいた内容は今後の改善に役立てます。

返信をご希望の場合のみご記入ください。

スパム対策(レートリミット・ハニーポット・送信速度チェック)はサーバ側で自動適用されます。