The blog post that would not update

The blog post that would not update

Diagram of a stale Next.js page: CMS ok, CDN ok, fetch cache stale, page stale

I rewrote one of the posts on this site last week. Sanity showed the new text. The API returned the new text. The site served the old text, and kept serving it with the calm of a system that believes it is doing everything right. Which, it turned out, it was.

If you put Next.js in front of a headless CMS, some version of this is waiting for you. Here is the order in which I blamed things.

The CDN, wrongly

My Sanity client runs with useCdn: true in production, meaning reads come off Sanity’s CDN rather than the live API. Cached content, obvious suspect. I curled the apicdn endpoint directly and the new text was there within a minute of saving. Not the CDN.

The page, also wrongly

The writing pages are rendered on demand, so I assumed each request pulled fresh data. In the App Router that assumption is wrong, and it is wrong on purpose. Server-side fetch calls are cached by default, next-sanity’s client.fetch rides through the same layer, and I had never configured revalidation anywhere. Never configured means cached forever.

Forever survives more than I expected. Locally the entries sit in .next/cache and come back after a rebuild, which cost me one very confused hour of testing against a cache I thought I had just destroyed. On Vercel it is worse, or better, depending on your mood: the Data Cache gets restored into new deployments, so even shipping a fresh build did not flush the old text.

The one-line fix

export const revalidate = 3600

With that on the route segment, every Sanity fetch under it revalidates hourly. The first visitor after expiry still gets the stale page while the refresh runs behind the scenes, and the visitor after that gets the new one. Stale-while-revalidate works exactly as advertised once you remember to turn it on.

If an hour is too slow for you, Sanity webhooks can trigger revalidation on demand. I thought about it for a minute and decided the plumbing was not worth it for a blog where the publishing schedule is "when something is worth writing down".

The method that found it

Nothing clever. I listed every cache between the CMS and the screen, then tested each one directly: curl the live API, curl the CDN, curl my own page. The bug has to live between the two responses that disagree, and there were only four places it could hide.

What makes these bugs feel haunted is that no component is broken. Every layer did exactly what its documentation says. The wrong page was the sum of five correct behaviours, and the fix was one line I should have written six months ago.