From 83a5ba6ddaab441b062b50626474865cc8b1b414 Mon Sep 17 00:00:00 2001 From: Miguel Colmenares Date: Tue, 30 Jun 2026 20:51:24 -0500 Subject: [PATCH 1/3] docs(WEB-1069): clarify POST reads stay dynamic; add edge/force-static/use-cache options + 30d/1y tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Separate the data-fetch cache (POST caches WITH next options) from route rendering (POST-read route still emits private/no-store) — the WEB-1069 gotcha - Document the three rendering-layer fixes: CDN edge override (shipped), force-static (interim, needs null-safety + throw-on-5xx), use cache (strategic) - Raise revalidate tiers to 30d (CCDS/WP) and image minimumCacheTTL to 1y - Correct the prior claim that next:{revalidate,tags} alone makes a POST page cacheable Co-Authored-By: Claude Opus 4.8 --- .../instructions/caching.instructions.md | 44 ++++++--- .../shared/skills/nextjs-caching/SKILL.md | 89 +++++++++++++------ 2 files changed, 95 insertions(+), 38 deletions(-) diff --git a/templates/shared/instructions/caching.instructions.md b/templates/shared/instructions/caching.instructions.md index 4103b95..d643c92 100644 --- a/templates/shared/instructions/caching.instructions.md +++ b/templates/shared/instructions/caching.instructions.md @@ -22,10 +22,25 @@ the others. That leaves POST reads uncached and forces the whole route into dynamic rendering (`cache-control: private, no-store`). -2. **A `POST` IS cacheable cross-request — but only with explicit `next` options.** - Next.js does not cache `POST` by default. Any read `fetch` to CCDS or the WP GraphQL endpoint — - GET or POST — must set `next: { revalidate, tags }`. The request body is part of the cache key, so - different filter bodies cache independently. +2. **A `POST` read caches its DATA with `next` options — but the ROUTE still renders dynamically.** + Next.js does not cache `POST` by default; any read `fetch` (GET or POST) to CCDS or the WP GraphQL + endpoint must set `next: { revalidate, tags }` (the body is part of the cache key, so different + filter bodies cache independently). **However** (WEB-1069, Next 16) a page whose data comes from a + POST read still prerenders as `ƒ` (Dynamic) and serves `cache-control: private, no-store` even with + those options — the data-fetch cache and the route's static/dynamic classification are independent. + So `next: { revalidate, tags }` is **necessary but not sufficient** to make a POST-read page + CDN-cacheable; you must also apply a rendering/edge strategy (rule 2b). GET reads ISR natively and + need nothing extra. + +2b. **Make a POST-read page cacheable at the rendering/edge layer.** Pick one (lowest risk first): + - **CDN edge override (current/default):** `src/proxy.ts` matches city/community paths and sets + `Cache-Control: public, s-maxage=43200, stale-while-revalidate=3600`. Origin stays dynamic; the + CDN caches the deterministic-per-URL response. No build-time risk. This is what WEB-1069 shipped. + - **`export const dynamic = "force-static"` (interim):** prerenders the route as real ISR, but a + bad CCDS record/response at build time caches a blank page (the WEB-1058 regression) — use only + when the page is fully null-safe and reads throw on 5xx at runtime. Validate per repo. + - **`cacheComponents: true` + `use cache` (strategic):** PPR migration; do not mix ad hoc with + route-segment `export const revalidate`. 3. **Always pair `tags` with a `revalidate` duration.** `next: { tags }` alone either holds stale data indefinitely or (Next 16 default) does not cache at runtime at all. Use a `CACHE_DURATIONS`-style @@ -34,18 +49,21 @@ the others. 4. **`React.cache()` is request dedup only.** It collapses duplicate calls within a single render. It does **not** cache across requests and is never a substitute for `next: { revalidate }`. -5. **Every cacheable route exports `revalidate`.** Suggested tiers: listing 24h–30d, detail/city 24h - (`86400`), state/landing 7d (`604800`), WP catch-all 7d. Do **not** add `revalidate` to - mutation/personalized routes (forms, thank-you, wizards). Prefer `export const revalidate` over - `dynamic = "force-static"` so a failed ISR revalidation preserves the last good cache instead of - overwriting it with a broken page. +5. **Every cacheable route exports `revalidate`.** CCDS/WP data changes rarely and is refreshed + on-demand (webhooks + tags), so the default is intentionally **long: `2592000` (30d)** for CCDS + reads, `WP_CACHE_DURATIONS`, and page segments alike (WEB-1069). Shorter tiers (24h/7d) are fine + per-route for volatile sources. Do **not** add `revalidate` to mutation/personalized routes (forms, + thank-you, wizards). Default to `export const revalidate` over `dynamic = "force-static"` so a + failed ISR revalidation preserves the last good cache instead of overwriting it with a broken page + (force-static is still a valid POST-read option per rule 2b once null-safety + throw-on-5xx exist). 6. **On-demand revalidation dual-invalidates.** `/api/revalidate` must call `revalidateTag`/ `revalidatePath` **and** the CDN invalidation (e.g. `invalidateCloudFrontPaths`) in the same request, otherwise the CDN keeps serving stale until its own TTL. -7. **Image config** in `next.config`: set `minimumCacheTTL: 2592000` (30d), `qualities`, and - `formats: ["image/avif", "image/webp"]`. No malformed `remotePatterns` hostnames. +7. **Image config** in `next.config`: set `minimumCacheTTL: 31536000` (1y — optimized images are + content-hashed and never change), `qualities`, and `formats: ["image/avif", "image/webp"]`. No + malformed `remotePatterns` hostnames. 8. **Asset proxy TTLs** are type-keyed (image/css/font) at `365d + SWR 30d`. @@ -67,7 +85,7 @@ export async function fetchData({ method = "GET", body = null, revalidateTag, - revalidate = 86400, // 24h time-based fallback; pair with tags for surgical invalidation + revalidate = 2592000, // 30d time-based fallback; pair with tags for surgical invalidation mutation = false, }: FetchOptions): Promise { const isMutation = mutation || method === "PUT" || method === "DELETE"; @@ -91,7 +109,7 @@ export async function fetchData({ ```ts export const WP_CACHE_DURATIONS = { - pages: 86400, posts: 86400, menus: 86400, staticPages: 604800, default: 86400, + pages: 2592000, posts: 2592000, menus: 2592000, staticPages: 2592000, default: 2592000, // 30d } as const; export async function fetchWPAPI( diff --git a/templates/shared/skills/nextjs-caching/SKILL.md b/templates/shared/skills/nextjs-caching/SKILL.md index 9bc2303..3034ab7 100644 --- a/templates/shared/skills/nextjs-caching/SKILL.md +++ b/templates/shared/skills/nextjs-caching/SKILL.md @@ -18,8 +18,14 @@ route — and when diagnosing "why isn't this page cached / why is it serving st ## Core principle **Cache by intent (read vs. mutation), never by the HTTP method.** Some reads must use `POST` because -they take a body (e.g. CCDS `geo-search`). They still have to cache like a `GET`. Mutations must never -cache. Getting this wrong silently turns a static page into a per-request dynamic render. +they take a body (e.g. CCDS `geo-search`). Their *data* still has to cache like a `GET` — send +`next: { revalidate, tags }`, never `no-store`. Mutations must never cache. + +**But caching the data is not the same as making the page static.** A POST read caches its response +cross-request, yet Next.js still renders the *route* dynamically (`private, no-store`). The data-fetch +cache (layer 2) and the route's static/dynamic classification (layers 1/5) are **independent** — see +the two sections below. This is the WEB-1069 gotcha and the reason city/community pages needed an edge +override even though their fetches were already cached. --- @@ -41,20 +47,43 @@ whole route into **dynamic rendering**, which emits `cache-control: private, no- --- -## How Next.js 16 actually decides (the rule behind the rule) +## POST reads: the data caches, but the route stays dynamic (the key gotcha) + +Two independent facts, both true: + +1. **The data fetch caches.** From `next/dist/server/lib/patch-fetch.js`: a `fetch` is uncacheable + only when it has no `next`/`cache` options *and* the segment `revalidate` is `0`. So a POST **with** + `next: { revalidate }` IS cached cross-request — the **request body is part of the cache key** + (`generateCacheKey` hashes `init.body`), so different filter bodies cache independently. This is + real and bounds origin/CCDS load; it is why the WordPress GraphQL POST caches fine. -From `next/dist/server/lib/patch-fetch.js`: +2. **The route still renders dynamically.** Empirically (WEB-1069, Next 16), a page whose data comes + from a POST read prerenders as `ƒ` (Dynamic) and serves `cache-control: private, no-store` — **even + with** `next: { revalidate, tags }` on the fetch. Next treats the POST as request-time data for + prerendering, so ISR (layer 1) never engages. GET reads do not have this problem — they ISR + natively (`s-maxage`, `x-nextjs-cache: HIT`). -- `fetch` is **not cached by default**. A non-`GET` method is "uncacheable" **only when no explicit - cache config is provided**. Concretely, a POST is auto-no-cached only when it has no `next`/`cache` - options *and* the segment `revalidate` is `0`. -- Therefore **a `POST` IS cached cross-request when you pass an explicit `next: { revalidate }`** (and - the segment isn't `revalidate: 0`). This is why the WordPress GraphQL POST caches fine. -- The **request body is part of the cache key** (`incremental-cache` → `generateCacheKey` hashes - `init.body`), so different filter bodies cache independently and never collide. +**Implication:** `next: { revalidate, tags }` on a POST read is **necessary** (data cache) but **not +sufficient** to make the page CDN-cacheable. You must additionally pick a rendering/edge strategy +(next section). `revalidate` + `tags` alone will NOT flip a POST-read page from `private, no-store` to +`public`. -**Implication:** you do **not** need `unstable_cache` or a proxy header override to cache POST reads. -Just send `next: { revalidate, tags }`. +## Making a POST-read page cacheable (rendering / edge layer) + +Pick one (ordered by risk, lowest first): + +1. **CDN edge override (current, lowest risk).** `src/proxy.ts` matches the city/community paths and + sets `Cache-Control: public, s-maxage=43200, stale-while-revalidate=3600`. The origin stays + dynamic; the CDN caches the deterministic-per-URL response. Per-repo regex, no build-time risk — + this is what WEB-1069 shipped. +2. **`export const dynamic = "force-static"` (interim).** Forces the POST read to `force-cache` and + prerenders the route as real ISR (confirmed via `prerender-manifest.json`). Removed in WEB-1058 + because a CCDS failure at build time cached a **blank page**; safer now that reads throw on 5xx at + runtime (a failed revalidation keeps the last good cache), but full prerender is sensitive to + null/bad records — keep the page fully null-safe and validate per repo before adopting. +3. **`cacheComponents: true` + `use cache` (strategic).** Wrap the POST read in `use cache` so its data + lands in the static shell (PPR). Next-recommended long term; larger migration — do **not** mix ad + hoc with route-segment `export const revalidate`. --- @@ -76,7 +105,7 @@ export async function fetchData({ method = "GET", body = null, revalidateTag, - revalidate = 86400, // 24h time-based fallback; pair with tags for surgical invalidation + revalidate = 2592000, // 30d time-based fallback; pair with tags for surgical invalidation mutation = false, }: FetchOptions): Promise { const isMutation = mutation || method === "PUT" || method === "DELETE"; @@ -116,18 +145,25 @@ await fetch(WP_API_URL, { ## ISR revalidate tiers -| Route type | `export const revalidate` | -|------------|---------------------------| -| Listing / index | `2592000` (30d) — webhook handles freshness | -| Detail / city / community (`[slug]`) | `86400` (24h) | -| State / advisor landing | `604800` (7d) | -| WP catch-all (`[[...uri]]`) | `604800` (7d) | +CCDS/WP data changes rarely and is refreshed on-demand via webhooks + tags, so the time-based default +is intentionally **long** (WEB-1069): + +| Route / config | Value | +|----------------|-------| +| CCDS reads (client default) | `2592000` (30d) | +| WordPress GraphQL reads (`WP_CACHE_DURATIONS`) | `2592000` (30d) | +| Listing / detail / city / community / state / landing / WP catch-all | `2592000` (30d) | +| `next.config` image `minimumCacheTTL` | `31536000` (1y) — optimized images are content-hashed, never change | - Every cacheable route exports `revalidate`. Do **not** add it to form/personalized routes. -- Prefer `export const revalidate` over `dynamic = "force-static"`: a failed ISR revalidation then +- The long default is safe because a missed webhook still self-heals within 30d; use `tags` for + immediate surgical invalidation. Shorter tiers (24h/7d) are fine per-route if a source is volatile. +- Default to `export const revalidate` over `dynamic = "force-static"`: a failed ISR revalidation then preserves the last good cache instead of overwriting it with a broken page. Pair with a client that **throws on 5xx at runtime** (so ISR keeps the previous version) but returns an error during the - build phase (so `generateStaticParams` can skip a bad entry without failing the build). + build phase (so `generateStaticParams` can skip a bad entry without failing the build). `force-static` + is still a valid POST-read option (see "Making a POST-read page cacheable") **once** the page is + fully null-safe and the throw-on-5xx guard is in place. --- @@ -146,13 +182,16 @@ if (invalidateCDN) await invalidateCloudFrontPaths([path]); ## Anti-patterns -- ❌ `next: method === "GET" ? {...} : { revalidate: 0 }` — leaves POST reads uncached → the route - renders dynamically (`private, no-store`). This is the exact regression that broke city pages. +- ❌ `next: method === "GET" ? {...} : { revalidate: 0 }` — leaves POST reads uncached (origin hit + every render). Note: even a *correctly* cached POST read still renders the route dynamically — that + needs an edge override, not just `next` options (see "Making a POST-read page cacheable"). - ❌ Bare `fetch(url, { method: "POST", body })` for a read — refetched from origin every render. - ❌ `next: { tags }` with no `revalidate` — holds stale data or doesn't cache at runtime. - ❌ Caching a mutation (`submit`, lead, `PUT`/`DELETE`/`PATCH`). - ❌ Treating `React.cache()` as cross-request caching (it's request-scoped dedup only). -- ❌ `dynamic = "force-static"` on a page whose revalidation can fail (caches a broken page). +- ❌ `dynamic = "force-static"` on a page that is **not** fully null-safe or lacks throw-on-5xx — a bad + CCDS record/response at build time caches a broken/blank page (the WEB-1058 regression). It is a + valid option only once those guards exist. - ❌ Mixing `cacheComponents: true` (`"use cache"`) with route-segment `export const revalidate` — that is a separate, deliberate migration; do not introduce it ad hoc. From 8bb3726841cefca0f954e7aee3180aa315d5495b Mon Sep 17 00:00:00 2001 From: Miguel Colmenares Date: Tue, 30 Jun 2026 21:00:51 -0500 Subject: [PATCH 2/3] docs(WEB-1069): standardize CDN cache window (30d/30d) + expireTime rule - Document expireTime semantics: s-maxage=, SWR=; must be >= largest revalidate. Set 5184000 (60d) for a uniform 30d/30d header. - Update proxy override value to public, s-maxage=2592000, stale-while-revalidate=2592000 Co-Authored-By: Claude Opus 4.8 --- .../shared/instructions/caching.instructions.md | 16 +++++++++++----- templates/shared/skills/nextjs-caching/SKILL.md | 13 ++++++++++--- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/templates/shared/instructions/caching.instructions.md b/templates/shared/instructions/caching.instructions.md index d643c92..ab04b7e 100644 --- a/templates/shared/instructions/caching.instructions.md +++ b/templates/shared/instructions/caching.instructions.md @@ -34,8 +34,9 @@ the others. 2b. **Make a POST-read page cacheable at the rendering/edge layer.** Pick one (lowest risk first): - **CDN edge override (current/default):** `src/proxy.ts` matches city/community paths and sets - `Cache-Control: public, s-maxage=43200, stale-while-revalidate=3600`. Origin stays dynamic; the - CDN caches the deterministic-per-URL response. No build-time risk. This is what WEB-1069 shipped. + `Cache-Control: public, s-maxage=2592000, stale-while-revalidate=2592000` — the same header ISR + pages emit (see rule 7 `expireTime`), so the CDN policy is uniform. Origin stays dynamic; the CDN + caches the deterministic-per-URL response. No build-time risk. This is what WEB-1069 shipped. - **`export const dynamic = "force-static"` (interim):** prerenders the route as real ISR, but a bad CCDS record/response at build time caches a blank page (the WEB-1058 regression) — use only when the page is fully null-safe and reads throw on 5xx at runtime. Validate per repo. @@ -61,9 +62,14 @@ the others. `revalidatePath` **and** the CDN invalidation (e.g. `invalidateCloudFrontPaths`) in the same request, otherwise the CDN keeps serving stale until its own TTL. -7. **Image config** in `next.config`: set `minimumCacheTTL: 31536000` (1y — optimized images are - content-hashed and never change), `qualities`, and `formats: ["image/avif", "image/webp"]`. No - malformed `remotePatterns` hostnames. +7. **`next.config` cache config.** `expireTime` sets the CDN stale window: Next emits + `s-maxage=, stale-while-revalidate=` for ISR pages, so + `expireTime` **must be ≥ the largest page `revalidate`**. Set `expireTime: 5184000` (60d) so a 30d + page yields `s-maxage=2592000, stale-while-revalidate=2592000` — the same header the proxy sets on + dynamic pages (rule 2b). (WEB-1069 bug: `expireTime: 86400` under a 30d revalidate = invalid stale + window.) Also set image `minimumCacheTTL: 31536000` (1y — optimized images are content-hashed and + never change), `qualities`, and `formats: ["image/avif", "image/webp"]`. No malformed + `remotePatterns` hostnames. 8. **Asset proxy TTLs** are type-keyed (image/css/font) at `365d + SWR 30d`. diff --git a/templates/shared/skills/nextjs-caching/SKILL.md b/templates/shared/skills/nextjs-caching/SKILL.md index 3034ab7..42fc105 100644 --- a/templates/shared/skills/nextjs-caching/SKILL.md +++ b/templates/shared/skills/nextjs-caching/SKILL.md @@ -73,9 +73,10 @@ sufficient** to make the page CDN-cacheable. You must additionally pick a render Pick one (ordered by risk, lowest first): 1. **CDN edge override (current, lowest risk).** `src/proxy.ts` matches the city/community paths and - sets `Cache-Control: public, s-maxage=43200, stale-while-revalidate=3600`. The origin stays - dynamic; the CDN caches the deterministic-per-URL response. Per-repo regex, no build-time risk — - this is what WEB-1069 shipped. + sets `Cache-Control: public, s-maxage=2592000, stale-while-revalidate=2592000` — the **same** + header the ISR pages emit (see `expireTime` under ISR tiers), so the CDN policy is uniform across + static and dynamic routes. The origin stays dynamic; the CDN caches the deterministic-per-URL + response. Per-repo regex, no build-time risk — this is what WEB-1069 shipped. 2. **`export const dynamic = "force-static"` (interim).** Forces the POST read to `force-cache` and prerenders the route as real ISR (confirmed via `prerender-manifest.json`). Removed in WEB-1058 because a CCDS failure at build time cached a **blank page**; safer now that reads throw on 5xx at @@ -153,9 +154,15 @@ is intentionally **long** (WEB-1069): | CCDS reads (client default) | `2592000` (30d) | | WordPress GraphQL reads (`WP_CACHE_DURATIONS`) | `2592000` (30d) | | Listing / detail / city / community / state / landing / WP catch-all | `2592000` (30d) | +| `next.config` `expireTime` (CDN stale window) | `5184000` (60d) → emits `s-maxage=2592000, stale-while-revalidate=2592000` | | `next.config` image `minimumCacheTTL` | `31536000` (1y) — optimized images are content-hashed, never change | - Every cacheable route exports `revalidate`. Do **not** add it to form/personalized routes. +- **`expireTime` sets the CDN stale window.** Next emits `s-maxage=, + stale-while-revalidate=` for ISR pages, so `expireTime` **must be ≥ the + largest `revalidate`** or the stale window is invalid (the WEB-1069 bug: `expireTime: 86400` under a + 30d revalidate). Set it to `5184000` (60d) so a 30d page yields the same header the proxy sets on + dynamic pages: `s-maxage=2592000, stale-while-revalidate=2592000`. - The long default is safe because a missed webhook still self-heals within 30d; use `tags` for immediate surgical invalidation. Shorter tiers (24h/7d) are fine per-route if a source is volatile. - Default to `export const revalidate` over `dynamic = "force-static"`: a failed ISR revalidation then From 859c886878a2e33d43e90fce74f4af1253bea1f4 Mon Sep 17 00:00:00 2001 From: Miguel Colmenares Date: Wed, 1 Jul 2026 11:18:25 -0500 Subject: [PATCH 3/3] chore: bump version to 2.5.1 --- CHANGELOG.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/index.js | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1d41a..f4f145f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.5.1] - 2026-07-01 + +### Fixed + +- **`caching.instructions.md` / `nextjs-caching` skill — corrected the POST-read caching claim** (WEB-1069). The previous guidance implied `next: { revalidate, tags }` alone was sufficient to make a POST-read page (e.g. CCDS `geo-search`, which powers city/community pages) CDN-cacheable. Production testing showed the **data** fetch caches with those options, but the **route** still renders dynamically (`cache-control: private, no-store`) — the two are independent. Both documents now separate the data-fetch cache fact from the route-rendering fact and document the three fixes: CDN edge override (`proxy.ts`, shipped default), `dynamic = "force-static"` (interim, requires null-safety + throw-on-5xx), and `cacheComponents` + `use cache` (strategic). +- **Revalidate tiers raised to 30 days; image `minimumCacheTTL` to 1 year** in both documents — CCDS/WP data changes rarely and is refreshed on-demand via webhooks + tags, so the previous 24h/30d defaults were unnecessarily short. +- **Documented the `expireTime` / CDN stale-window rule** — Next emits `s-maxage=, stale-while-revalidate=` for ISR pages, so `expireTime` must be ≥ the largest page `revalidate` or the stale window is invalid. Added the correct value (`5184000`, 60d) and the matching proxy override value (`s-maxage=2592000, stale-while-revalidate=2592000`) so ISR and edge-overridden dynamic pages present one consistent CDN policy. + ## [2.5.0] - 2026-06-30 ### Added diff --git a/package-lock.json b/package-lock.json index 7027382..d2d0e46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@silverassist/agents-toolkit", - "version": "2.5.0", + "version": "2.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@silverassist/agents-toolkit", - "version": "2.5.0", + "version": "2.5.1", "license": "PolyForm-Noncommercial-1.0.0", "bin": { "agents-toolkit": "bin/cli.js" diff --git a/package.json b/package.json index 6727b03..3256335 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@silverassist/agents-toolkit", - "version": "2.5.0", + "version": "2.5.1", "description": "Reusable AI agent prompts for development workflows with Jira integration — supports GitHub Copilot, Claude Code, and Codex", "author": "Santiago Ramirez", "license": "PolyForm-Noncommercial-1.0.0", diff --git a/src/index.js b/src/index.js index bc4c6d0..6dd8899 100644 --- a/src/index.js +++ b/src/index.js @@ -3,7 +3,7 @@ * @module @silverassist/agents-toolkit */ -export const VERSION = "2.5.0"; +export const VERSION = "2.5.1"; export const PROMPTS = { workflow: [