Render Strategy Decision
Purpose
Make every route's rendering strategy explicit and justified by data characteristics. Document the decision so future maintainers don't have to re-derive it from cache and revalidate flags scattered in code.
Universal — the rendering-mode decision (static / incremental / server-rendered / client-only / streaming) exists in every modern meta-framework (Next.js, Nuxt, SvelteKit, Remix, Astro, Analog). Terminology and config syntax vary; the decision tree is the same.
Procedure
Inventory current state
- List all routes in the framework's router directory
- For each: what's the current strategy (static / incremental / server-rendered / client-only)?
- What are the per-route caching / revalidation settings on data fetches?
Mental model: in modern meta-frameworks, streaming + Suspense (or framework equivalent) is the substrate — static / dynamic / hybrid describe the mix within that substrate, not separate alternatives. Many frameworks now allow per-route mixing of static shell + dynamic islands.
Apply the decision tree
| Data characteristic |
Strategy |
| Same for all users + changes rarely (marketing, docs) |
Static (SSG) |
| Same for all users + changes periodically (blog, product list) |
Incremental (ISR) |
| Per-user data (dashboard, profile) |
Server-rendered (dynamic) |
| Real-time + browser APIs (live chat, drag/drop) |
Client-only island |
| Mixed: shell static, slow sections async |
Streaming + Suspense (or framework equivalent) |
See Implementation for the exact per-route config syntax (Next.js / Nuxt / SvelteKit / Remix / Astro).
What forces dynamic (the detection signal) — a route turns dynamic the moment it reads a request-time input: cookies, headers, the URL's search params, or an uncached / no-store fetch. This is usually implicit — one such read deep in a child component silently opts the whole route out of static. When auditing "why isn't this static?", grep for those reads.
Mostly-static + a personalized sliver (avatar, "Hi {name}", cart count) is the most common trap — don't make the whole page dynamic for it. Keep the page static and isolate the personalized part as a streamed island (PPR / Suspense) or a client-side fetch after hydration.
Between two correct options, decide by cost — static and ISR serve from cache (cheap, low TTFB); force-dynamic re-renders every request (server cost, higher TTFB). Pick the most cacheable strategy the data's freshness tolerates.
Align the data layer's caching with the route strategy
- Static route → cache the fetch
- Incremental route → timed revalidation
- Dynamic route → no-store (always fresh)
- Mismatch (e.g., a cached fetch in a dynamic route) → silent staleness bugs
- See Implementation for the concrete fetch/cache API per framework
Place Suspense boundaries close to dynamic data
- Root-level Suspense → entire page blocks on the slowest data
- Per-section Suspense → static shell renders instantly, dynamic sections stream in
- Don't wrap the LCP element in Suspense — streaming it in delays the largest paint; keep LCP content in the static shell and stream only slow / below-the-fold sections
- Coordinate
loading.tsx placement with the smallest fetching subtree (see async-ux-states)
Set the route's strategy config explicitly
- Declare the strategy at the route level rather than relying on framework inference — explicit beats implicit
- See Implementation for the exact segment-config flags (
dynamic / revalidate / fetchCache in Next.js, route rules in Nuxt, prerender/ssr in SvelteKit)
Document the decision per route
- Comment at top of
page.tsx: // Strategy: ISR @ 60s — product list updates hourly
- For non-obvious choices: ADR
Verify (validation loop)
- Build and read the per-route render-mode labels (Next.js
next build, etc.); confirm each route's actual mode matches its intended strategy
- If a route you expected static is built dynamic, find the request-time input that forced it (step 2) — remove it or isolate it to an island, then rebuild
- Loop until every route's built mode matches its documented strategy
Completion Criteria
Output
Implementation
React + Next.js (default)
- Inventory:
find app -name 'page.tsx' -o -name 'page.ts' -o -name 'route.ts'; next build prints a per-route render-mode legend (○ Static / ● SSG / ƒ Dynamic; ISR routes show a revalidate interval) — read it to verify intent (exact glyphs vary by Next version)
- Static: default (no
dynamic flag)
- ISR:
export const revalidate = 60
- Server-rendered:
export const dynamic = 'force-dynamic'
- Client-only:
'use client' directive, wrap minimum interactive section
- Streaming:
<Suspense> boundaries; Next.js 15+ PPR for shell + island mixing
- Fetch alignment:
fetch(url, { cache: 'force-cache' | 'no-store' | { next: { revalidate: N } } })
Other stacks
- Vue / Nuxt: route config in
definePageMeta({ ssr: false, prerender: true }); useFetch + useAsyncData for data caching; Nuxt 3.10+ supports route rules per-page
- SvelteKit:
+page.ts prerender = true (static), ssr = false (client-only), default is dynamic SSR; data loading via load() functions
- Remix / React Router: loaders are dynamic by default; static via
headers() Cache-Control; no built-in ISR (use HTTP caching)
- Astro: static by default;
export const prerender = false for SSR; islands architecture is built-in (client:load, client:visible directives)
- Universal decision tree stands alone — only the per-route config syntax changes
Related skills
rendering-performance — when the wrong strategy is causing CWV issues
api-caching-optimization — fetch options (cache, revalidate) must align with the strategy
async-ux-states — Suspense boundary placement is a render-strategy concern
Reference
- Key insight encoded: Decision flows from data shape, not page type. Per-request user data forces dynamic rendering; build-time-knowable data should be static with
revalidate for ISR. A single request-time read (cookies / headers / searchParams / no-store fetch) silently forces the whole route dynamic — for a mostly-static page with one personalized sliver, isolate that sliver to a streamed island rather than making the page dynamic. Place Suspense boundaries close to the dynamic data access (not at route root, and not around the LCP element) so the static shell renders instantly.
1---2name: render-strategy-decision3description: Choose the right rendering strategy per Next.js route — SSG / ISR / SSR / CSR / Streaming + Suspense — driven by data shape, not by page type. Use when adding a new route, when a route unexpectedly renders dynamically, when performance issues prompt re-evaluation, or when a code review flags inconsistency. Not for diagnosing Core Web Vitals regressions (use rendering-performance) or tuning fetch/cache policies (use api-caching-optimization).4license: MIT5---67# Render Strategy Decision89## Purpose10Make every route's rendering strategy explicit and justified by data characteristics. Document the decision so future maintainers don't have to re-derive it from `cache` and `revalidate` flags scattered in code.1112**Universal** — the rendering-mode decision (static / incremental / server-rendered / client-only / streaming) exists in every modern meta-framework (Next.js, Nuxt, SvelteKit, Remix, Astro, Analog). Terminology and config syntax vary; the decision tree is the same.1314## Procedure15161. **Inventory current state**17 - List all routes in the framework's router directory18 - For each: what's the current strategy (static / incremental / server-rendered / client-only)?19 - What are the per-route caching / revalidation settings on data fetches?2021 **Mental model**: in modern meta-frameworks, streaming + Suspense (or framework equivalent) is the **substrate** — static / dynamic / hybrid describe the mix *within* that substrate, not separate alternatives. Many frameworks now allow per-route mixing of static shell + dynamic islands.22232. **Apply the decision tree**2425 | Data characteristic | Strategy |26 |---|---|27 | Same for all users + changes rarely (marketing, docs) | **Static (SSG)** |28 | Same for all users + changes periodically (blog, product list) | **Incremental (ISR)** |29 | Per-user data (dashboard, profile) | **Server-rendered (dynamic)** |30 | Real-time + browser APIs (live chat, drag/drop) | **Client-only island** |31 | Mixed: shell static, slow sections async | **Streaming + Suspense (or framework equivalent)** |3233 See Implementation for the exact per-route config syntax (Next.js / Nuxt / SvelteKit / Remix / Astro).3435 **What forces dynamic** (the detection signal) — a route turns dynamic the moment it reads a *request-time input*: cookies, headers, the URL's search params, or an uncached / `no-store` fetch. This is usually implicit — one such read deep in a child component silently opts the *whole* route out of static. When auditing "why isn't this static?", grep for those reads.3637 **Mostly-static + a personalized sliver** (avatar, "Hi {name}", cart count) is the most common trap — don't make the whole page dynamic for it. Keep the page static and isolate the personalized part as a streamed island (PPR / Suspense) or a client-side fetch after hydration.3839 **Between two correct options, decide by cost** — static and ISR serve from cache (cheap, low TTFB); `force-dynamic` re-renders every request (server cost, higher TTFB). Pick the most cacheable strategy the data's freshness tolerates.40413. **Align the data layer's caching with the route strategy**42 - Static route → cache the fetch43 - Incremental route → timed revalidation44 - Dynamic route → no-store (always fresh)45 - Mismatch (e.g., a cached fetch in a dynamic route) → silent staleness bugs46 - See Implementation for the concrete fetch/cache API per framework47484. **Place Suspense boundaries close to dynamic data**49 - Root-level Suspense → entire page blocks on the slowest data50 - Per-section Suspense → static shell renders instantly, dynamic sections stream in51 - Don't wrap the LCP element in Suspense — streaming it in delays the largest paint; keep LCP content in the static shell and stream only slow / below-the-fold sections52 - Coordinate `loading.tsx` placement with the smallest fetching subtree (see `async-ux-states`)53545. **Set the route's strategy config explicitly**55 - Declare the strategy at the route level rather than relying on framework inference — explicit beats implicit56 - See Implementation for the exact segment-config flags (`dynamic` / `revalidate` / `fetchCache` in Next.js, route rules in Nuxt, `prerender`/`ssr` in SvelteKit)57586. **Document the decision per route**59 - Comment at top of `page.tsx`: `// Strategy: ISR @ 60s — product list updates hourly`60 - For non-obvious choices: ADR61627. **Verify (validation loop)**63 - Build and read the per-route render-mode labels (Next.js `next build`, etc.); confirm each route's *actual* mode matches its intended strategy64 - If a route you expected static is built dynamic, find the request-time input that forced it (step 2) — remove it or isolate it to an island, then rebuild65 - Loop until every route's built mode matches its documented strategy6667## Completion Criteria68- [ ] Every route has an explicit strategy (comment or ADR)69- [ ] `fetch` options align with route strategy (no mismatches)70- [ ] Route segment config (`dynamic`, `revalidate`) explicitly set where it matters71- [ ] Suspense boundaries are granular (no full-route blocking on a single slow fetch); LCP content not behind Suspense72- [ ] Build output confirms each route's actual render mode matches its documented strategy (no silent dynamic opt-in)7374## Output75- **Route files**: explicit `dynamic` / `revalidate` / `fetchCache` segment config per route76- **Per-route strategy comment** at top of `page.tsx`:77 ```ts78 // Strategy: ISR @ 60s — product list updates hourly; cache hit OK for ~5 min staleness79 ```80- **ADR**: `docs/adr/ADR-NNN-rendering-strategy.md` for non-obvious choices81- **Route inventory** (paste into PR description): table of route / strategy / fetch options / justification8283## Implementation8485### React + Next.js (default)86- Inventory: `find app -name 'page.tsx' -o -name 'page.ts' -o -name 'route.ts'`; `next build` prints a per-route render-mode legend (○ Static / ● SSG / ƒ Dynamic; ISR routes show a `revalidate` interval) — read it to verify intent (exact glyphs vary by Next version)87- Static: default (no `dynamic` flag)88- ISR: `export const revalidate = 60`89- Server-rendered: `export const dynamic = 'force-dynamic'`90- Client-only: `'use client'` directive, wrap minimum interactive section91- Streaming: `<Suspense>` boundaries; Next.js 15+ PPR for shell + island mixing92- Fetch alignment: `fetch(url, { cache: 'force-cache' | 'no-store' | { next: { revalidate: N } } })`9394### Other stacks95- **Vue / Nuxt**: route config in `definePageMeta({ ssr: false, prerender: true })`; `useFetch` + `useAsyncData` for data caching; Nuxt 3.10+ supports route rules per-page96- **SvelteKit**: `+page.ts` `prerender = true` (static), `ssr = false` (client-only), default is dynamic SSR; data loading via `load()` functions97- **Remix / React Router**: loaders are dynamic by default; static via `headers()` Cache-Control; no built-in ISR (use HTTP caching)98- **Astro**: static by default; `export const prerender = false` for SSR; islands architecture is built-in (`client:load`, `client:visible` directives)99- **Universal decision tree** stands alone — only the per-route config syntax changes100101## Related skills102- `rendering-performance` — when the wrong strategy is causing CWV issues103- `api-caching-optimization` — fetch options (cache, revalidate) must align with the strategy104- `async-ux-states` — Suspense boundary placement is a render-strategy concern105106## Reference107- **Key insight encoded**: Decision flows from data shape, not page type. Per-request user data forces dynamic rendering; build-time-knowable data should be static with `revalidate` for ISR. A single request-time read (cookies / headers / searchParams / `no-store` fetch) silently forces the *whole* route dynamic — for a mostly-static page with one personalized sliver, isolate that sliver to a streamed island rather than making the page dynamic. Place Suspense boundaries close to the dynamic data access (not at route root, and not around the LCP element) so the static shell renders instantly.