I run a small content site about CarPlay build on top of Next.js 16 + Sanity on Vercel, and troubleshooting-type posts there are time-sensitive — when an iOS point release breaks something, the window where the answer is useful is short. Waiting days for indexing kills the whole point. So I wired the site so that hitting Publish triggers everything in one chain: cache invalidation, sitemap update, and an IndexNow ping — about 70 lines of code total, no cron jobs, no third-party services. Here's the actual implementation with the three gotchas that bit me.
The chain
Editor hits Publish in Sanity
→ webhook POSTs to /api/revalidate?secret=…
→ revalidateTag("posts") + revalidateTag("post:")
→ page re-renders on next request (~1s), sitemap lastmod is now correct
→ pingIndexNow(["/blog/"]) fires async
→ Bing-family engines fetch within minutes
Step 1: two granularities of cache tags
Cache tags are the unit of invalidation in the App Router. Tag both the collection query and each individual post:
// lib/posts.ts
export async function getPost(slug: string) {
return client.fetch(POST_QUERY, { slug }, {
next: { tags: ["posts", `post:${slug}`] },
});
}
export async function getAllPosts() {
return client.fetch(POSTS_QUERY, {}, {
next: { tags: ["posts"] },
});
}
Two tags on purpose. The collection tag (posts) catches everything that renders lists — blog index, sitemap, "related posts" blocks. The per-post tag lets me re-render one changed page without nuking the cache for fifty untouched ones.
Step 2: the revalidation webhook
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { type NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const secret = req.nextUrl.searchParams.get("secret");
const expected = process.env.REVALIDATE_SECRET;
if (!expected) {
return NextResponse.json(
{ ok: false, error: "REVALIDATE_SECRET not set" },
{ status: 500 },
);
}
if (secret !== expected) {
return NextResponse.json({ ok: false, error: "Invalid secret" }, { status: 401 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ ok: false, error: "Body is not JSON" }, { status: 400 });
}
const slug = extractSlug(body);
revalidateTag("posts");
if (slug) revalidateTag(`post:${slug}`);
return NextResponse.json({ ok: true, revalidated: { slug: slug ?? null } });
}
The Sanity side, in Studio → Manage → API → Webhooks:
URL: https://yoursite.com/api/revalidate?secret=
Trigger on: Create, Update, Delete
Filter: _type == "post"
Projection: { "slug": slug.current, "_type": _type }
Gotcha #1 — don't trust the payload shape. Sanity ships either the projected flat value or the full document depending on how the webhook is configured, so the slug arrives either as "my-post" or as { _type: "slug", current: "my-post" }. My first version handled only the flat shape and silently revalidated nothing for delete events. Handle both:
function extractSlug(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
if (typeof b.slug === "string") return b.slug;
if (b.slug && typeof b.slug === "object") {
const inner = (b.slug as Record<string, unknown>).current;
if (typeof inner === "string") return inner;
}
return null;
}
And always invalidate the collection tag even when there's no slug — a delete event might not carry one, and unconditional revalidateTag("posts") means the index and sitemap heal themselves no matter how weird the payload is.
Gotcha #2 — fail loudly on missing config. The 500 branch for an unset secret looks paranoid until you add a preview environment and forget the env var. A silent 401 costs an hour of "why isn't the webhook firing"; an explicit "not set" error costs ten seconds.
Step 3: IndexNow
At this point the site updates within a second of publishing — but search engines still don't know. IndexNow is a dead-simple protocol adopted by Bing, Yandex, Naver, Seznam and Yep: you host a key file, you POST URLs, they crawl.
// lib/indexnow.ts
const KEY = process.env.INDEXNOW_KEY!; // 32-char hex you generate once
const ENDPOINT = "https://api.indexnow.org/IndexNow";
const SITE_URL = "https://yoursite.com";
export async function pingIndexNow(paths: string[]) {
const body = {
host: new URL(SITE_URL).host,
key: KEY,
keyLocation: `${SITE_URL}/${KEY}.txt`,
urlList: paths.map((p) => `${SITE_URL}${p.startsWith("/") ? p : `/${p}`}`),
};
try {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
return { ok: res.ok, status: res.status };
} catch (err) {
return { ok: false, status: 0, error: String(err) };
}
}
The key file is literally a text file at /.txt containing the key — drop it in public/. The engine fetches it once to verify host ownership.
Wire it into the webhook after the tags are flushed:
if (slug) {
revalidateTag(`post:${slug}`);
// fire-and-forget: indexing must never block the webhook response
pingIndexNow([`/blog/${slug}`]).catch(() => {});
}
Gotcha #3 — never await the ping in the hot path. IndexNow is a courtesy signal; if api.indexnow.org is slow, your CMS webhook shouldn't time out because of it. That's also why the wrapper returns {ok: false} instead of throwing — the .catch(() => {}) is belt-and-suspenders on top.
One more practical note: editors save drafts a lot. If every save fires the webhook, you'll ping the same URL fifty times a day. The protocol tolerates it, but batch or debounce if your CMS can't filter publish-only events.
What about Google?
Google doesn't participate in IndexNow — no point pretending otherwise. What you get: near-instant Bing-family indexing, which also feeds Copilot answers. For Google, the win is indirect but real: because the collection tag invalidates the sitemap too, its lastmod is accurate the moment a post goes live, and in my experience that shortens Google's pickup from "days" to "next crawl" — without any pinging service.
If you're on Contentful, Strapi or Payload instead of Sanity, the only part that changes is extractSlug — the tag scheme and the IndexNow wrapper are CMS-agnostic.
Happy to compare notes in the comments — especially curious whether anyone has measured Google crawl-latency improvement from accurate sitemap lastmod at bigger scale than mine.
Top comments (0)