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 regenerations a day against content that changes ten times a year. Each one runs the page on a function, and any run whose output differs from the cached copy also costs ISR writes.
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. The previous lesson removed export const revalidate = 60 before enabling Cache Components; now add the new 'use cache' directive tied to a tag called drops. Then change the webhook to call revalidateTag('drops', 'max') instead of revalidatePath('/drops'). The webhook becomes the trigger. Everything else holds the cached value until it is marked stale.
Outcome
The drops page is cached indefinitely and invalidated only when the drop-launch webhook fires, so /drops regenerates when the schedule changes instead of up to 1,440 times a day.
Hands-on exercise 3.3
Two files. About fifteen lines of net code change.
Requirements:
- Open
app/drops/page.tsx - Confirm the
export const revalidate = 60line was removed in Lesson 3.2 - Add an import:
import { 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', 'max') - 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 regeneration count 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 first load after the webhook can still show the cached page.
revalidateTag('drops', 'max')marks the tag stale, so that request gets the old copy instantly while the page regenerates in the background. The load after it gets the fresh render. - Loads after that don't trigger a new revalidation. Refresh ten times and nothing new shows up for
/dropsin Observability
Open Saturday > Observability > ISR. Before this change, you'd see the drops path revalidating every minute it had traffic. After, it revalidates only after the webhook fires.
One thing this test can't show: editing app/data/drops.ts and pushing would update the page without the webhook at all, because every new deployment starts with a fresh build. The webhook earns its keep when the schedule lives outside the repo, in a CMS or a database, and changes without a deploy.
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', 'max')instead ofrevalidatePath('/drops')- Hitting the webhook with a valid secret returns
{ "revalidated": true } - The drops page serves a fresh render on the second load after the webhook fires
- Observability shows
/dropsrevalidating only after webhook calls, not every minute
Troubleshooting
The webhook returns 200 but the page doesn't update.
Load it twice first. With the 'max' profile, the first request after the webhook gets the stale copy while the fresh one renders in the background. If the second load is still old, either the tag string in the page (cacheTag('drops')) doesn't match the tag string in the webhook (revalidateTag('drops', 'max')), 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.
/drops is still revalidating every minute.
Verify the new deployment is the one in production. The old, timer-based deployment keeps regenerating until a new build replaces it. Also check that the page calls getDrops(). If the cached function is imported but never called, the drops tag never attaches to anything, and the webhook has nothing to invalidate.
Solution
The finished app/drops/page.tsx is in the complete folder if you want to check your version against it.
The updated page:
import Link from "next/link";
import { 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", "max");
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 the drops page regenerates when the schedule changes. Next section moves to the bandwidth side, starting with the biggest line item: images.
Was this helpful?