---
title: "ISR Writes"
description: "Move Saturday's drop schedule from interval revalidation to tag-based on-demand revalidation, triggered by the drop-launch webhook."
canonical_url: "https://vercel.com/academy/optimize-your-vercel-account/isr-writes"
md_url: "https://vercel.com/academy/optimize-your-vercel-account/isr-writes.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-07-22T01:12:04.819Z"
content_type: "lesson"
course: "optimize-your-vercel-account"
course_title: "Optimize Your Vercel Account"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# ISR Writes

# ISR Writes

The Saturday drops page changes about once a month. We add a new drop, update a release date, shuffle the schedule. Maybe ten content changes per year. The way it's wired right now, we're telling Next.js the page goes stale every 60 seconds. Any visit after that window triggers a fresh regeneration, which on a steadily trafficked site means up to 1,440 ISR writes a day against content that changes ten times a year. Every write is billed.

On-demand revalidation flips the relationship. Don't check on a timer. Wait for someone to tell you the drop schedule changed. That "someone" is your `drop-launch` webhook, which already exists and already gets called when a drop goes live.

We have to do two things. Replace the `export const revalidate = 60` on the page with the new `'use cache'` directive tied to a tag called `drops`. Then change the webhook to call `revalidateTag('drops')` instead of `revalidatePath('/drops')`. The webhook becomes the trigger. Everything else holds the cached value forever.

\*\*Note: What is ISR?\*\*

Incremental Static Regeneration. A page renders once, gets served as static,
and re-renders when it goes stale on a timer or when your code says the
content changed. Each re-render is an ISR write, and writes are metered.

## Outcome

The drops page is cached indefinitely and invalidated only when the drop-launch webhook fires. ISR writes for `/drops` drop from up to 1,440/day to \~0.

## Hands-on exercise 3.3

Two files. About fifteen lines of net code change.

**Requirements:**

1. Open `app/drops/page.tsx`
2. Delete the `export const revalidate = 60` line
3. Add an import: `import { unstable_cacheTag as cacheTag } from "next/cache";`
4. Wrap the data assembly in a cached function. The simplest approach is to factor the sort into a function annotated with `'use cache'` and tagged with `cacheTag('drops')`
5. Open `app/api/drop-launch/route.ts`
6. Change `import { revalidatePath } from "next/cache"` to `import { revalidateTag } from "next/cache"`
7. Change `revalidatePath('/drops')` to `revalidateTag('drops')`
8. Commit, push, redeploy

**Implementation hints:**

- The `'use cache'` directive caches the function it's inside, keyed by arguments. Putting it inside a tiny wrapper function is the cleanest way to mark the boundary of what gets cached.
- The cache tag is a string of your choosing. It just has to match between the page (which writes the tag) and the webhook (which invalidates the tag). We're using `drops` for simplicity. For a larger app you might use `drops:${dropId}` to invalidate one drop without dumping the rest.
- The drops data is in-memory in `app/data/drops.ts`. The cache shines more when the data source is slow (a database, an API). For Saturday the static JSON is fast enough that you won't see a dramatic speed change in the browser, but the ISR write metering still drops to nearly zero, which is the point.
- The webhook needs the cache tag to exist before it can invalidate it. If your first webhook call returns success but the page doesn't refresh, you probably haven't deployed the new drops page yet.

## Try It

Hit the webhook. Use the secret you set in Lesson 2.1 (or your password manager if you can't remember it).

```bash
curl -X POST https://YOUR_DOMAIN/api/drop-launch \
  -H "Content-Type: application/json" \
  -H "x-webhook-secret: YOUR_SECRET" \
  -d '{"dropId":"drop-003"}'
```

You should get back:

```json
{
  "revalidated": true,
  "dropId": "drop-003",
  "revalidatedAt": "2026-05-27T14:22:08.412Z"
}
```

Now load `/drops` in your browser. Then load it again. You should see two things:

1. The page renders the latest data
2. Successive loads don't trigger a new revalidation. Refresh ten times and you'll see no new ISR writes show up in Observability

Open Saturday > Observability > Edge Requests and filter to ISR. Before this change, you'd see writes piling up every minute for the drops path. After, you see writes only when the webhook fires.

If you want to see the full loop, edit `app/data/drops.ts` to change one of the release dates, push the change, wait for the deploy, hit the webhook, then refresh `/drops`. The updated date should appear immediately.

## Done-When

- [ ] `app/drops/page.tsx` no longer has `export const revalidate = 60`
- [ ] `app/drops/page.tsx` uses `'use cache'` with `cacheTag('drops')`
- [ ] `app/api/drop-launch/route.ts` calls `revalidateTag('drops')` instead of `revalidatePath('/drops')`
- [ ] Hitting the webhook with a valid secret returns `{ "revalidated": true }`
- [ ] The drops page reflects changes after the webhook fires
- [ ] Observability shows ISR writes for `/drops` dropping to near-zero outside webhook events

## Troubleshooting

**The webhook returns 200 but the page doesn't update.**

Either the tag string in the page (`cacheTag('drops')`) doesn't match the tag string in the webhook (`revalidateTag('drops')`), or the deployment isn't the one being hit. Tags are exact-match strings. Check both files for typos. Hard refresh the page (Cmd+Shift+R) to bypass browser cache.

**The drops page is throwing errors after the change.**

Most likely a missing `cacheComponents: true` in `next.config.ts`. We added it in Lesson 3.2; if you skipped that, the `'use cache'` directive won't work.

**ISR writes haven't dropped to zero.**

Verify the new deployment is the one in production. The old, timer-based deployment will keep writing ISR until a new build replaces it. Also check that the page actually uses the cached function, if your imports are unused or the function isn't called, Next.js falls back to rebuilding the page on each request.

## Solution

The finished `app/drops/page.tsx` is on the [`complete` branch](https://github.com/vercel-labs/academy-optimize-vercel-account/blob/complete/app/drops/page.tsx) if you want to check your version against it.

The updated page:

```tsx title="app/drops/page.tsx"
import Link from "next/link";
import { unstable_cacheTag as cacheTag } from "next/cache";
import { drops } from "@/app/data/drops";
import { getProductBySlug } from "@/app/data/products";

async function getDrops() {
  "use cache";
  cacheTag("drops");
  return [...drops].sort(
    (a, b) =>
      new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime()
  );
}

export default async function DropsPage() {
  const sortedDrops = await getDrops();

  // ...rest of the component unchanged
}
```

And the updated webhook:

```ts title="app/api/drop-launch/route.ts"
import { NextResponse } from "next/server";
import { revalidateTag } from "next/cache";

export async function POST(request: Request) {
  const secret = request.headers.get("x-webhook-secret");

  if (!process.env.DROP_WEBHOOK_SECRET) {
    return NextResponse.json(
      { error: "Webhook secret is not configured" },
      { status: 500 }
    );
  }

  if (secret !== process.env.DROP_WEBHOOK_SECRET) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = (await request.json()) as { dropId?: string };
  if (!body.dropId) {
    return NextResponse.json(
      { error: "dropId is required" },
      { status: 400 }
    );
  }

  revalidateTag("drops");

  return NextResponse.json({
    revalidated: true,
    dropId: body.dropId,
    revalidatedAt: new Date().toISOString(),
  });
}
```

That wraps Section 3. The compute side of Saturday is now lean: functions bill only for active work, repeat lookups skip the slow path, and ISR writes happen when content actually changes. Next section moves to the bandwidth side, starting with the biggest line item: images.


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
