# Webhook revalidation or a revalidate timer: pick one

> A 60-second timer rebuilds pages nobody asked for and still makes editors wait. Tag every read, invalidate on publish, and stop guessing.

The first version of every headless-CMS integration looks like this:

```ts
export const revalidate = 60; // seconds
```

Sixty seconds feels like a reasonable compromise between "fresh" and "cheap". It
is neither.

## What the timer actually costs

**Editors still wait, and they cannot tell how long.** Publishing at 10:00:01
means the page changes at 10:01:00, unless nobody visits, in which case the
revalidation is not even triggered, because ISR is request-driven. The editor
refreshes, sees the old page, publishes again, and asks you why the CMS is
broken.

**You pay for rebuilds nobody wanted.** Every page with a timer re-renders
whenever it is visited after expiry, whether or not anything changed. On a blog
with 200 posts and a crawler working through your sitemap, that is 200 renders
and 200 GROQ queries an hour to reproduce byte-identical HTML.

**Shortening it makes both worse.** `revalidate = 5` is a rendering loop with a
CMS attached, and editors still see a delay.

The timer is a guess about when content changed. You do not have to guess:
Sanity will tell you.

## Tag every read

Caching by tag is what makes targeted invalidation possible. Every read in this
repo goes through one wrapper that attaches them:

```ts
const post = await sanityFetch<PostDetail>({
  query: POST_QUERY,
  params: { slug },
  tags: ["post", `post:${slug}`],
});
```

Two tags, two granularities:

- `post`: everything that shows a list of posts. The index, the home page
  teaser, the sitemap.
- `post:<slug>`: this one document's page.

A page that reads three things ends up in three tag sets, and any of them can
invalidate it independently.

## Invalidate on publish

```ts
// src/app/api/sanity/revalidate/route.ts
export async function POST(request: Request) {
  if (!secretMatches(request.headers.get("x-webhook-secret"), expected)) {
    return Response.json({ revalidated: false }, { status: 401 });
  }

  const { _type, slug } = await request.json();
  const tags = [_type];
  if (typeof slug === "string" && slug !== "") tags.push(`${_type}:${slug}`);

  for (const tag of tags) revalidateTag(tag, "max");

  return Response.json({ revalidated: true, tags });
}
```

In sanity.io/manage, API -> Webhooks:

- **URL** `https://<your-site>/api/sanity/revalidate`
- **Trigger on** create, update, delete
- **Filter** `_type == "post"`
- **Projection** `{"_type": _type, "slug": slug.current}`
- **Header** `x-webhook-secret: <SANITY_REVALIDATE_SECRET>`

The projection matters: without it Sanity posts the whole document, and you are
parsing a body you do not need to extract two strings.

## `revalidateTag(tag, "max")`: the second argument is not optional

The signature is `revalidateTag(tag, profile)`. The single-argument form is
deprecated and behaves like `{ expire: 0 }`: the cached entry is dropped
outright, so the next visitor blocks while the page re-renders.

`"max"` marks the entry stale instead. The next request is served the old page
immediately and triggers a background re-render, and subsequent requests get the
new one. Readers never wait; the editor's change is live within a request or two.

Use `{ expire: 0 }` deliberately when correctness beats speed (a takedown, a
GDPR deletion, a price that must never be shown again) and accept the blocking
render that comes with it.

## Verifying the loop

Do not trust it until you have watched it work:

1. Deploy, then load the post page twice so it is definitely cached.
2. Change one word in the Studio and publish.
3. Reload. The first reload may show the old text (that is stale-while-
   revalidate doing its job) but the second must show the new one.
4. Open the webhook's delivery log in sanity.io/manage. It shows the status
   code and the response body from your route, which is where `{"revalidated":
   true, "tags": ["post", "post:my-slug"]}` should be.

A 401 in that log means the header does not match the environment variable in
the deployed environment. A 200 with `revalidated: false` means the filter or
projection is sending you something other than what the route expects.

## Do not do both

Once webhook invalidation works, remove the page-level `revalidate` exports. A
timer on top of tag invalidation gives you rebuilds you did not ask for and
makes "why did this page change?" unanswerable.

The one place a timer still earns its keep is content you do not control the
publishing of: a third-party feed, a status page, an exchange rate. If nothing
can call your webhook, a timer is the only tool you have. Everything in your own
CMS should be event-driven.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
