Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<revalidate>, stale-while-revalidate=<expireTime - 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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* @module @silverassist/agents-toolkit
*/

export const VERSION = "2.5.0";
export const VERSION = "2.5.1";

export const PROMPTS = {
workflow: [
Expand Down
50 changes: 37 additions & 13 deletions templates/shared/instructions/caching.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,26 @@ 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=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.
- **`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
Expand All @@ -34,18 +50,26 @@ 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. **`next.config` cache config.** `expireTime` sets the CDN stale window: Next emits
`s-maxage=<revalidate>, stale-while-revalidate=<expireTime − 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`.

Expand All @@ -67,7 +91,7 @@ export async function fetchData<T>({
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<T | null> {
const isMutation = mutation || method === "PUT" || method === "DELETE";
Expand All @@ -91,7 +115,7 @@ export async function fetchData<T>({

```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<T>(
Expand Down
102 changes: 74 additions & 28 deletions templates/shared/skills/nextjs-caching/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand All @@ -41,20 +47,44 @@ whole route into **dynamic rendering**, which emits `cache-control: private, no-

---

## How Next.js 16 actually decides (the rule behind the rule)

From `next/dist/server/lib/patch-fetch.js`:

- `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:** you do **not** need `unstable_cache` or a proxy header override to cache POST reads.
Just send `next: { revalidate, tags }`.
## 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.

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`).

**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`.

## 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=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
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`.

---

Expand All @@ -76,7 +106,7 @@ export async function fetchData<T>({
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<T | null> {
const isMutation = mutation || method === "PUT" || method === "DELETE";
Expand Down Expand Up @@ -116,18 +146,31 @@ 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` `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.
- Prefer `export const revalidate` over `dynamic = "force-static"`: a failed ISR revalidation then
- **`expireTime` sets the CDN stale window.** Next emits `s-maxage=<revalidate>,
stale-while-revalidate=<expireTime − 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
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.

---

Expand All @@ -146,13 +189,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.

Expand Down