# Render Strategy Decision

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

- Skill: `jaykim88/render-strategy-decision` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jaykim88/render-strategy-decision`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jaykim88/render-strategy-decision/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: JayKim88 (https://skillmd.com/u/jaykim88)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jaykim88/render-strategy-decision

---


# 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

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

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

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

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

5. **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)

6. **Document the decision per route**
   - Comment at top of `page.tsx`: `// Strategy: ISR @ 60s — product list updates hourly`
   - For non-obvious choices: ADR

7. **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
- [ ] Every route has an explicit strategy (comment or ADR)
- [ ] `fetch` options align with route strategy (no mismatches)
- [ ] Route segment config (`dynamic`, `revalidate`) explicitly set where it matters
- [ ] Suspense boundaries are granular (no full-route blocking on a single slow fetch); LCP content not behind Suspense
- [ ] Build output confirms each route's actual render mode matches its documented strategy (no silent dynamic opt-in)

## Output
- **Route files**: explicit `dynamic` / `revalidate` / `fetchCache` segment config per route
- **Per-route strategy comment** at top of `page.tsx`:
  ```ts
  // Strategy: ISR @ 60s — product list updates hourly; cache hit OK for ~5 min staleness
  ```
- **ADR**: `docs/adr/ADR-NNN-rendering-strategy.md` for non-obvious choices
- **Route inventory** (paste into PR description): table of route / strategy / fetch options / justification

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

