Rendering Strategies

Static, dynamic, streaming, and partial prerendering explained

Rendering in Next.js

Next.js provides multiple rendering strategies to optimize performance and user experience. Understanding when to use each strategy is key to building fast, scalable applications.

🎨 Rendering Strategies

SSG - Static Site Generation (build time)
SSR - Server-Side Rendering (request time)
ISR - Incremental Static Regeneration
CSR - Client-Side Rendering
Streaming - Progressive rendering
PPR - Partial Prerendering (experimental)

Static Site Generation (SSG)

// Pages are rendered at BUILD time
// Best for: blogs, marketing pages, documentation

// app/posts/page.tsx
export default async function Posts() {
  const posts = await getPosts(); // Fetched at build time
  return (
    <ul>
      {posts.map(post => <li key={post.id}>{post.title}</li>)}
    </ul>
  );
}

// Generate static pages for dynamic routes
// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map(post => ({
    slug: post.slug,
  }));
}

export default async function Post({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return <article>{post.content}</article>;
}

// This generates HTML at build time for each post

Server-Side Rendering (SSR)

// Pages are rendered on EACH REQUEST
// Best for: personalized content, real-time data

// Force dynamic rendering
export const dynamic = 'force-dynamic';

// Or use no-store in fetch
async function getUser() {
  const res = await fetch('/api/user', { cache: 'no-store' });
  return res.json();
}

// Using cookies/headers makes it dynamic automatically
import { cookies, headers } from 'next/headers';

export default async function Dashboard() {
  const cookieStore = await cookies();
  const token = cookieStore.get('auth-token');
  
  const headersList = await headers();
  const userAgent = headersList.get('user-agent');
  
  const user = await getUser(token);
  return <div>Welcome, {user.name}</div>;
}

Incremental Static Regeneration (ISR)

// Static pages that revalidate periodically
// Best for: e-commerce, news sites, frequently updated content

// Time-based revalidation
export const revalidate = 60; // Revalidate every 60 seconds

export default async function Products() {
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 }, // Or per-fetch
  }).then(r => r.json());
  
  return <ProductGrid products={products} />;
}

// On-demand revalidation
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';

export async function POST(request: Request) {
  const { path, tag } = await request.json();
  
  if (path) revalidatePath(path);
  if (tag) revalidateTag(tag);
  
  return Response.json({ revalidated: true, now: Date.now() });
}

Streaming with Suspense

import { Suspense } from 'react';

// Stream content progressively
export default function Page() {
  return (
    <div>
      {/* Renders immediately */}
      <header>
        <h1>Dashboard</h1>
      </header>

      {/* Each section streams independently */}
      <div className="grid grid-cols-3 gap-4">
        <Suspense fallback={<CardSkeleton />}>
          <RevenueCard /> {/* 100ms */}
        </Suspense>
        
        <Suspense fallback={<CardSkeleton />}>
          <UsersCard /> {/* 200ms */}
        </Suspense>
        
        <Suspense fallback={<CardSkeleton />}>
          <OrdersCard /> {/* 500ms */}
        </Suspense>
      </div>

      {/* Slow component doesn't block others */}
      <Suspense fallback={<TableSkeleton />}>
        <DataTable /> {/* 2000ms */}
      </Suspense>
    </div>
  );
}

// Each component can be an async Server Component
async function RevenueCard() {
  const revenue = await getRevenue();
  return <Card>Revenue: {revenue}</Card>;
}

Loading.tsx for Route-level Loading

// app/dashboard/loading.tsx
// Automatically wraps page in Suspense

export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
      <div className="grid grid-cols-3 gap-4">
        <div className="h-32 bg-gray-200 rounded" />
        <div className="h-32 bg-gray-200 rounded" />
        <div className="h-32 bg-gray-200 rounded" />
      </div>
    </div>
  );
}

// File structure
app/
├── dashboard/
│   ├── loading.tsx    # Shows while page loads
│   └── page.tsx       # The actual page

Partial Prerendering (PPR)

// Experimental: Static shell + dynamic holes
// Enable in next.config.js

// next.config.js
const nextConfig = {
  experimental: {
    ppr: true,
  },
};

// app/page.tsx
import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      {/* Static shell - prerendered */}
      <Header />
      <Hero />
      
      {/* Dynamic hole - rendered on request */}
      <Suspense fallback={<CartSkeleton />}>
        <Cart /> {/* Uses cookies/auth */}
      </Suspense>
      
      {/* Static content */}
      <FeaturedProducts />
      <Footer />
    </div>
  );
}

// PPR serves static shell instantly from CDN
// Then streams in dynamic parts

📖 Rendering Documentation →

Choosing a Strategy

Use Case Strategy Example
Static content SSG Blog, docs
Personalized data SSR Dashboard, settings
Frequently updated ISR E-commerce, news
Mixed static/dynamic PPR Landing + cart
Fast initial load Streaming Complex dashboards

✅ Rendering Best Practices

  • • Default to static - add dynamism only when needed
  • • Use Suspense boundaries for independent loading
  • • Choose ISR for content that changes periodically
  • • Use streaming for slow data sources
  • • Add loading.tsx for route-level loading states
  • • Consider PPR for pages with mixed static/dynamic content