Skip to content

Published in the Admin, Empty on the Pillar Page

Adityo Guni Waluyo

A freshly published entity never appeared on the pillar page while the API served it fine. The lesson was one cache option: no-store on fetch, page ISR kept intact.

TL;DR

A published entity never showed on the pillar page grid. The page's ISR config was fine; the real culprit was getEntities fetching without a cache option, so Next.js 14 cached the list at build time forever. Adding cache: "no-store" fixed it, plus an E2E parity test guards the regression.

I was running a routine parity audit on KotaPortal, the kind of work that normally takes five minutes: create an entity through the admin UI, set it to published, then check its card on the pillar page. The card never showed up.

The layers I probed in order: the admin UI form submitted fine, the record was already published in the database, and the API returned the entity, verified from two places: straight to the host and from inside the container, identical results. The GET /entities response contained the new entity. But the latest-entities grid on the pillar page kept showing the old set. What made it worse: in dev everything looked healthy, the entity appeared the moment it was saved. The staleness only surfaced when production regenerated the page.

Wrong Suspect: the ISR

My first accusation was the ISR. My reasoning: REVALIDATE_SECONDS must be too high, or the pillar page route was misconfigured, so the page kept serving an old snapshot. I nearly dropped the revalidate to zero to force "always fresh".

The page layer was actually correct, and deliberately so. The freshness contract lives at the page level: the homepage uses 300 seconds, other pages 3600 seconds, and 0 in dev so every request triggers regeneration. A pillar page being stale for up to an hour is a decision, not an accident. Forcing revalidate 0 would only change the page cadence, and the bug would stay alive.

The Finding, One Layer Down

The fault sat in the request-level data cache. getEntities in frontend/src/lib/api/endpoints.ts ran with no cache option at all. In a repo on Next.js 14, a fetch without options in a statically prerendered route is fetched once at build time, and the result sticks in the data cache [1]. The fetch reference is explicit: "auto no cache (default): Next.js fetches the resource from the remote server on every request in development, but will fetch once during next build because the route will be statically prerendered" [1].

// before: listing fetched once at build
const res = await apiClient.GET("/entities")

// after: refetch per request, freshness owned by the page
const res = await apiClient.GET("/entities", { cache: "no-store" })

That single cache: "no-store" line is what finally gave published entities their card. The option definition matches the symptom exactly: Next.js fetches the resource from the remote server on every request [1].

Why did dev not reveal it? Besides dev being per-request by behavior [1], fetch requests restored from the Server Components HMR cache are not logged at all [4]. Even with fetch logging turned on, that request can pass without a trace after an HMR refresh. Dev is not a sufficient place to test listing freshness.

Two Knobs That Ignore Each Other

The lesson is not "turn caching off everywhere". These two mechanisms stand independently: the cache option on fetch [2] and export const revalidate on the route.

export const revalidate = Number(process.env.REVALIDATE_SECONDS ?? 3600)

The request knob decides whether a fetch stores its result. The route knob decides when a page regenerates. If a listing's freshness contract is owned by the page, the request cache must opt out per call. Otherwise the data cache silently overrides the page cadence, and a pillar page can go stale not for an hour but until the next build.

On versions: this repo runs next ^14.2.0, the era when the default still cached fetch results. Next.js 15 flipped that default: "fetch requests are no longer cached by default. To opt specific fetch requests into caching, you can pass the cache: 'force-cache' option" [5]. The current caching guide states the same, with force-cache as the opt-in [2]. That is why an explicit option is worth more than trusting the framework default: the contract lives at the call site, not in the behavior of whatever version you happen to run. The upgrade path between versions is documented, including the upgrade command [3].

The proof loop I left behind: an E2E parity spec that creates the entity through the UI, publishes it, verifies the row, then asserts the card appears on the pillar page. This class of regression must never again pass on "dev looks fine". One module without a public surface went on record as an owner decision, not a bug silently fixed.

Sources

Related articles