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, as a timestamp, a short execution id, and a live age.

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 call, so a cache hit shows in the value as well as the timestamp. Every API in this app returns only numbers and ISO timestamps. None return free text, user-submitted content or images.

Read this first

Routes

Self-test, every check at once

/selftest

Requests 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.

Route Handler streaming NDJSONdata-exec / data-fallback attributes

upstreams: none, it only reads this app

GCS tag-write stress test (SITE-6194)

/gcs-stress

Bursts cache writes and revalidations at this instance and counts the real GCS writes to the shared tag mapping, to prove they stay paced under the per-object rate limit and lose no updates.

check · On Pantheon, press "Run GCS stress test". Every check should be green, with tags.json writes at least one flush interval apart.

unstable_cacherevalidateTagGCS object generations

upstreams: none, it only writes this app's cache bucket

SSG, fully prerendered

/ssg

Every 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.

'use cache'cacheLife('max')

upstreams: frankfurter.dev (settled date) · open-meteo.com

SSR, fully dynamic

/ssr

The opposite of /ssg. Nothing is cached, and every scope defers to request time with connection() inside Suspense.

check · Reload repeatedly. Every stamp and probe value changes every time.

connection()<Suspense>cookies()headers()

upstreams: random.org · coingecko.com · open-meteo.com

PPR, shell plus holes

/ppr

The 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.

'use cache'<Suspense>connection()streaming

upstreams: open-meteo.com · coingecko.com

ISR revalidation windows

/isr

Moving upstream data cached at three different speeds, so you can watch background regeneration.

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.

cacheLife('isr-15' | 'isr-60' | 'blog' | 'hours')stale-while-revalidate

upstreams: coingecko.com · open-meteo.com · frankfurter.dev · api.github.com

Cache tags and invalidation

/tags

The 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.

cacheTag()updateTag()revalidateTag()revalidatePath()refresh()

upstreams: open-meteo.com · in-app mutable store

cacheLife profiles

/cache-life

Every 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.

cacheLife()prerender thresholdsApp Shell eligibility

upstreams: random.org

Cache scopes

/scopes

'use cache' vs 'use cache: remote' vs 'use cache: private' vs the legacy tagged fetch vs unstable_cache. Each sends the same numeric probe through a different storage path.

check · Open in two different browsers. The shared and remote panels show the same number in both; the private one differs per client.

'use cache''use cache: remote''use cache: private'fetch force-cacheunstable_cache

upstreams: random.org

Streaming and waterfalls

/streaming

Sequential 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.

<Suspense>Promise.allstreaming order

upstreams: open-meteo.com + injected delay

ISR fallback and App Shell upgrade

/isr-fallback

generateStaticParams 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.

generateStaticParams()partialPrefetchingApp Shellparams in Suspense

upstreams: open-meteo.com

Pages Router ISR

/pages-isr/london

getStaticProps with revalidate: 60. London and Paris are prerendered by getStaticPaths; other stations render on first request through fallback: 'blocking'. The only route whose entries reach cacheHandler as Pages Router entries.

check · Reload: the probe holds. Call /api/pages-revalidate?station=london and the next load has a new probe. Try /pages-isr/tokyo for the fallback path.

getStaticProps revalidategetStaticPaths fallback: 'blocking'res.revalidate()

upstreams: random.org

Instant navigation

/instant

The 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.

export const instantNavigation Inspector<Suspense>

upstreams: open-meteo.com + injected delay

Proxy (was middleware)

/proxy

proxy.ts branches for direct responses, redirects, rewrites, header injection and A/B bucketing, plus what it 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.

proxy.tsmatcher has/missingNextResponsewaitUntilNode.js runtime

upstreams: none, proxy must not fetch data

Prefetch behaviour

/prefetch

Segment-level prefetch config and Link prefetch props, and how to see in the network tab what each one 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.

export const prefetch<Link prefetch>partialPrefetching

upstreams: open-meteo.com

Intercepting routes

/intercept

One 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.

@slot parallel routes(.) intercepting routesdefault.tsxNext-Url

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

open

The 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'

GCS tag-write stress test, as a stream

open

POST with ?confirm=mutate-cache. Streams the SITE-6194 stress run as newline-delimited JSON; accepts ?entries=, ?concurrency= and ?cleanup=0.

curl -N -X POST 'localhost:3000/api/gcs-stress?confirm=mutate-cache'

Per-request timestamp

open

Uncached route handler. Supports ?delay=<ms> to simulate a slow backend.

curl -s localhost:3000/api/now

Mutable store

open

GET reads the in-app store; POST adjusts stock and invalidates the inventory tag.

curl -s localhost:3000/api/inventory

Webhook-shaped invalidation

open

revalidateTag 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'

Pages Router on-demand revalidation

open

res.revalidate() for one /pages-isr station page. Only known station slugs are accepted.

curl -s 'localhost:3000/api/pages-revalidate?station=london'

Guarded by proxy.ts

open

The 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

Route handler with a cached helper

open

A dynamic route handler whose work is wrapped in 'use cache', tagged so it can be invalidated. The response itself is not cached.

curl -s localhost:3000/api/cached-time

Prerendered route handler

open

Reads no request data, so it is prerendered and its whole response is cached as an APP_ROUTE entry by the singular cacheHandler. Tagged scope:route-handler.

curl -si localhost:3000/api/prerendered-probe | grep -i x-nextjs-cache

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 behaviour measured in this app. That includes the trade-off between revalidateTag(tag, 'max') and the immediate invalidation calls, which the API names do not make obvious.