3.1 — Choosing a rendering strategy¶
Module 3 · Lesson 1 · 🟡 Intermediate · ~35 min
What you'll learn¶
- What each rendering mode actually costs and delivers
- A decision tree that maps commerce page types to modes
- How one dynamic API call silently converts a whole route to SSR
- The migration path when the answer for your page is "the other one"
The modes¶
| Mode | HTML generated | TTFB | Freshness | Cacheable at CDN | Cost per request |
|---|---|---|---|---|---|
| Static (SSG) | Build time | ~20 ms (edge) | Stale until rebuild | ✅ Fully | ~0 |
| ISR | Build/first request, regenerated on a timer or on demand | ~20 ms (hit) | Bounded staleness | ✅ Fully | ~0 on hit |
| SSR (dynamic) | Every request | 200–1500 ms | Perfect | ❌ (usually) | Full render + data |
| PPR | Static shell + dynamic holes streamed | ~20 ms to first byte | Shell stale, holes fresh | ✅ Shell | Partial |
| CSR | Client, after JS loads | ~20 ms (empty) | Fresh | ✅ (empty shell) | ~0 server, high client |
The commerce insight: the pages that make you the most money (PDP, PLP) are mostly static with a few dynamic elements — price for your segment, stock at your store, your cart count. The whole game is not letting those few elements force the whole page into SSR.
The decision tree¶
Is the page's content identical for all users at a given moment?
│
├─ YES ──▶ Does it change often?
│ ├─ Rarely (marketing, guides, legal) ────────▶ STATIC (SSG)
│ └─ Regularly (PLP, PDP, home) ───────────────▶ ISR
│ (+ on-demand revalidation)
│
└─ NO ───▶ What fraction of the page is personalized?
├─ Small (price band, cart count, recently viewed) ──▶ PPR
│ static shell + dynamic holes; the correct answer
│ for most commerce pages
├─ Large but cacheable per segment (currency, locale, market) ──▶
│ ISR keyed by segment, or edge-rendered variants
└─ Everything (cart, checkout, account) ──▶ SSR, uncached,
optimized for TTFB and minimal JS
Applied to Aurora Market:
| Page | Mode | Rationale |
|---|---|---|
| Home | ISR revalidate: 300 + on‑demand purge on CMS publish |
Same for everyone; merchandisers publish several times a day |
| PLP | ISR for page 1 of each category; facets as dynamic holes | Page 1 is 78% of PLP traffic |
| PDP | PPR: static shell (images, description, specs) + dynamic holes (price, stock, cart) | The flagship case |
| Search | SSR with edge caching for the top ~2K queries | Long tail is uncacheable |
| Cart | SSR, no-store |
Entirely per‑user |
| Checkout | SSR, no-store, minimal JS |
Entirely per‑user, highest risk |
| Account | SSR, no-store |
Entirely per‑user |
Static and ISR¶
// app/c/[slug]/page.tsx — a category page
export const revalidate = 300; // regenerate at most every 5 minutes
export const dynamicParams = true; // allow params not in generateStaticParams
// Pre-build the categories that matter; the rest generate on first request
export async function generateStaticParams() {
const top = await getTopCategories({ limit: 500 });
return top.map((c) => ({ slug: c.slug }));
}
export default async function CategoryPage({
params,
}: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const [category, products] = await Promise.all([
getCategory(slug),
getCategoryProducts(slug, { page: 1 }),
]);
return <CategoryView category={category} products={products} />;
}
The scale question: Aurora has 11K categories and 380K parent products. Building all of them takes hours and most are never visited. The answer is a hybrid:
generateStaticParams → top 500 categories / top 20K products (built at deploy: ~4 min)
dynamicParams: true → everything else generated on first request, then cached
revalidate + on-demand purge → freshness
This is covered in depth in 3.5 ISR at catalog scale, including the cold‑start problem for the long tail.
How a route accidentally becomes dynamic¶
This is the single most common architectural bug in Next.js commerce apps, and it's invisible until you look at the build output.
Reading any of these opts the route out of static rendering:
cookies(),headers(),draftMode()searchParamsin a page componentconnection()(explicit dynamic marker)fetch(..., { cache: 'no-store' })(or the Next.js 15+ default, wherefetchis uncached unless you opt in)export const dynamic = 'force-dynamic'- Reading
requestin a Route Handler
// ❌ One innocent-looking line in the ROOT LAYOUT makes every route dynamic.
// This is Aurora's structural problem #1.
// app/layout.tsx
import { cookies } from 'next/headers';
export default async function RootLayout({ children }) {
const cookieStore = await cookies();
const currency = cookieStore.get('currency')?.value ?? 'USD'; // 💀
return (
<html>
<body>
<CurrencyProvider value={currency}>{children}</CurrencyProvider>
</body>
</html>
);
}
Every page in the app is now server‑rendered per request. TTFB goes from 20 ms to 900 ms, CDN HTML hit ratio goes to ~0, and origin cost multiplies. All for a three‑character currency code.
// ✅ Option A — push the dynamic read into a small Suspense boundary (PPR-friendly)
// app/layout.tsx (stays static)
export default function RootLayout({ children }) {
return (
<html>
<body>
<Suspense fallback={<CurrencyFallback />}>
<CurrencyProviderDynamic>{children}</CurrencyProviderDynamic>
</Suspense>
</body>
</html>
);
}
// ✅ Option B — resolve it at the edge and vary the cache key on a normalized header
// proxy.ts / middleware.ts sets x-currency; the CDN keys on 'currency-region'
// (3 buckets, not 40), so you get 3 cached variants instead of 0.
// ✅ Option C — render prices client-side from a static base price + a tiny
// currency payload. Correct when the conversion is purely presentational.
Find your accidentally-dynamic routes¶
next build tells you directly. Read the legend:
Route (app) Size First Load JS
┌ ○ / 2.1 kB 198 kB
├ ● /c/[slug] 4.3 kB 241 kB
├ ƒ /p/[slug] 6.8 kB 267 kB ← 💀 should be ◐ or ●
├ ƒ /cart 3.2 kB 212 kB ← correct
└ ƒ /checkout 9.1 kB 289 kB ← correct
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML with generateStaticParams
◐ (Partial Prerender) static shell with dynamic holes
ƒ (Dynamic) server-rendered on demand
Make this a CI gate. A route silently flipping from ● to ƒ is a TTFB regression of several
hundred milliseconds that no other check will catch:
// scripts/check-route-modes.mjs
import { readFileSync } from 'node:fs';
// The mode each route MUST have. Flipping to dynamic is a build failure.
const EXPECTED = {
'/': 'static',
'/c/[slug]': 'static', // SSG/ISR
'/p/[slug]': 'partial', // PPR
'/cart': 'dynamic',
'/checkout': 'dynamic',
};
const prerender = JSON.parse(readFileSync('.next/prerender-manifest.json', 'utf8'));
const appPaths = JSON.parse(readFileSync('.next/app-path-routes-manifest.json', 'utf8'));
const failures = [];
for (const [route, expected] of Object.entries(EXPECTED)) {
const actual = classifyRoute(route, prerender, appPaths); // see examples/ci
if (actual !== expected) {
failures.push(`${route}: expected ${expected}, got ${actual}`);
}
}
if (failures.length) {
console.error('Route rendering mode regression:\n ' + failures.join('\n '));
console.error('\nA route became dynamic. Look for a new cookies()/headers()/searchParams read.');
process.exit(1);
}
Debugging why a route is dynamic:
# Next.js prints the reason when a route bails out of static rendering
NEXT_PRIVATE_DEBUG_CACHE=1 npx next build 2>&1 | grep -A3 -i 'dynamic\|bail'
Client‑side rendering: when it's right¶
CSR gets a bad reputation, but it's the correct choice in specific places:
✅ Good uses: account dashboards behind a login (SEO irrelevant, data is per‑user and uncacheable), highly interactive tools (size finder, product configurator, store locator map), anything below the fold that most users never see.
❌ Bad uses: product content, category listings, prices, anything that needs to be indexed or that's part of your LCP.
// ✅ Correct CSR: an interactive configurator loaded on demand.
// The PDP shell stays server-rendered and indexable.
const ProductConfigurator = dynamic(
() => import('@/components/product-configurator'),
{
ssr: false, // genuinely no value in SSR'ing this
loading: () => <ConfiguratorSkeleton />, // reserves space → no CLS
},
);
ssr: falseis not a performance technique by itself. It removes server render time but adds a client round trip and guarantees the content is invisible to the preload scanner and to crawlers. Use it for genuinely client‑only widgets, never for content.
Cost comparison at Aurora scale¶
PDP, 11.8M views/month:
| Strategy | Origin renders/mo | Compute cost/mo | p75 TTFB | CDN HTML hit ratio |
|---|---|---|---|---|
| Full SSR (today) | 11.8M | ~$34,000 | 910 ms | 4% |
| ISR (5 min) | ~180K | ~$1,900 | 40 ms | 94% |
| PPR | ~11.8M partial | ~$9,200 | 90 ms | shell 96% |
PPR costs more compute than pure ISR because the dynamic holes still execute per request — but it delivers correct prices and stock, which pure ISR cannot. That's the trade you're making, and it's usually right: $9.2K/mo and correct prices beats $1.9K/mo and a customer service problem.
Migration path¶
Don't rewrite. Move one page type at a time, in this order:
1. Marketing/content pages → Static (zero risk, proves the pipeline)
2. Homepage → ISR (single URL, easy to monitor and revert)
3. Category/PLP → ISR (high traffic, high reward, bounded blast radius)
4. PDP → PPR (the flagship; do it last of the cacheable pages)
5. Cart/checkout → stays SSR (optimize TTFB and JS instead)
Per page type, the sequence that keeps you safe:
- Instrument first. TTFB and CDN hit ratio for that path in a dashboard.
- Flag it. Route‑level flag so rollback is a config change, not a deploy.
- Canary 1% → 10% → 50%. Watch TTFB, error rate, and data‑freshness complaints (the failure mode of caching is stale prices, which shows up in customer service before it shows up in monitoring — set up a manual check with that team).
- Verify the mode actually changed in the build output and in
x-nextjs-cache/ageheaders in production. Teams routinely "migrate to ISR" and stay dynamic because of one leftovercookies()call.
# Confirm it's really cached in production
curl -sI https://www.auroramarket.com/p/wool-overshirt-navy \
| grep -iE 'x-nextjs-cache|x-vercel-cache|age|cache-control|x-cache'
# x-nextjs-cache: HIT ← what you want
# age: 143 ← served from cache, 143s old
Common mistakes¶
| Mistake | Cost |
|---|---|
One cookies() in the root layout |
Entire app becomes dynamic |
force-dynamic "to be safe" |
Throws away every caching benefit |
| Pre‑building 380K pages | Multi‑hour builds; most pages never visited |
| ISR without on‑demand revalidation | Stale prices for the full revalidate window |
| PPR without checking what's in the shell | Personalized data baked into a shared cache 💀 |
ssr: false on content |
Invisible to crawlers and to the preload scanner |
| Migrating everything at once | No attribution when metrics move; no safe rollback |
| Not verifying the mode in production | "We migrated to ISR" while still 100% dynamic |
Lab 3.1 — Classify your routes¶
- Run
next buildand record the mode symbol for every route. - For each route that's
ƒ(dynamic), find out why: - Build a table: route → current mode → should be mode (using the decision tree) → what's blocking it.
- Pick the highest‑traffic route where current ≠ should‑be, and write down the single line of code that's blocking it. It's usually one line.
- Add the route‑mode CI gate so today's classification is preserved.
- In production,
curl -Ieach route and confirm the cache headers match the intended mode.
Checklist¶
- Every route's intended mode is documented and gated in CI
- No
cookies()/headers()in the root layout or shared layouts -
generateStaticParamscovers the high‑traffic subset, not the whole catalog -
dynamicParams: truefor the long tail - Cart/checkout explicitly
no-store - Cache HIT verified in production headers, not just assumed from config
- Migration is per‑page‑type, flagged, and canaried