DEV Community

Cover image for I wired our Next.js blog so a published post is in search indexes within minutes — here's the whole pipeline
Valery
Valery

Posted on

I wired our Next.js blog so a published post is in search indexes within minutes — here's the whole pipeline

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"] },
  });
}
Enter fullscreen mode Exit fullscreen mode

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 } });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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) };
  }
}
Enter fullscreen mode Exit fullscreen mode

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(() => {});
}
Enter fullscreen mode Exit fullscreen mode

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)