こんにちは😊
JapanLifeStart.comの開発・運営を担当しながら、React.js・Next.js専門のフリーランスエンジニアとしても活動しています❗️
2025年は「AIにコードを書かせる」だけじゃなく、運用そのもの(更新・関連付け・整合性チェック)をAI+自動化に寄せた1年でした。
この記事では、Next.js + Sanity で「管理画面を開かずに」コンテンツが勝手に育つようにした、個人運用寄りの実験記を書きます。
🎯 何を自動化したのか(最初にゴール)
個人サイト運用で、地味にコストが高いのが「公開後の紐付け作業」です。
- 新記事を公開したら、カテゴリの“親記事(ハブ)”にリンクを足す
- 関連記事の配列を更新して回遊を作る
- 必要に応じて並び替え・メンテする
これを人間が管理画面で毎回やると、記事が増えるほど更新漏れが出ます。
そこで「公開」というイベントをトリガーに、裏側で自動追記する仕組みに置き換えました。
この記事の主役は“UI”ではなく、Webhookで起動するサーバ処理です(脱・管理画面)。
🧠 全体像(Webhook → Patch → ISR)
やっていることはシンプルで、流れは次の通りです。
- Sanityで新記事を公開 → Webhookが飛ぶ
- Next.js API RouteがWebhookを受ける
- Sanityから「親記事(ハブ)」を検索(GROQ)
- 親記事に対してPatchで配列へ参照を追記
- フロントはISRで“次回アクセス時に”自然に最新化
🗺️ フロー図
🔧 なぜ「Patch」なのか(置き換えではなく差分更新)
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スクリプト)
自動化は便利ですが、手動で戻せる手順が書いてあるだけで運用の安心感が変わります。
失敗時は、ローカルでスクリプトを実行して「欠けた参照」をまとめて埋め直すのが確実です。
運用フロー(これだけ)
- Better Stackで
event:errorを検索して、落ちているカテゴリ(category)を特定する。 - hub未作成・slug不一致・権限など原因を直す。
- 下の
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万円もらえる特別キャンペーン
現在、フリーランスエージェントの 「クラウドワークステック」 が、紹介経由での登録&稼働で「10万円(税込)」がもらえる 異例のキャンペーンを実施中です。
「自分の市場価値(単価)を知りたい」「週3日からの副業・リモート案件を探している」という方は、ぜひこの機会に登録してみてください。
※キャンペーンは予告なく終了する場合があります。
最後に:業務委託のご相談を承ります
私は業務委託エンジニアとしてWEB制作やシステム開発を請け負っています。最新技術を活用したレスポンシブなWebサイト制作、インタラクティブなアプリケーション開発、API連携など幅広いご要望に対応可能です。
「課題解決に向けた即戦力が欲しい」「高品質なWeb制作を依頼したい」という方は、お気軽にご相談ください。一緒にビジネスの成長を目指しましょう!




