Rendering & caching harness
Manual test routes for SSG, SSR, ISR, PPR, instant navigation and cache tag invalidation on Next.js 16 with cacheComponents enabled, behind the Pantheon cache handler.
How to read every panel
Each panel shows the scope that produced it, its declared caching policy, and when that scope last executed — a timestamp, a short execution id, and a live age. That is the whole method:
Reload the page and see which stamps moved. A stamp that stays put is a cache hit. A stamp that jumps means the scope re-ran.
The probe panels read an upstream that returns a different integer on every single call, so a cache hit is visible in the content itself and not only in the timestamp. Every API in this harness returns numbers and ISO timestamps only — no free text, no user-submitted content, no imagery.
Read this first
- Most of this only works in a build.
next devdoes not prefetch and does not prerender, so PPR shells, instant navigation and static output needpnpm build && pnpm start. Dev mode is for the validation insights in the error overlay. - Dev also caches fetches across HMR. Uncached panels can look cached in dev. Disable it with
serverComponentsHmrCache: false, or just test in a build. - A hard refresh changes the answer. The browser sends
cache-control: no-cachewhen devtools has caching disabled, and Next.js then ignores fetch cache options entirely. - Behind Pantheon’s CDN there are two caches. A stamp that will not move even after invalidation may be the edge response, not the app. Check with
curl -sI <url>and compare against a cache-busting query string.
Routes
Self-test — run every check at once
/selftestRequests every route below twice from the server, compares each panel's execution stamp against what that route guarantees, and reports a verdict with the evidence and a ranked list of what would explain a failure.
check · Press "Test caching". Anything that is not green says what it expected, what it saw, and what would account for the difference.
upstreams: none — it only reads this app
SSG — fully prerendered
/ssgEvery scope on the page is cached with a long lifetime, so the whole route is static HTML with no dynamic holes.
check · Reload repeatedly. No stamp should ever move. Stamps match the build time until you rebuild.
upstreams: frankfurter.dev (settled date) · open-meteo.com
SSR — fully dynamic
/ssrThe mirror image: nothing is cached, every scope defers to request time with connection() inside Suspense.
check · Reload repeatedly. Every stamp and probe value changes every single time.
upstreams: random.org · coingecko.com · open-meteo.com
PPR — shell plus holes
/pprThe default rendering model: a prerendered shell with cached content already in it, and request-time holes that stream in after.
check · Hard-reload with a throttled network. The shell and cached panels paint instantly; the delayed holes fill in at 1s, 2s and 4s.
upstreams: open-meteo.com · coingecko.com
ISR — revalidation windows
/isrGenuinely-moving upstream data cached at three different speeds, so background regeneration is observable.
check · Watch a panel's age pass its revalidate window, reload to trigger regeneration (you get the stale value), then reload again to see the new stamp.
upstreams: coingecko.com · open-meteo.com · frankfurter.dev · api.github.com
Cache tags and invalidation
/tagsThe same tagged data invalidated four different ways, plus a real mutation to prove read-your-own-writes.
check · Precision vs speed: revalidateTag(tag, 'max') re-executes only the tagged scope but needs two reloads; the immediate calls (updateTag, {expire: 0}) move every cached scope on the route. refresh() moves nothing.
upstreams: open-meteo.com · in-app mutable store
cacheLife profiles
/cache-lifeEvery built-in and custom profile side by side, including four probes that straddle the documented prerender thresholds.
check · After a build, check which probes shipped in the static HTML: stale < 30s and expire < 5min become dynamic holes instead.
upstreams: random.org
Cache scopes
/scopes'use cache' vs 'use cache: remote' vs 'use cache: private' vs the legacy tagged fetch — the same numeric probe through four different storage paths.
check · Open in two different browsers. The shared and remote panels show the same number in both; the private one differs per client.
upstreams: random.org
Streaming and waterfalls
/streamingSequential awaits against parallel ones, with staggered upstream delays, and Suspense boundaries at three granularities.
check · The sequential column takes the sum of its delays; the parallel column takes the max. Per-item boundaries fill one at a time.
upstreams: open-meteo.com + injected delay
ISR fallback and App Shell upgrade
/isr-fallbackgenerateStaticParams prerenders two regions and one station each; everything else gets the App Shell instantly, then upgrades in the background.
check · Count Suspense fallbacks per URL shape: none when both params are known, one for a known region with an unlisted station (a partial shell), two when neither is known — and none on the second visit.
upstreams: open-meteo.com
Instant navigation
/instantThe instant route segment config, with tabs that validate clean and one that deliberately blocks.
check · In dev, the blocking tab raises an insight in the error overlay naming the component. In next start, tab switches paint with no gap.
upstreams: open-meteo.com + injected delay
Proxy (was middleware)
/proxyproxy.ts branches for direct responses, redirects, rewrites, header injection and A/B bucketing — plus what it deliberately cannot do.
check · Proxy runs even for statically prerendered routes: curl -sI /ssg three times and x-proxy-invocation differs every time while the body never changes.
upstreams: none — proxy must not fetch data
Prefetch behaviour
/prefetchSegment-level prefetch config and Link prefetch props, with a network-tab recipe for seeing what each one actually requests.
check · In next start with the network tab open, scroll the links into view and compare requests for partial, default and force-disabled destinations.
upstreams: open-meteo.com
Intercepting routes
/interceptOne URL rendered as a modal on client navigation and as a full page on a hard load, selected by the Next-Url request header.
check · Click a photo for the modal, refresh for the full page. With curl, RSC requests with and without Next-Url: /intercept redirect to different _rsc values and return different payloads.
upstreams: none
Route handlers
These are the in-app APIs. They give you a request-time signal that no external service can rate limit, and a webhook-shaped way to invalidate tags from outside the app.
Self-test, as a stream
openThe self-test suite as newline-delimited JSON, one event per check. GET is read-only; POST with ?confirm=mutate-cache also runs the invalidation scenarios.
curl -N 'localhost:3000/api/selftest?suite=quick'
Per-request timestamp
openUncached route handler. Supports ?delay=<ms> to simulate a slow backend.
curl -s localhost:3000/api/now
Mutable store
openGET reads the in-app store; POST adjusts stock and invalidates the inventory tag.
curl -s localhost:3000/api/inventory
Webhook-shaped invalidation
openrevalidateTag from a route handler, where updateTag is unavailable. Accepts ?tag=, ?path= and ?mode=stale|now.
curl -s 'localhost:3000/api/revalidate?tag=stations&mode=now'
Guarded by proxy.ts
openThe proxy returns 401 directly and this handler never runs. Add the authorization header to let it through.
curl -si localhost:3000/api/proxy-guard | head -5
Cached route handler
openA route handler wrapping its work in 'use cache', tagged so it can be invalidated.
curl -s localhost:3000/api/cached-time
Further reading
TESTING.md in the repo root has the full route table, the shell one-liners for reading stamps out of the markup, the cacheLife prerender thresholds, and the behaviours that were measured in this app — including the trade-off between revalidateTag(tag, 'max') and the immediate invalidation calls, which is not obvious from the API surface.