Field note / nextjs

publishedupdated 2026-05-01#nextjs#ssr#ssg#isr#ppr

Next.js Rendering: SSR / SSG / ISR / PPR

When each rendering strategy runs, how to choose between them, fetch cache options, Partial Prerendering, and the data freshness vs performance trade-offs.

Overview

Next.js App Router collapses rendering strategy into a single API: fetch() with cache options. The strategy is determined per-request-per-segment based on how data is fetched, not by which function you export. Understanding the cache semantics — and when Next.js opts a segment out of static rendering — is what separates informed architecture from trial and error.


Architecture

RENDERING STRATEGIES
════════════════════════════════════════════════════════

  STATIC (SSG equivalent)             Default for App Router
  ├── Rendered at build time           No dynamic functions called
  ├── HTML cached on CDN edge         fetch() with cache: 'force-cache'
  ├── Fastest possible TTFB           or no revalidation options
  └── Use for: marketing, docs, blogs

  DYNAMIC (SSR equivalent)
  ├── Rendered on every request        cookies(), headers(), searchParams used
  ├── Cannot be cached at edge        OR fetch() with cache: 'no-store'
  ├── Fresh data guaranteed           OR dynamic = 'force-dynamic' export
  └── Use for: dashboards, personalized content

  INCREMENTAL STATIC REGENERATION (ISR)
  ├── Statically generated at build
  ├── Regenerated in background        fetch({ next: { revalidate: 60 } })
  │   after revalidate period          or export const revalidate = 60
  ├── Stale-while-revalidate behavior  first user after expiry gets stale
  └── Use for: product pages, news feeds, semi-dynamic content

  PARTIAL PRERENDERING (PPR — Next.js 14+)
  ├── Static shell served instantly    <Suspense> boundaries create PPR holes
  ├── Dynamic holes stream in          Each Suspense = separate dynamic island
  ├── Best of SSG + SSR               No trade-off between static shell + dynamic data
  └── Use for: e-commerce (static product + dynamic price/inventory)

FETCH CACHE OPTIONS (Next.js App Router)
════════════════════════════════════════════════════════

  fetch(url, { cache: 'force-cache' })    → static (cached forever or until revalidated)
  fetch(url, { cache: 'no-store' })       → dynamic (never cached, every request fresh)
  fetch(url, { next: { revalidate: 60 }}) → ISR (cached 60s, then background refresh)
  fetch(url, { next: { tags: ['products'] }}) → tag-based revalidation via revalidateTag()

  WHAT FORCES DYNAMIC RENDERING:
  • cookies() or headers() called in Server Component
  • searchParams prop used in page.tsx
  • Dynamic segment with generateStaticParams() not covering all cases
  • fetch({ cache: 'no-store' }) anywhere in the render tree

Technical Implementation

Static with On-Demand Revalidation

// app/products/[slug]/page.tsx — statically generated at build
export async function generateStaticParams() {
  const products = await getProducts();
  return products.map(p => ({ slug: p.slug }));  // pre-generate these pages
}

export default async function ProductPage({ params }: { params: { slug: string } }) {
  // Cached forever — regenerated only via revalidateTag('products')
  const product = await fetch(`https://api.example.com/products/${params.slug}`, {
    next: { tags: [`product:${params.slug}`, 'products'] },
  }).then(r => r.json());

  return <ProductDetail product={product} />;
}

// API route — call from CMS webhook or admin action to bust specific pages
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';

export async function POST(req: NextRequest) {
  const { tag, secret } = await req.json();

  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  revalidateTag(tag);  // invalidates all pages tagged with this tag
  return Response.json({ revalidated: true, tag });
}

ISR with Time-Based Revalidation

// Segment-level revalidation: applies to all fetches in this route
export const revalidate = 300;  // 5 minutes — stale-while-revalidate

export default async function BlogPage() {
  const posts = await fetch('https://cms.example.com/posts', {
    // Inherits segment revalidate (300s), or override per-fetch:
    next: { revalidate: 60 },  // per-fetch override: 1 minute
  }).then(r => r.json());

  return (
    <div>
      {posts.map((post: Post) => <PostCard key={post.id} post={post} />)}
    </div>
  );
}

Partial Prerendering (PPR)

// next.config.ts — enable PPR (Next.js 14 experimental, 15 stable)
const config = {
  experimental: { ppr: true },
};

// app/shop/[id]/page.tsx — PPR in action
import { Suspense } from 'react';

// Static shell: product name, description, images → built at deploy time
// Dynamic holes: price, stock, personalized CTA → stream from server on request
export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);  // static (cacheable)

  return (
    <div>
      {/* Static content: renders at build time, served from CDN instantly */}
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <ProductImages images={product.images} />

      {/* Dynamic hole: Suspense = PPR boundary, streams on each request */}
      <Suspense fallback={<PriceSkeleton />}>
        <DynamicPrice productId={params.id} />    {/* reads cookies for user pricing */}
      </Suspense>

      <Suspense fallback={<StockSkeleton />}>
        <LiveStock productId={params.id} />       {/* never cached */}
      </Suspense>
    </div>
  );
}

// DynamicPrice is a Server Component — but it reads cookies → forces dynamic
async function DynamicPrice({ productId }: { productId: string }) {
  const userTier = cookies().get('user-tier')?.value ?? 'standard';
  const price = await getPrice(productId, userTier);  // cache: 'no-store'
  return <PriceDisplay price={price} tier={userTier} />;
}

Interview Preparation

Q: How does the App Router determine whether to statically or dynamically render a page?

Next.js statically renders a route segment by default unless it detects a "dynamic signal." Dynamic signals include: calling cookies() or headers() inside the segment's render tree, accessing searchParams prop in page.tsx, using fetch({ cache: 'no-store' }), or exporting export const dynamic = 'force-dynamic'. The presence of any dynamic signal forces the entire segment to dynamic rendering (unless PPR is enabled, where Suspense boundaries isolate dynamic islands). This means a Server Component that calls cookies() deep in the render tree makes the entire page dynamic — even if most of the data is cacheable.

Q: What is the difference between ISR revalidate and on-demand revalidation?

Time-based ISR (revalidate: 60) uses stale-while-revalidate semantics: after 60 seconds, the next request gets the stale cached page while Next.js regenerates a fresh version in the background. Subsequent requests get the new version. On-demand revalidation (revalidateTag / revalidatePath) invalidates the cache immediately when you call it — typically from a webhook when CMS content changes. On-demand is better for content accuracy (page updates within seconds of CMS change, not 60 seconds later). Time-based is simpler (no webhook infrastructure needed). Use both together: short revalidate as a safety net, on-demand revalidation for immediate updates.

Q: When would you choose no-store over force-cache for data fetching?

force-cache (default): data is cached and served from cache on subsequent requests. Use for data that changes infrequently and where freshness is not critical — product catalog, documentation, blog posts. no-store: data is never cached, always fetched fresh. Use for: user-specific data (cart, notifications, account info), real-time data (stock prices, live scores), or any data where stale results would be wrong or harmful. Financial data, inventory counts, session-dependent content — all no-store. Rule of thumb: if multiple different users should see different data for the same URL, it must be no-store (or use cookies to vary the cache).


Learning Resources