Skip to content

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()
  • searchParams in a page component
  • connection() (explicit dynamic marker)
  • fetch(..., { cache: 'no-store' }) (or the Next.js 15+ default, where fetch is uncached unless you opt in)
  • export const dynamic = 'force-dynamic'
  • Reading request in 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: false is 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:

  1. Instrument first. TTFB and CDN hit ratio for that path in a dashboard.
  2. Flag it. Route‑level flag so rollback is a config change, not a deploy.
  3. 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).
  4. Verify the mode actually changed in the build output and in x-nextjs-cache / age headers in production. Teams routinely "migrate to ISR" and stay dynamic because of one leftover cookies() 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

  1. Run next build and record the mode symbol for every route.
  2. For each route that's ƒ (dynamic), find out why:
    rg -n "cookies\(\)|headers\(\)|force-dynamic|no-store|connection\(\)|draftMode" app/ | sort
    
  3. Build a table: route → current mode → should be mode (using the decision tree) → what's blocking it.
  4. 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.
  5. Add the route‑mode CI gate so today's classification is preserved.
  6. In production, curl -I each 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
  • generateStaticParams covers the high‑traffic subset, not the whole catalog
  • dynamicParams: true for 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

Next: 3.2 Server Components & client boundaries