Next.js Rendering Strategies

Static & Dynamic Rendering

Caterpillar logo

Rendering Strategies Overview

The Foundation of Next.js Performance

Understanding rendering strategies is the key to building fast, scalable Next.js applications. Each strategy determines when and where your content is generated.

Core Question: Should this content be generated at build time, at request time, or somewhere in between?

Two Rendering Strategies:

  • Static Rendering: Generated at build time, served instantly from CDN. Can include revalidation (ISR) for periodic updates.
  • Dynamic Rendering: Generated on every request, always fresh data

Static Rendering

The Default & Fastest Option

Static rendering generates HTML at build time. The result is cached and served directly from the CDN, providing the fastest possible response times.

Default behavior: Routes are rendered statically unless you use dynamic functions (cookies, headers, searchParams) or uncached data requests.
// app/about/page.tsx
// This page is static by default - no data fetching

export default function AboutPage() {
  return (
    <div>
      <h1>About Our Company</h1>
      <p>Founded in 2020, we build amazing products.</p>
    </div>
  )
}
Key Insight: SSG and ISR are the same rendering mode. The only difference is whether you generate all paths upfront (SSG) or add revalidation to update them periodically (ISR). Both produce static output.

Static rendering benefits:

  • Instant loading: Pre-generated HTML served from edge locations
  • Zero compute cost: No server processing per request
  • Perfect SEO: Complete HTML available to crawlers
  • High availability: Static content remains available globally even if a region goes down, because it's replicated to CDN edge locations with no compute dependency
Best Practice: Default to static rendering. Only opt into dynamic rendering when your page truly needs fresh data on every request.

Dynamic Rendering (SSR)

Fresh Data on Every Request

Dynamic rendering generates HTML on every request. This ensures users always see the most current data, but requires server compute time.

When to use: Personalized content, real-time data, or pages that depend on request-specific information (cookies, headers, search params).

What triggers dynamic rendering:

  • Using cookies() or headers()
  • Accessing searchParams prop
  • Uncached data fetching
// app/dashboard/page.tsx
import { cookies } from 'next/headers'

export default async function Dashboard() {
  // Reading cookies makes this page dynamic
  const session = (await cookies()).get('session')

  if (!session) {
    redirect('/login')
  }

  const userData = await getUserData(session.value)

  return (
    <div>
      <h1>Welcome, {userData.name}</h1>
      <p>Your personalized dashboard</p>
    </div>
  )
}
Performance Warning: Dynamic pages have higher latency (server processing time) and compute costs. They also execute in a specific region — if that region goes down, dynamic content fails while static content remains available globally.

Static with Revalidation (ISR)

Static Pages That Stay Fresh

Add revalidation to static rendering to get automatic background updates. Pages are served instantly from cache, then automatically regenerated after a specified time period.

How it works: First visitor gets cached content instantly. After the revalidation period, the next request triggers a background regeneration - serving stale while revalidating.
// app/products/page.tsx
// Static page with 60-second revalidation

export const revalidate = 60 // Revalidate every 60 seconds

export default async function ProductsPage() {
  const products = await fetch('https://api.example.com/products')
    .then(res => res.json())

  return (
    <div>
      <h1>Products</h1>
      <p>Last updated: {new Date().toLocaleTimeString()}</p>
      {products.map(product => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  )
}

Revalidation timeline:

  • T=0: Page built at build time, cached
  • T=1-59s: All requests served from cache instantly
  • T=60s: Cache is "stale" - next request triggers background regeneration
  • T=61s: Background regeneration complete, new cached version ready
Perfect for: E-commerce products, blog posts, news feeds - content that updates periodically but doesn't need real-time freshness.

On-Demand Revalidation

Update When Content Changes

Instead of waiting for time-based revalidation, you can trigger cache updates immediately when content changes using revalidatePath() or revalidateTag().

Use Case: When a user edits a blog post, immediately invalidate the cache so all visitors see the updated content.
// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function updatePost(id: string, data: FormData) {
  await db.post.update({
    where: { id },
    data: {
      title: data.get('title'),
      content: data.get('content'),
    }
  })

  // Invalidate the specific post page
  revalidatePath(`/blog/${id}`)

  // Also invalidate the blog listing
  revalidatePath('/blog')
}

Choosing between path and tag:

MethodUse WhenExample
revalidatePathYou know the exact URL to invalidateSingle page update
revalidateTagData is shared across multiple pagesProduct data on listing + detail pages

Dynamic Functions

What Triggers Dynamic Rendering?

Certain functions and patterns automatically make your page dynamic. Understanding these "dynamic signals" is crucial for controlling your rendering strategy.

FunctionTriggerAlternative
cookies()Always dynamicPass as prop from parent
headers()Always dynamicUse proxy/middleware for auth
searchParamsAlways dynamicUse generateStaticParams for known values
// This page is accidentally dynamic!
export default async function ProductPage({ params }) {
  const { id } = await params

  // Reading headers makes the ENTIRE page dynamic
  const locale = (await headers()).get('accept-language')

  const product = await getProduct(id)

  return <div>...</div>
}

// Better: Extract dynamic part
export default async function ProductPage({ params }) {
  const { id } = await params
  const product = await getProduct(id)  // Can be cached

  return (
    <div>
      <ProductDetails product={product} />
      {/* Only this component is dynamic */}
      <Suspense fallback={<div>Loading...</div>}>
        <LocalizedContent />
      </Suspense>
    </div>
  )
}

async function LocalizedContent() {
  const locale = (await headers()).get('accept-language')
  return <div>Content in {locale}</div>
}
Common Mistake: Using a dynamic function anywhere in your component tree makes the entire page dynamic. Isolate dynamic data in Suspense boundaries!

generateStaticParams

Pre-rendering Dynamic Routes

For dynamic routes like /blog/[slug], use generateStaticParams to pre-render pages at build time instead of generating them on-demand.

Key Benefit: Convert potentially infinite dynamic routes into a finite set of static pages, dramatically improving performance and reducing server load.
// app/blog/[slug]/page.tsx

export async function generateStaticParams() {
  const posts = await getAllPosts()

  return posts.map(post => ({
    slug: post.slug,
  }))
}

// Now each post page is pre-rendered at build time
export default async function BlogPost({ params }) {
  const { slug } = await params
  const post = await getPost(slug)

  return <article>{post.content}</article>
}

dynamicParams options:

  • true (default): Unknown paths are generated on-demand
  • false: Unknown paths return 404
Pro Tip: Pre-render your most visited pages, let less common ones generate on-demand. This balances build time with cache efficiency.

Streaming with Suspense

Progressive Page Loading

Streaming allows you to progressively send HTML to the client. Fast content appears immediately while slower content loads in the background.

User Experience: Instead of waiting for the slowest data, users see the page build up progressively. This dramatically improves perceived performance.
import { Suspense } from 'react'

export default function DashboardPage() {
  return (
    <div>
      {/* Renders immediately - no data dependency */}
      <h1>Dashboard</h1>
      <nav>Overview | Analytics | Settings</nav>

      {/* Fast data - appears quickly */}
      <Suspense fallback={<StatsSkeleton />}>
        <QuickStats />
      </Suspense>

      {/* Medium speed data */}
      <Suspense fallback={<ChartSkeleton />}>
        <AnalyticsChart />
      </Suspense>

      {/* Slow data - loads last */}
      <Suspense fallback={<TableSkeleton />}>
        <DetailedReport />
      </Suspense>
    </div>
  )
}

async function QuickStats() {
  const stats = await getStats()  // ~100ms
  return <StatsCards data={stats} />
}

async function AnalyticsChart() {
  const data = await getChartData()  // ~500ms
  return <Chart data={data} />
}

async function DetailedReport() {
  const report = await generateReport()  // ~2000ms
  return <ReportTable data={report} />
}

Benefits of streaming:

  • Faster Time to First Byte (TTFB): Server starts sending immediately
  • Progressive rendering: Content appears as it's ready
  • Better Core Web Vitals: Improved LCP and FCP scores
  • No waterfalls: Multiple data fetches happen in parallel
Critical: Streaming only works with dynamic rendering. Static pages (SSG, ISR) are pre-generated as complete HTML — no compute happens at request time, so there's nothing to stream. Suspense fallbacks will not show when generating ISR paths.
Skeleton Design: Create loading skeletons that match your content layout to prevent layout shift when real content loads.

unstable_cache

Caching Beyond fetch()

unstable_cache wraps any async function and persists its return value in Next.js's Data Cache. This API enables caching for database queries, SDK calls, and expensive computations.

Key Benefit: Store data in the Data Cache to enable static-like performance for dynamic pages. Retrieve pre-computed data instantly while still having access to cookies, headers, and other dynamic APIs in your page.
import { unstable_cache } from 'next/cache'

const cachedFn = unstable_cache(
  fetchData,   // () => Promise<T> - the function to cache
  keyParts?,   // string[] - extra cache-key segments
  options?     // { tags?: string[]; revalidate?: number | false }
)

Common Use Cases:

  • Database queries: Cache ORM/SQL calls to avoid repeated database hits
  • Third-party SDKs: GitHub, Stripe, CMS clients that don't use fetch internally
  • Expensive computations: Analytics, report generation, ML inference
  • Aggregated data: Compose multiple sources into a single cached unit
Constraints: No dynamic APIs (cookies(), headers()) inside the cached function. Inputs and outputs must be JSON-serializable.

Choosing the Right Strategy

Decision Matrix

Use this decision tree to choose the optimal rendering strategy for your page:

Content TypeStrategyExample
Never changesStaticLegal pages, about page
Changes periodicallyStatic + revalidationBlog posts, product listings
User-specific dataDynamic (in Suspense)Dashboard, cart, profile
Real-time requirementsDynamicLive scores, stock prices
📄Static

Content that rarely changes should be fully static

Quick Reference

Rendering Strategies Cheat Sheet

StrategyWhen GeneratedRevalidationUse For
StaticBuild timeOptional (time-based or on-demand)Marketing, blog, products
DynamicEvery requestN/APersonalized, real-time

Key APIs:

APIPurpose
revalidateRoute segment config for revalidation timing
revalidatePath()Invalidate cache by URL
revalidateTag()Invalidate cache by tag
generateStaticParams()Pre-render dynamic routes
unstable_cache()Cache any async function
connection()Opt into dynamic rendering (replaces unstable_noStore)
1 / 11
Slide 1 of 11