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.
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:
- Open
app/drops/page.tsx - Delete the
export const revalidate = 60line - Add an import:
import { unstable_cacheTag as cacheTag } from "next/cache"; - 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 withcacheTag('drops') - Open
app/api/drop-launch/route.ts - Change
import { revalidatePath } from "next/cache"toimport { revalidateTag } from "next/cache" - Change
revalidatePath('/drops')torevalidateTag('drops') - 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
dropsfor simplicity. For a larger app you might usedrops:${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).
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:
{
"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:
- The page renders the latest data
- 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.tsxno longer hasexport const revalidate = 60app/drops/page.tsxuses'use cache'withcacheTag('drops')app/api/drop-launch/route.tscallsrevalidateTag('drops')instead ofrevalidatePath('/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
/dropsdropping 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 if you want to check your version against it.
The updated page:
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:
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.
Was this helpful?