configure-isg
Wire ISG revalidation for the project's adapter: route-level `timeRevalidate`/`webhookRevalidate`, the authenticated `/__pracht/revalidate` webhook, Cloudflare Workers Cache, Vercel ISR, and cache-key pitfalls — then verify locally. Use for "set up ISG", "configure revalidation",
Install
npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/configure-isg
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
git clone https://github.com/JoviDeCroock/pracht.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jovidecroock/pracht collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Pracht Configure ISG
ISG renders at build time and regenerates after a time window or an
authenticated webhook (https://pracht.resynapse.dev/docs/rendering#isg--incremental-static-generation, https://pracht.resynapse.dev/docs/adapters). The
mechanics differ per adapter, and a route with render: "isg" but no
revalidate policy never regenerates — the prerenderer only writes an ISG
manifest entry when a policy exists. This skill wires the policy, the
webhook, and the adapter-specific cache correctly.
Step 1: Identify adapter and candidate routes
MCP: when the pracht MCP server is registered (pracht dev-mcp), prefer its
inspect_routes/inspect_api/inspect_build/doctor/verify tools over
shelling out.
pracht inspect routes --json # render + revalidate per route
pracht inspect build --json # adapterTarget (requires a prior `pracht build`)
Prerequisite: pracht inspect needs a vite config with the pracht plugin
wired up. If unsure which routes deserve ISG at all, run
/tune-render-mode first — this skill assumes the mode choice is made.
Step 2: Wire the revalidate policy (manifest router)
// src/routes.ts
import { timeRevalidate, webhookRevalidate } from "@pracht/core";
route("/pricing", () => import("./routes/pricing.tsx"), {
render: "isg",
revalidate: [timeRevalidate(3600), webhookRevalidate()],
});
timeRevalidate(seconds)— requires a positive integer; anything else throws at manifest evaluation.webhookRevalidate()— no arguments; opts the route into the webhook endpoint.revalidateaccepts one policy or an array (RouteRevalidate); the array above means "hourly, or sooner when a webhook names this path".
Pages router: time-based ISG is expressed with two static page exports:
export const RENDER_MODE = "isg";
export const REVALIDATE = 3600;
REVALIDATE must be a positive integer literal number of seconds. Build,
doctor, and verify reject a missing or misplaced policy. Pages mode does
not accept policies on _app or 404, and fenced Markdown/MDX examples are
ignored. It does not support webhook or combined policies; eject with generateRoutesFile
from @pracht/vite-plugin/pages-router for those.
For dynamic routes, getStaticPaths() enumerates the prerendered params.
Paths it did not enumerate render per-request without a cached copy, and
webhooks naming them are skipped on Node/Cloudflare (nothing to refresh).
Step 3: The revalidation webhook
All adapters expose POST <base>/__pracht/revalidate
(PRACHT_REVALIDATE_ENDPOINT from @pracht/core). For example, an app with
base: "/app/" uses:
curl -X POST https://example.com/app/__pracht/revalidate \
-H "Authorization: Bearer $PRACHT_REVALIDATE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"paths":["/pricing"]}'
At the default base: "/", omit /app. Keep request-body paths base-free;
they identify manifest routes rather than public deployment URLs.
- Auth:
PRACHT_REVALIDATE_TOKENenv var; fails closed with401when unset or wrong. Providers that can't send bearer auth may use thex-pracht-revalidate-tokenheader instead. - Body:
pathsarray, max 64 entries (else400). Response reportsrevalidated/skipped/failedarrays plus adetailsarray naming why each path was skipped (not_a_route,not_isg,not_prerendered,no_webhook_policy) or failed — checkdetailsfirst when a webhook appears to do nothing.scopesays how far a refresh reached:instance(Node: that server only),colo(Cloudflare Cache API: that data center only), orglobal(Netlify, Vercel, Cloudflare routes purged viacache: true). Failed paths keep serving the previous copy. Regeneration is single-flighted per path and never replays the caller's cookies/auth headers.
Step 4: Adapter mechanics
| Adapter | Time revalidation | Webhook revalidation |
|---|---|---|
| Node | File mtime vs window, per server; serves stale, refreshes in background | Regenerates the on-disk HTML synchronously on the receiving replica only — serve ISG routes from a single instance |
| Cloudflare (default) | Worker-managed Cache API timestamp, env.ASSETS fallback — per colo |
Overwrites the Cache API entry in the receiving colo only (scope: "colo"); a colo with no cached copy serves the build snapshot as stale and re-renders, a colo holding a copy keeps it until eviction |
Cloudflare (cache: true) |
Edge-tier Workers Caching in front of the Worker for time-revalidated routes | Webhook-only routes keep the worker-managed path; time+webhook routes also get their edge entry purged |
| Vercel | Build Output prerender functions: .prerender-config.json with expiration from the time policy and build HTML as fallback, next to a Node Serverless Function per ISG route (Vercel rejects ISR on an Edge Function) |
For webhook revalidation, x-vercel-cache verifies the bypass; PRACHT_REVALIDATE_TOKEN becomes the bypassToken and must be set at build time (runtime-only setting → webhook paths report failed until you rebuild). Time-only ISR does not require the token. |
Cloudflare specifics (https://pracht.resynapse.dev/docs/adapters#isg-and-workers-caching):
- The default per-colo path needs no extra config. The
cache: trueupgrade needs bothcloudflareAdapter({ cache: true })in vite config (optionally{ cache: { staleWhileRevalidate: <seconds> } }) and{ "cache": { "enabled": true } }in wrangler config. - With it on, time-revalidated pages are no longer emitted as build-time snapshots (first request after deploy renders cold); webhook-only routes keep their snapshots.
- Programmatic purge:
purgeCache/routeCacheTagfrom@pracht/adapter-cloudflare/cache— protect any purge API route with a secret. - Cache safety: responses with
Set-Cookie,Cache-Control: private/no-store, orVary: Cookie/Authorization/*are never stored in the shared edge cache.
Step 5: Cache-key cardinality caveats
See https://pracht.resynapse.dev/docs/adapters#isg-and-workers-caching. Node and worker-managed
Cloudflare ISG key generated pages by pathname. Workers Caching keys by
exact path plus query string (param order and trailing slash included), so
/pricing?ref=a and /pricing?ref=b are independent entries with independent
revalidation — and attacker-chosen query values create unbounded cold entries.
Before enabling cache: true, canonicalize or reject stray query params (the
docs describe an uncached-gateway pattern), and note that routes exporting
markdown or declaring middleware-owned markdown: true carry Vary: Accept,
which multiplies variants per Accept string. Vercel prerender functions are
generated with allowQuery: [], so
query strings do not fragment that cache. Middleware never runs for cached ISG
hits on any adapter — keep per-visitor logic on SSR routes.
Step 6: Verify locally
- Node:
PRACHT_REVALIDATE_TOKEN=dev-secret pracht preview --port 3000(builds, then runsdist/server/server.jswith inherited env;--skip-buildreuses a build). Then curl the Step 3 command againstlocalhost:3000and check the JSON reports your path underrevalidated; re-fetch the page and confirm the change. For time policies, use a short window (e.g.timeRevalidate(5)), request after expiry — first response is the stale copy, the next one is fresh. - Cloudflare:
pracht previewdelegates towrangler dev; putPRACHT_REVALIDATE_TOKENin.dev.varsso the worker sees it. - Vercel: no faithful local production runtime —
pracht previewpoints atvercel build/vercel dev; verify webhook behavior on a real deployment built withPRACHT_REVALIDATE_TOKENset.
Finish with the standard gate — and run /pre-deploy before shipping:
pracht verify --json
pracht typegen # if src/routes.ts changed
Rules
- Never overwrite
wrangler.jsonc/wrangler.tomlorvercel.json— diff and merge, confirming collisions withAskUserQuestion. - Never propose ISG for personalized responses. ISG HTML renders always run
on a sanitized request (
GET,Accept: text/html, path only — no cookies, credentials, query, or body); Cloudflare Workers Caching uses the same isolation withAccept: text/markdownfor its markdown cache variant. A loader that reads the session therefore sees an anonymous visitor. On top of that,Set-CookieorCache-Control: private/no-storeoutput fails regeneration on Node and Cloudflare (on Vercel the credential headers are stripped and the mismatch is logged), andVary: Cookie/Authorization/*is kept out of shared caches by design. - Always pair
render: "isg"with an explicitrevalidatepolicy — without one the route silently behaves like SSG. - On Vercel, set
PRACHT_REVALIDATE_TOKENin the build environment, not just at runtime. - Webhook invalidation is per-colo on Cloudflare's default path and
per-instance on Node (the response's
scopesays which);pracht buildwarns about Cloudflare webhook routes. A webhook-only route never goes stale by time, so pair it withtimeRevalidate()to bound the lag, pluscache: trueon Cloudflare so the webhook purges every colo.
$ARGUMENTS
Files (pracht)
-
SKILL.md 9.7 KB
--- name: configure-isg version: 1.0.1 description: | Wire ISG revalidation for the project's adapter: route-level `timeRevalidate`/`webhookRevalidate`, the authenticated `/__pracht/revalidate` webhook, Cloudflare Workers Cache, Vercel ISR, and cache-key pitfalls — then verify locally. Use for "set up ISG", "configure revalidation", "add a revalidation webhook", "my ISG page never updates", "cache this page at the edge". allowed-tools: - Bash - Read - Edit - Write - Grep - Glob - AskUserQuestion --- # Pracht Configure ISG ISG renders at build time and regenerates after a time window or an authenticated webhook (<https://pracht.resynapse.dev/docs/rendering#isg--incremental-static-generation>, <https://pracht.resynapse.dev/docs/adapters>). The mechanics differ per adapter, and a route with `render: "isg"` but **no `revalidate` policy never regenerates** — the prerenderer only writes an ISG manifest entry when a policy exists. This skill wires the policy, the webhook, and the adapter-specific cache correctly. ## Step 1: Identify adapter and candidate routes MCP: when the pracht MCP server is registered (`pracht dev-mcp`), prefer its `inspect_routes`/`inspect_api`/`inspect_build`/`doctor`/`verify` tools over shelling out. ```bash pracht inspect routes --json # render + revalidate per route pracht inspect build --json # adapterTarget (requires a prior `pracht build`) ``` Prerequisite: `pracht inspect` needs a vite config with the pracht plugin wired up. If unsure *which* routes deserve ISG at all, run `/tune-render-mode` first — this skill assumes the mode choice is made. ## Step 2: Wire the revalidate policy (manifest router) ```typescript // src/routes.ts import { timeRevalidate, webhookRevalidate } from "@pracht/core"; route("/pricing", () => import("./routes/pricing.tsx"), { render: "isg", revalidate: [timeRevalidate(3600), webhookRevalidate()], }); ``` - `timeRevalidate(seconds)` — requires a positive **integer**; anything else throws at manifest evaluation. - `webhookRevalidate()` — no arguments; opts the route into the webhook endpoint. - `revalidate` accepts one policy or an array (`RouteRevalidate`); the array above means "hourly, or sooner when a webhook names this path". **Pages router:** time-based ISG is expressed with two static page exports: ```ts export const RENDER_MODE = "isg"; export const REVALIDATE = 3600; ``` `REVALIDATE` must be a positive integer literal number of seconds. Build, `doctor`, and `verify` reject a missing or misplaced policy. Pages mode does not accept policies on `_app` or `404`, and fenced Markdown/MDX examples are ignored. It does not support webhook or combined policies; eject with `generateRoutesFile` from `@pracht/vite-plugin/pages-router` for those. For dynamic routes, `getStaticPaths()` enumerates the prerendered params. Paths it did not enumerate render per-request without a cached copy, and webhooks naming them are `skipped` on Node/Cloudflare (nothing to refresh). ## Step 3: The revalidation webhook All adapters expose `POST <base>/__pracht/revalidate` (`PRACHT_REVALIDATE_ENDPOINT` from `@pracht/core`). For example, an app with `base: "/app/"` uses: ```sh curl -X POST https://example.com/app/__pracht/revalidate \ -H "Authorization: Bearer $PRACHT_REVALIDATE_TOKEN" \ -H "Content-Type: application/json" \ -d '{"paths":["/pricing"]}' ``` At the default `base: "/"`, omit `/app`. Keep request-body paths base-free; they identify manifest routes rather than public deployment URLs. - Auth: `PRACHT_REVALIDATE_TOKEN` env var; fails closed with `401` when unset or wrong. Providers that can't send bearer auth may use the `x-pracht-revalidate-token` header instead. - Body: `paths` array, max 64 entries (else `400`). Response reports `revalidated` / `skipped` / `failed` arrays plus a `details` array naming why each path was skipped (`not_a_route`, `not_isg`, `not_prerendered`, `no_webhook_policy`) or failed — check `details` first when a webhook appears to do nothing. `scope` says how far a refresh reached: `instance` (Node: that server only), `colo` (Cloudflare Cache API: that data center only), or `global` (Netlify, Vercel, Cloudflare routes purged via `cache: true`). Failed paths keep serving the previous copy. Regeneration is single-flighted per path and never replays the caller's cookies/auth headers. ## Step 4: Adapter mechanics | Adapter | Time revalidation | Webhook revalidation | | ------- | ----------------- | -------------------- | | Node | File mtime vs window, per server; serves stale, refreshes in background | Regenerates the on-disk HTML synchronously on the **receiving replica only** — serve ISG routes from a single instance | | Cloudflare (default) | Worker-managed Cache API timestamp, `env.ASSETS` fallback — **per colo** | Overwrites the Cache API entry in the receiving colo only (`scope: "colo"`); a colo with no cached copy serves the build snapshot as `stale` and re-renders, a colo holding a copy keeps it until eviction | | Cloudflare (`cache: true`) | Edge-tier Workers Caching in front of the Worker for time-revalidated routes | Webhook-only routes keep the worker-managed path; time+webhook routes also get their edge entry purged | | Vercel | Build Output prerender functions: `.prerender-config.json` with `expiration` from the time policy and build HTML as fallback, next to a Node Serverless Function per ISG route (Vercel rejects ISR on an Edge Function) | For webhook revalidation, `x-vercel-cache` verifies the bypass; `PRACHT_REVALIDATE_TOKEN` becomes the `bypassToken` and **must be set at build time** (runtime-only setting → webhook paths report `failed` until you rebuild). Time-only ISR does not require the token. | Cloudflare specifics (<https://pracht.resynapse.dev/docs/adapters#isg-and-workers-caching>): - The default per-colo path needs **no extra config**. The `cache: true` upgrade needs both `cloudflareAdapter({ cache: true })` in vite config (optionally `{ cache: { staleWhileRevalidate: <seconds> } }`) **and** `{ "cache": { "enabled": true } }` in wrangler config. - With it on, time-revalidated pages are no longer emitted as build-time snapshots (first request after deploy renders cold); webhook-only routes keep their snapshots. - Programmatic purge: `purgeCache` / `routeCacheTag` from `@pracht/adapter-cloudflare/cache` — protect any purge API route with a secret. - Cache safety: responses with `Set-Cookie`, `Cache-Control: private`/`no-store`, or `Vary: Cookie`/`Authorization`/`*` are never stored in the shared edge cache. ## Step 5: Cache-key cardinality caveats See <https://pracht.resynapse.dev/docs/adapters#isg-and-workers-caching>. Node and worker-managed Cloudflare ISG key generated pages by **pathname**. Workers Caching keys by exact path **plus query string** (param order and trailing slash included), so `/pricing?ref=a` and `/pricing?ref=b` are independent entries with independent revalidation — and attacker-chosen query values create unbounded cold entries. Before enabling `cache: true`, canonicalize or reject stray query params (the docs describe an uncached-gateway pattern), and note that routes exporting `markdown` or declaring middleware-owned `markdown: true` carry `Vary: Accept`, which multiplies variants per `Accept` string. Vercel prerender functions are generated with `allowQuery: []`, so query strings do not fragment that cache. Middleware never runs for cached ISG hits on any adapter — keep per-visitor logic on SSR routes. ## Step 6: Verify locally - **Node**: `PRACHT_REVALIDATE_TOKEN=dev-secret pracht preview --port 3000` (builds, then runs `dist/server/server.js` with inherited env; `--skip-build` reuses a build). Then curl the Step 3 command against `localhost:3000` and check the JSON reports your path under `revalidated`; re-fetch the page and confirm the change. For time policies, use a short window (e.g. `timeRevalidate(5)`), request after expiry — first response is the stale copy, the next one is fresh. - **Cloudflare**: `pracht preview` delegates to `wrangler dev`; put `PRACHT_REVALIDATE_TOKEN` in `.dev.vars` so the worker sees it. - **Vercel**: no faithful local production runtime — `pracht preview` points at `vercel build`/`vercel dev`; verify webhook behavior on a real deployment built with `PRACHT_REVALIDATE_TOKEN` set. Finish with the standard gate — and run `/pre-deploy` before shipping: ```bash pracht verify --json pracht typegen # if src/routes.ts changed ``` ## Rules 1. Never overwrite `wrangler.jsonc`/`wrangler.toml` or `vercel.json` — diff and merge, confirming collisions with `AskUserQuestion`. 2. Never propose ISG for personalized responses. ISG HTML renders always run on a sanitized request (`GET`, `Accept: text/html`, path only — no cookies, credentials, query, or body); Cloudflare Workers Caching uses the same isolation with `Accept: text/markdown` for its markdown cache variant. A loader that reads the session therefore sees an anonymous visitor. On top of that, `Set-Cookie` or `Cache-Control: private`/`no-store` output fails regeneration on Node and Cloudflare (on Vercel the credential headers are stripped and the mismatch is logged), and `Vary: Cookie`/`Authorization`/`*` is kept out of shared caches by design. 3. Always pair `render: "isg"` with an explicit `revalidate` policy — without one the route silently behaves like SSG. 4. On Vercel, set `PRACHT_REVALIDATE_TOKEN` in the build environment, not just at runtime. 5. Webhook invalidation is per-colo on Cloudflare's default path and per-instance on Node (the response's `scope` says which); `pracht build` warns about Cloudflare webhook routes. A webhook-only route never goes stale by time, so pair it with `timeRevalidate()` to bound the lag, plus `cache: true` on Cloudflare so the webhook purges every colo. $ARGUMENTS
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.