1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

🤝 AIに“コード”より先に“運用”を任せてみた:Webhook×Patchでコンテンツが勝手に育つ(2025年 Next.js + Sanity)

Last updated at Posted at 2026-01-21

こんにちは😊

株式会社プロドウガ@YushiYamamotoです!

JapanLifeStart.comの開発・運営を担当しながら、React.js・Next.js専門のフリーランスエンジニアとしても活動しています❗️

2025年は「AIにコードを書かせる」だけじゃなく、運用そのもの(更新・関連付け・整合性チェック)をAI+自動化に寄せた1年でした。

この記事では、Next.js + Sanity で「管理画面を開かずに」コンテンツが勝手に育つようにした、個人運用寄りの実験記を書きます。

unnamed-4.jpg

🎯 何を自動化したのか(最初にゴール)

個人サイト運用で、地味にコストが高いのが「公開後の紐付け作業」です。

  • 新記事を公開したら、カテゴリの“親記事(ハブ)”にリンクを足す
  • 関連記事の配列を更新して回遊を作る
  • 必要に応じて並び替え・メンテする

これを人間が管理画面で毎回やると、記事が増えるほど更新漏れが出ます。

そこで「公開」というイベントをトリガーに、裏側で自動追記する仕組みに置き換えました。

この記事の主役は“UI”ではなく、Webhookで起動するサーバ処理です(脱・管理画面)。


🧠 全体像(Webhook → Patch → ISR)

やっていることはシンプルで、流れは次の通りです。

  1. Sanityで新記事を公開 → Webhookが飛ぶ
  2. Next.js API RouteがWebhookを受ける
  3. Sanityから「親記事(ハブ)」を検索(GROQ)
  4. 親記事に対してPatchで配列へ参照を追記
  5. フロントはISRで“次回アクセス時に”自然に最新化

🗺️ フロー図


🔧 なぜ「Patch」なのか(置き換えではなく差分更新)

unnamed-3.jpg

Sanity公式ドキュメントでも、ドキュメントを丸ごと置き換えるのではなく、プログラムからの更新はpatch(差分操作)を使うのが良いと説明されています。

patchには setIfMissing などの操作があり、存在しないフィールドを安全に初期化してから更新できます。

この設計にすると「人間が管理画面で追加して保存する」を、APIの差分命令で再現できます。

つまり、運用を“手作業”から“イベント駆動”へ移せます。

patchは便利ですが、検索条件が雑だと「違う親記事に追記してしまう」事故が起きます。最初に親記事の特定ルール(カテゴリ/タグ/シリーズ)を固定するのが重要です。


🛠️ 基本実装:動くサンプル(Next.js App Router)

ここから「読者がそのまま写して動かせる」形で書きます。

例として「新記事が公開されたら、カテゴリごとの親記事(hub)の relatedPosts 配列へ参照を末尾追加」します。

環境変数(例)

SANITY_PROJECT_ID="xxxx"
SANITY_DATASET="production"
SANITY_API_VERSION="2025-01-01"
SANITY_WRITE_TOKEN="書き込み権限トークン"
SANITY_WEBHOOK_SECRET="Webhook用シークレット"

SANITY_WRITE_TOKEN はサーバ専用です。Next.jsのクライアント側に渡る設計にしないでください(漏洩するとDBを書き換えられます)。

Sanityサーバクライアント

// lib/sanityServerClient.ts
import { createClient } from "@sanity/client";

export const sanityServer = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: process.env.SANITY_DATASET!,
  apiVersion: process.env.SANITY_API_VERSION || "2025-01-01",
  token: process.env.SANITY_WRITE_TOKEN!, // 書き込みOK(サーバ限定)
  useCdn: false,
});

Webhook受信 → 検索 → Patch(メイン)

app/api/sanity/webhook/route.ts(Webhook→親記事検索→Patch追記)
import { NextResponse } from "next/server";
import { sanityServer } from "@/lib/sanityServerClient";

type WebhookPayload = {
  _id?: string;
  category?: { slug?: { current?: string } };
};

export async function POST(req: Request) {
  // 0) Webhook簡易認証(最低限)
  const secret = req.headers.get("x-webhook-secret");
  if (secret !== process.env.SANITY_WEBHOOK_SECRET) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }

  // 1) payload取得(Webhook設定に合わせて調整)
  const payload = (await req.json()) as WebhookPayload;
  const newPostId = payload?._id;
  const categorySlug = payload?.category?.slug?.current;

  if (!newPostId || !categorySlug) {
    return NextResponse.json(
      { ok: false, reason: "missing _id or category slug" },
      { status: 400 }
    );
  }

  // 2) 親記事(hub)を検索
  const parent = await sanityServer.fetch(
    `*[_type=="hub" && category.slug.current==$category][0]{ _id }`,
    { category: categorySlug }
  );

  if (!parent?._id) {
    return NextResponse.json(
      { ok: false, reason: "parent hub not found" },
      { status: 404 }
    );
  }

  // 3) Patchで配列の末尾に参照を追記
  // patchの思想:ドキュメントを置き換えず“差分”を適用する
  await sanityServer
    .patch(parent._id)
    .setIfMissing({ relatedPosts: [] }) // 無ければ作る
    .append("relatedPosts", [{ _type: "reference", _ref: newPostId }])
    .commit({ autoGenerateArrayKeys: true });

  return NextResponse.json({ ok: true });
}

動作確認(Webhookの手動テスト例)

本番はSanity側から飛びますが、ローカルで動作確認するなら curl が早いです。

curl -X POST http://localhost:3000/api/sanity/webhook \
  -H "Content-Type: application/json" \
  -H "x-webhook-secret: YOUR_SECRET" \
  -d '{"_id":"post-123","category":{"slug":{"current":"sim"}}}'


♻️ ISRで「デプロイ待ちゼロ」に寄せる

「DBは更新されたのに、表示が古い」問題を避けるためにISRを使います。

App Routerなら、ページファイルで export const revalidate = 60; のように設定して一定間隔で再検証できます。

// app/(site)/hub/[slug]/page.tsx
export const revalidate = 60;

この構成の良さは、コンテンツ更新=即デプロイ、にしなくていいところです。運用が軽くなります。


🧪 2025年に分かったこと(個人運用のリアル)

AIを絡めた運用自動化って、キラキラした話だけではないです。

個人で回すからこそ、効いたポイントと落とし穴がはっきりしました。

👍 効いた

  • 「関連更新の漏れ」がほぼ消える:公開したら親記事が勝手に育つ
  • 運用が“フロー”になる:管理画面での気合い作業が減る
  • AIの価値が上がる:実装そのものより、例外パターン整理(重複・誤爆・復旧手順)で効く

👀 落とし穴

  • 重複追加:Webhookが複数回飛ぶことを前提に、同じ参照を入れない対策が必要
  • 誤爆:親記事検索条件が曖昧だと、別カテゴリに混ざる
  • 監視不足:失敗しても気づけないと、静かにリンクが壊れる(ログ・通知が重要)

✅ 実務導入に向けた4つの追記

ここからは、個人運用でも「壊れない・直せる」を担保するために追加した、実務版の構成を紹介します。


🛡️ 追記①:重複追加を防ぐ(Idempotency)

Webhookは「同じイベントが複数回届く」ことが普通にあります。

そのため idempotent(何回呼ばれても結果が同じ) に寄せるのが安全です。

方針

  • 先に親記事の relatedPosts[]._ref を取得
  • newPostId が既に含まれていたら 何もしないskipped: true を返す)
  • 含まれていなければ patch().append() で追加

Studio側の手動操作も混ざる運用なら、配列フィールドに Rule.unique() を付けて「UIからの重複」を防ぐのも効果があります。


🗃️ 追記②:ログを永続化する(Better Stack)

「失敗通知」だけだと、あとで原因追跡や再実行がしづらいです。

最低限、Webhook処理ごとに イベントログを永続化しておくと、復旧が爆速になります。
今回は軽量に導入できる Better Stack (Logtail) を採用しました。HTTPでJSONを送るだけでログが残ります。

環境変数(追加)

BETTERSTACK_SOURCE_TOKEN="xxxx"  # LogsのSource Token
AUTOMATION_ENABLED="true"        # Kill Switch用

ログ送信ユーティリティ

lib/betterstack.ts(4イベント送信)
type LogEvent = "webhook_received" | "skipped" | "patched" | "error";

type AutomationLog = {
  requestId: string;
  event: LogEvent;
  postId?: string;
  parentId?: string;
  category?: string;
  error?: string; // event=error のときだけ
  at: string;
};

export async function logToBetterStack(log: AutomationLog) {
  const token = process.env.BETTERSTACK_SOURCE_TOKEN;
  if (!token) return;

  await fetch("https://in.logs.betterstack.com", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`, // Bearer $SOURCE_TOKEN
    },
    body: JSON.stringify([log]), // 1件でも配列で送れる
  });
}


📝 追記③:実務版Webhook実装(リトライ+ログ+Kill Switch)

これらを統合した、最終的な route.ts です。

app/api/sanity/webhook/route.ts(実務寄り:ログ永続化・リトライ・手動停止)
import { NextResponse } from "next/server";
import { sanityServer } from "@/lib/sanityServerClient";
import { logToBetterStack } from "@/lib/betterstack";
import crypto from "crypto";

// リトライ用ヘルパー
function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, ms));
}

async function withRetry<T>(fn: () => Promise<T>) {
  const delays = [250, 1000, 3000];
  let lastError: unknown;

  for (let i = 0; i < delays.length; i++) {
    try {
      return await fn();
    } catch (e) {
      lastError = e;
      await sleep(delays[i]);
    }
  }
  throw lastError;
}

type WebhookPayload = {
  _id?: string;
  category?: { slug?: { current?: string } };
};

export async function POST(req: Request) {
  const requestId = crypto.randomUUID();

  // Kill Switch
  if (process.env.AUTOMATION_ENABLED !== "true") {
    return NextResponse.json({ ok: true, skipped: true, reason: "disabled" });
  }

  const secret = req.headers.get("x-webhook-secret");
  if (secret !== process.env.SANITY_WEBHOOK_SECRET) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }

  const payload = (await req.json()) as WebhookPayload;
  const newPostId = payload?._id;
  const categorySlug = payload?.category?.slug?.current;

  // 1. 受信ログ
  await logToBetterStack({
    requestId,
    event: "webhook_received",
    postId: newPostId,
    category: categorySlug,
    at: new Date().toISOString(),
  });

  if (!newPostId || !categorySlug) {
    await logToBetterStack({
      requestId,
      event: "error",
      error: "missing _id or category slug",
      at: new Date().toISOString(),
    });
    return NextResponse.json({ ok: false }, { status: 400 });
  }

  try {
    // 2. 親記事(hub)を特定
    const parent = await sanityServer.fetch(
      `*[_type=="hub" && category.slug.current==$category][0]{ _id }`,
      { category: categorySlug }
    );

    if (!parent?._id) {
      throw new Error("parent hub not found");
    }

    // 3. 重複チェック
    const existing = await sanityServer.fetch(
      `*[_id==$id][0]{ "refs": relatedPosts[]._ref }`,
      { id: parent._id }
    );
    const refs: string[] = existing?.refs ?? [];

    if (refs.includes(newPostId)) {
      await logToBetterStack({
        requestId,
        event: "skipped",
        postId: newPostId,
        parentId: parent._id,
        category: categorySlug,
        at: new Date().toISOString(),
      });
      return NextResponse.json({ ok: true, skipped: true });
    }

    // 4. Patch(リトライつき)
    await withRetry(async () => {
      return sanityServer
        .patch(parent._id)
        .setIfMissing({ relatedPosts: [] })
        .append("relatedPosts", [{ _type: "reference", _ref: newPostId }])
        .commit({ autoGenerateArrayKeys: true });
    });

    await logToBetterStack({
      requestId,
      event: "patched",
      postId: newPostId,
      parentId: parent._id,
      category: categorySlug,
      at: new Date().toISOString(),
    });

    return NextResponse.json({ ok: true, requestId });

  } catch (e) {
    await logToBetterStack({
      requestId,
      event: "error",
      postId: newPostId,
      category: categorySlug,
      error: String(e),
      at: new Date().toISOString(),
    });
    return NextResponse.json({ ok: false, requestId }, { status: 500 });
  }
}


🧰 追記④:手動復旧とバックフィル(Nodeスクリプト)

自動化は便利ですが、手動で戻せる手順が書いてあるだけで運用の安心感が変わります。

失敗時は、ローカルでスクリプトを実行して「欠けた参照」をまとめて埋め直すのが確実です。

運用フロー(これだけ)

  1. Better Stackで event:error を検索して、落ちているカテゴリ(category)を特定する。
  2. hub未作成・slug不一致・権限など原因を直す。
  3. 下の backfill-related-posts.ts を実行して「不足分だけ」を追記する(重複は入れない)。

バックフィル用スクリプト

Sanityの更新はpatch(差分更新)で行い、配列が無い場合は setIfMissing で初期化してから追記します。

scripts/backfill-related-posts.ts
import "dotenv/config";
import { createClient } from "@sanity/client";

const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: process.env.SANITY_DATASET!,
  apiVersion: process.env.SANITY_API_VERSION || "2025-01-01",
  token: process.env.SANITY_WRITE_TOKEN!,
  useCdn: false,
});

async function backfill(categorySlug: string, limit = 50) {
  // 1. 親記事を特定
  const hub = await client.fetch(
    `*[_type=="hub" && category.slug.current==$category][0]{ _id }`,
    { category: categorySlug }
  );
  if (!hub?._id) throw new Error(`hub not found for category=${categorySlug}`);

  // 2. 子記事を取得
  const posts = await client.fetch(
    `*[_type=="post" && category.slug.current==$category]
      | order(publishedAt desc)[0...$limit]{ _id }`,
    { category: categorySlug, limit }
  );

  // 3. 既存参照を取得(重複チェック)
  const existing = await client.fetch(
    `*[_id==$id][0]{ "refs": relatedPosts[]._ref }`,
    { id: hub._id }
  );

  const refs: string[] = existing?.refs ?? [];
  const missing: string[] = (posts ?? [])
    .map((p: any) => p._id)
    .filter((id: string) => !refs.includes(id));

  if (missing.length === 0) return { ok: true, hubId: hub._id, added: 0 };

  // 4. 不足分だけ追記(差分更新)
  await client
    .patch(hub._id)
    .setIfMissing({ relatedPosts: [] })
    .append(
      "relatedPosts",
      missing.map((id) => ({ _type: "reference", _ref: id }))
    )
    .commit({ autoGenerateArrayKeys: true });

  return { ok: true, hubId: hub._id, added: missing.length };
}

const category = process.argv[2];
const limit = Number(process.argv[3] || 50);

if (!category) {
  console.error("Usage: node scripts/backfill-related-posts.ts <categorySlug> [limit]");
  process.exit(1);
}

backfill(category, limit)
  .then((res) => console.log(res))
  .catch((err) => {
    console.error(err);
    process.exit(1);
  });

実行例

node scripts/backfill-related-posts.ts sim 50


🚀 エンジニアの方へ:10万円もらえる特別キャンペーン

campaign_banner.png

現在、フリーランスエージェントの 「クラウドワークステック」 が、紹介経由での登録&稼働で「10万円(税込)」がもらえる 異例のキャンペーンを実施中です。

「自分の市場価値(単価)を知りたい」「週3日からの副業・リモート案件を探している」という方は、ぜひこの機会に登録してみてください。

👉 【限定特典付き】10万円キャンペーンに参加する

※キャンペーンは予告なく終了する場合があります。


最後に:業務委託のご相談を承ります

私は業務委託エンジニアとしてWEB制作やシステム開発を請け負っています。最新技術を活用したレスポンシブなWebサイト制作、インタラクティブなアプリケーション開発、API連携など幅広いご要望に対応可能です。

「課題解決に向けた即戦力が欲しい」「高品質なWeb制作を依頼したい」という方は、お気軽にご相談ください。一緒にビジネスの成長を目指しましょう!


在留外国人のためのメディア(JapanLifeStart.com)

👦 在留外国人のためのメディア(JapanLifeStart.com)

ポートフォリオ

👉 ポートフォリオ

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?