# Next.jsでCMSの公開を即反映する：Webhookと再検証

公開日: 2026-10-20 | 更新日: 2026-10-20 | URL: https://blog.genko.me/nextjs-webhook-revalidate

> Next.js App RouterでCMSの記事公開をWebhookで受け、一覧と詳細のキャッシュを更新する方法を解説します。genko.meの署名検証、手動公開と予約公開のイベント差、再検証のタイミング、失敗時の復旧まで扱います。

Next.jsでCMSの記事公開に合わせて表示を更新するには、署名を検証したWebhookから`revalidateTag`や`revalidatePath`を呼びます。取得時に付けたタグと、一覧・詳細のURLを更新対象にしてください。Webhookはキャッシュを無効化する通知であり、開いている全ブラウザの表示を同時に書き換える機能ではありません。

本記事はgenko.meの運営側が執筆しています。2026年10月20日に確認したNext.jsの公式仕様と[genko.me](https://genko.me/)のWebhook仕様を使い、App Routerの受信処理を作ります。接続の基本は[Next.jsでヘッドレスCMSを使う](https://blog.genko.me/headless-cms-nextjs)を参照してください。

## revalidateTagとrevalidatePathはどう使い分けますか

[Next.js公式のrevalidatePath](https://nextjs.org/docs/app/api-reference/functions/revalidatePath)では、Route Handlerからの呼び出しは次回アクセス時の再検証として説明されています。

| 呼び出し | 主な対象 | 表示への影響 |
| --- | --- | --- |
| `revalidateTag('genko:blog', 'max')` | 同じタグのデータ | 古い内容を返しながらバックグラウンドで更新 |
| `revalidateTag('genko:blog', { expire: 0 })` | 同じタグのデータ | 次の取得で新しいデータを待つ |
| `revalidatePath('/blog')` | 一覧ページ | 次にそのパスを訪れたとき再検証 |

この記事ではWebhook受信後の次の取得で古いデータを返さないため、`{ expire: 0 }`を使います。応答速度を優先して一時的な古さを許容するなら`'max'`が候補です。1引数だけの`revalidateTag(tag)`は非推奨です。詳細は[公式リファレンス](https://nextjs.org/docs/app/api-reference/functions/revalidateTag)を確認してください。

## 1. 記事取得にキャッシュタグを付ける

Next.js 16のApp Routerを前提にします。以下はCache Componentsを有効にしていない構成で、標準の`fetch`にキャッシュを明示する例です。`lib/posts.ts`へ保存します。

```ts
import 'server-only';

export async function getPosts() {
  const key = process.env.GENKO_API_KEY;
  if (!key) throw new Error('GENKO_API_KEY is not configured');
  const url = new URL('https://api.genko.me/v1/your-workspace/blog');
  url.searchParams.set('orders', '-publishedAt');
  url.searchParams.set('limit', '10');
  const response = await fetch(url, {
    headers: { 'X-API-KEY': key },
    cache: 'force-cache',
    next: { tags: ['genko:blog'], revalidate: 3600 },
  });
  if (!response.ok) throw new Error(`Delivery API: ${response.status}`);
  return response.json() as Promise<{
    contents: { id: string; title: string }[];
  }>;
}
```

`app/blog/page.tsx`から呼び出します。`title`というテキストフィールドを持つリスト形式の`blog`APIと、公開済み記事を用意してください。

```tsx
import { getPosts } from '@/lib/posts';

export default async function BlogPage() {
  const { contents } = await getPosts();
  return <ul>{contents.map(post => <li key={post.id}>{post.title}</li>)}</ul>;
}
```

`GENKO_API_KEY`はサーバーで設定し、ワークスペースIDも置き換えます。`@/`はプロジェクトルートの想定です。

## 2. Webhookの署名を検証する受信ルートを作る

SDKを追加します。署名検証後に再検証を呼びます。

```bash
npm install @genko-me/sdk server-only
```

`app/api/genko-webhook/route.ts`を作り、サーバーの`GENKO_WEBHOOK_SECRET`にCMSへ登録するものと同じシークレットを設定します。配信APIキーとは別の値です。

```ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { verifyGenkoSignature } from '@genko-me/sdk';

const events = new Set([
  'entry.created', 'entry.updated', 'entry.deleted',
  'entry.published', 'entry.unpublished',
]);

export async function POST(request: Request) {
  const secret = process.env.GENKO_WEBHOOK_SECRET;
  if (!secret) return new Response('Missing secret', { status: 500 });
  const rawBody = await request.text();
  const valid = await verifyGenkoSignature({
    secret,
    rawBody,
    signatureHeader: request.headers.get('x-genko-signature'),
    timestampHeader: request.headers.get('x-genko-timestamp'),
  });
  if (!valid) return new Response('Invalid signature', { status: 401 });

  let payload;
  try { payload = JSON.parse(rawBody); }
  catch { return new Response('Invalid JSON', { status: 400 }); }
  if (!payload || typeof payload !== 'object' ||
      payload.workspace !== 'your-workspace' || payload.api !== 'blog') {
    return new Response('Unexpected source', { status: 400 });
  }
  if (payload.event === 'webhook.test') {
    return Response.json({ received: true, test: true });
  }
  if (!events.has(payload.event)) {
    return Response.json({ received: true, ignored: true });
  }
  if (typeof payload.entry?.id !== 'string' || !payload.entry.id) {
    return new Response('Missing entry ID', { status: 400 });
  }
  revalidateTag('genko:blog', { expire: 0 });
  revalidatePath('/blog');
  revalidatePath('/blog/[id]', 'page');
  return Response.json({ revalidated: true });
}
```

動的パターンは、削除やID変更で古いページが残らないよう詳細全体を対象にします。大規模サイトでは変更前URLを管理して個別パスへ絞ります。タグは取得側と一致させます。

署名はJSONを再生成した文字列ではなく、生の`rawBody`で検証します。SDKは`timestamp.rawBody`のHMAC-SHA256を確認し、既定では現在時刻と300秒を超えてずれた通知を拒否します。[Webhookガイド](https://docs.genko.me/guides/webhooks)にヘッダーと配送の仕様があります。

## 3. genko.meにWebhookを登録する

1. 受信ルートを外部から届くHTTPSのURLへデプロイします。
2. 対象APIの「設定」で「Webhookを追加」を開きます。
3. 最終的な受信URLとシークレットを登録します。
4. 「テスト送信」で受信確認をします。
5. 実際の記事を保存・公開し、一覧と詳細の更新を確認します。

genkoはリダイレクトを追跡しません。`localhost`やプライベートアドレスも通知先にはできません。テスト通知は`webhook.test`で`entry`が`null`です。上のルートでは署名と送信元の確認だけを行い、テストではキャッシュを変更しません。

## 公開イベントだけを受けると更新を見落とします

手動公開は`entry.created`か`entry.updated`、予約公開の確定は`entry.published`です。後者だけを待つと手動公開を見落とします。公開中の原稿を下書きとして保存するだけではWebhookは送られません。

公開から下書きへ戻した場合も`entry.updated`が届きます。`status === 'published'`の場合だけ処理すると、公開済みページの削除が反映されません。削除、公開終了も含めて無効化してください。

多言語ではサブ言語の通知に`lang`が付くため、英語版などのルートも更新対象に含めます。上の例は日本語の`/blog`だけを扱います。

## Webhookが成功しても古いページが出るとき

タグが違う、別ホストのCDNがHTMLをキャッシュしている、開いたタブの表示を見ている、といった場合はHTTP 200でも表示が変わりません。

genkoのWebhookは10秒でタイムアウトし、自動リトライはありません。配信ログから失敗した通知を手動で再送できます。再送時は元の本文に新しい署名とタイムスタンプが付きます。同じ通知を再度受けても処理できるようにします。

## よくある質問

### Webhookを送るとページはその瞬間に再生成されますか？

Route Handlerからの再検証は、基本的に次のアクセスや取得に備えた無効化です。閲覧中のブラウザへ新しい画面を一斉配信するものではありません。

### revalidateTagとrevalidatePathは両方必要ですか？

必ず両方ではありません。複数画面で共有する取得結果はタグ、特定URLの更新はパスを使い、実際のキャッシュ設計に合わせます。

### static exportしたNext.jsでも使えますか？

この受信処理にはサーバーが必要です。静的ファイルだけを配信する場合はWebhookで再ビルドするか、[静的HTMLからAPIを取得する方法](https://blog.genko.me/static-site-news-cms)を使います。

### テスト送信が成功すれば公開反映も確認できますか？

いいえ。テストには記事情報がなく、この例は更新処理をしません。記事の実際の公開・更新・非公開化を別途確認してください。

## 出典

2026年10月20日に確認しました。

- [Next.js revalidateTag](https://nextjs.org/docs/app/api-reference/functions/revalidateTag)
- [Next.js revalidatePath](https://nextjs.org/docs/app/api-reference/functions/revalidatePath)
- [genko Webhook](https://docs.genko.me/guides/webhooks)
- [genko Next.jsプラグイン](https://docs.genko.me/sdk/plugins)
- [genko JavaScript導入](https://docs.genko.me/frameworks/javascript/getting-started)
