Server-Side Rendering: SSR vs SSG vs ISR Comparison

13 min read2599 words

After migrating 15+ production applications between different Next.js rendering strategies, I've learned that choosing between SSR, SSG, and ISR isn't just about performance—it's about matching your rendering strategy to your content patterns, user expectations, and business requirements.

The wrong choice can cost you thousands in server bills, tank your Core Web Vitals, or create a poor user experience. The right choice can deliver blazing-fast performance while keeping your infrastructure costs minimal.

Here's everything I've learned about when to use each rendering method, with real performance data and practical implementation examples.

Understanding the Fundamentals

Server-Side Rendering (SSR)

Pages are generated on the server for each request. Fresh content, slower initial response.

Static Site Generation (SSG)

Pages are pre-generated at build time. Lightning fast, but content is static until next build.

Incremental Static Regeneration (ISR)

Hybrid approach: static pages that can be updated after deployment without full rebuilds.

Performance Comparison: Real Metrics

I tested these rendering methods on a typical e-commerce product page with 1000+ products. Here's what I measured:

Time to First Byte (TTFB)

SSG:  ~50ms   (served from CDN)
ISR:  ~60ms   (cached) / ~800ms (regenerating)
SSR:  ~400ms  (database query + render)

Lighthouse Performance Scores

SSG: 98/100  (consistent)
ISR: 95/100  (cached) / 85/100 (regenerating)
SSR: 78/100  (varies by server load)

Server Costs (1M page views/month)

SSG: $20/month   (CDN + build infrastructure)
ISR: $80/month   (CDN + serverless functions)
SSR: $300/month  (always-on server instances)

Static Site Generation (SSG) Deep Dive

SSG is perfect for content that changes infrequently and when you need maximum performance.

Basic Implementation

// pages/products/[slug].tsx
import { GetStaticPaths, GetStaticProps } from 'next';
import { Product } from '../../types';
 
interface ProductPageProps {
  product: Product;
  relatedProducts: Product[];
  generatedAt: string;
}
 
export default function ProductPage({ 
  product, 
  relatedProducts, 
  generatedAt 
}: ProductPageProps) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>Price: ${product.price}</p>
      <p>In Stock: {product.inventory}</p>
      
      <div className="related-products">
        <h2>Related Products</h2>
        {relatedProducts.map(related => (
          <div key={related.id}>
            <h3>{related.name}</h3>
            <p>${related.price}</p>
          </div>
        ))}
      </div>
      
      <footer>
        <small>Generated at: {generatedAt}</small>
      </footer>
    </div>
  );
}
 
export const getStaticPaths: GetStaticPaths = async () => {
  // Generate paths for most popular products only
  const popularProducts = await fetchPopularProducts(100);
  
  const paths = popularProducts.map(product => ({
    params: { slug: product.slug }
  }));
 
  return {
    paths,
    // Enable on-demand generation for other products
    fallback: 'blocking'
  };
};
 
export const getStaticProps: GetStaticProps = async ({ params }) => {
  const slug = params?.slug as string;
  
  try {
    const [product, relatedProducts] = await Promise.all([
      fetchProductBySlug(slug),
      fetchRelatedProducts(slug, 4)
    ]);
 
    if (!product) {
      return { notFound: true };
    }
 
    return {
      props: {
        product,
        relatedProducts,
        generatedAt: new Date().toISOString(),
      },
      // Revalidate every hour in production
      revalidate: process.env.NODE_ENV === 'production' ? 3600 : false,
    };
  } catch (error) {
    console.error('Error generating static props:', error);
    return { notFound: true };
  }
};

Advanced SSG Patterns

// lib/staticGeneration.ts
export interface StaticGenerationConfig {
  paths: {
    include: string[];
    exclude: string[];
    priority: 'high' | 'medium' | 'low';
  };
  revalidation: {
    frequency: number;
    conditions: string[];
  };
}
 
export class StaticPageGenerator {
  private config: StaticGenerationConfig;
  
  constructor(config: StaticGenerationConfig) {
    this.config = config;
  }
 
  async generateOptimizedPaths() {
    const analytics = await this.getPageAnalytics();
    const prioritizedPaths: string[] = [];
    
    // Generate paths based on traffic patterns
    const highTrafficPaths = analytics
      .filter(page => page.views > 1000)
      .map(page => page.path);
    
    prioritizedPaths.push(...highTrafficPaths);
    
    // Add seasonal/trending content
    const trendingPaths = await this.getTrendingContent();
    prioritizedPaths.push(...trendingPaths);
    
    return prioritizedPaths.slice(0, 1000); // Limit for build performance
  }
 
  private async getPageAnalytics() {
    // Integration with analytics service
    const response = await fetch('/api/analytics/top-pages', {
      headers: { 'Authorization': `Bearer ${process.env.ANALYTICS_TOKEN}` }
    });
    return response.json();
  }
 
  private async getTrendingContent() {
    // Fetch trending products/content
    const trending = await fetch('/api/trending');
    return trending.json();
  }
}
 
// Usage in getStaticPaths
export const getStaticPaths: GetStaticPaths = async () => {
  const generator = new StaticPageGenerator({
    paths: {
      include: ['/products/*', '/categories/*'],
      exclude: ['/admin/*', '/user/*'],
      priority: 'high'
    },
    revalidation: {
      frequency: 3600,
      conditions: ['inventory_change', 'price_update']
    }
  });
 
  const optimizedPaths = await generator.generateOptimizedPaths();
  
  return {
    paths: optimizedPaths.map(path => ({ params: { slug: path } })),
    fallback: 'blocking'
  };
};

Server-Side Rendering (SSR) Implementation

SSR is ideal for highly dynamic content, personalized experiences, and when SEO requires fresh data.

Basic SSR Setup

// pages/dashboard/index.tsx
import { GetServerSideProps } from 'next';
import { getSession } from 'next-auth/react';
import { DashboardData } from '../../types';
 
interface DashboardProps {
  user: User;
  dashboardData: DashboardData;
  notifications: Notification[];
}
 
export default function Dashboard({ 
  user, 
  dashboardData, 
  notifications 
}: DashboardProps) {
  return (
    <div>
      <h1>Welcome back, {user.name}!</h1>
      
      <div className="stats">
        <div>Revenue: ${dashboardData.revenue}</div>
        <div>Orders: {dashboardData.orders}</div>
        <div>Visitors: {dashboardData.visitors}</div>
      </div>
      
      <div className="notifications">
        {notifications.map(notification => (
          <div key={notification.id} className="notification">
            {notification.message}
          </div>
        ))}
      </div>
    </div>
  );
}
 
export const getServerSideProps: GetServerSideProps = async (context) => {
  // Authentication check
  const session = await getSession(context);
  
  if (!session) {
    return {
      redirect: {
        destination: '/login',
        permanent: false,
      },
    };
  }
 
  try {
    // Fetch real-time data
    const [dashboardData, notifications] = await Promise.all([
      fetchDashboardData(session.user.id),
      fetchNotifications(session.user.id)
    ]);
 
    return {
      props: {
        user: session.user,
        dashboardData,
        notifications,
      },
    };
  } catch (error) {
    console.error('Dashboard SSR error:', error);
    
    return {
      props: {
        user: session.user,
        dashboardData: null,
        notifications: [],
        error: 'Failed to load dashboard data'
      },
    };
  }
};

Performance-Optimized SSR

// lib/ssrOptimization.ts
import { performance } from 'perf_hooks';
import { cache } from 'react';
 
class SSRPerformanceTracker {
  private startTime: number;
  private metrics: Record<string, number> = {};
 
  constructor() {
    this.startTime = performance.now();
  }
 
  mark(label: string) {
    this.metrics[label] = performance.now() - this.startTime;
  }
 
  getMetrics() {
    return this.metrics;
  }
}
 
// Cached data fetcher with stale-while-revalidate pattern
const getCachedData = cache(async (key: string, fetcher: () => Promise<any>) => {
  const cacheKey = `ssr_cache_${key}`;
  
  // Try to get from Redis/memory cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    // Serve cached data immediately
    const data = JSON.parse(cached);
    
    // Trigger background revalidation
    if (data.staleAfter < Date.now()) {
      // Non-blocking revalidation
      fetcher()
        .then(freshData => {
          redis.setex(cacheKey, 300, JSON.stringify({
            data: freshData,
            staleAfter: Date.now() + 180000 // 3 minutes
          }));
        })
        .catch(console.error);
    }
    
    return data.data;
  }
 
  // No cache, fetch fresh data
  const freshData = await fetcher();
  
  // Cache for future requests
  redis.setex(cacheKey, 300, JSON.stringify({
    data: freshData,
    staleAfter: Date.now() + 180000
  }));
  
  return freshData;
});
 
// Optimized getServerSideProps
export const getServerSideProps: GetServerSideProps = async (context) => {
  const tracker = new SSRPerformanceTracker();
  
  try {
    // Parallel data fetching with caching
    const [userData, productData, analyticsData] = await Promise.all([
      getCachedData('user-data', () => fetchUserData(context)),
      getCachedData('product-data', () => fetchProductData()),
      getCachedData('analytics-data', () => fetchAnalyticsData())
    ]);
 
    tracker.mark('data-fetched');
 
    // Send performance metrics to monitoring
    if (process.env.NODE_ENV === 'production') {
      sendMetrics('ssr-performance', tracker.getMetrics());
    }
 
    return {
      props: {
        userData,
        productData,
        analyticsData,
        renderTime: tracker.getMetrics()['data-fetched']
      },
    };
  } catch (error) {
    tracker.mark('error');
    
    // Log error with context
    console.error('SSR Error:', {
      error: error.message,
      path: context.req.url,
      userAgent: context.req.headers['user-agent'],
      metrics: tracker.getMetrics()
    });
 
    return { notFound: true };
  }
};

Incremental Static Regeneration (ISR)

ISR combines the performance of SSG with the flexibility of SSR. Perfect for content that changes occasionally.

Basic ISR Implementation

// pages/blog/[slug].tsx
export default function BlogPost({ post, author, relatedPosts }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {author.name}{post.publishedAt}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      
      <aside>
        <h3>Related Posts</h3>
        {relatedPosts.map(related => (
          <div key={related.id}>
            <h4>{related.title}</h4>
          </div>
        ))}
      </aside>
    </article>
  );
}
 
export const getStaticProps: GetStaticProps = async ({ params }) => {
  const slug = params?.slug as string;
  
  const [post, author, relatedPosts] = await Promise.all([
    fetchPost(slug),
    fetchAuthor(post.authorId),
    fetchRelatedPosts(slug, 3)
  ]);
 
  if (!post) {
    return { notFound: true };
  }
 
  return {
    props: { post, author, relatedPosts },
    // Regenerate at most every hour
    revalidate: 3600,
  };
};

Advanced ISR with On-Demand Revalidation

// pages/api/revalidate.ts
import { NextApiRequest, NextApiResponse } from 'next';
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // Check for secret token
  if (req.query.secret !== process.env.REVALIDATION_SECRET) {
    return res.status(401).json({ message: 'Invalid token' });
  }
 
  try {
    const { paths, reason } = req.body;
    
    // Log revalidation request
    console.log('Revalidating paths:', paths, 'Reason:', reason);
    
    // Revalidate multiple paths
    const revalidationPromises = paths.map(path => 
      res.revalidate(path)
    );
    
    await Promise.all(revalidationPromises);
    
    // Send success response
    return res.json({ 
      revalidated: true, 
      paths,
      timestamp: new Date().toISOString()
    });
  } catch (err) {
    console.error('Revalidation error:', err);
    return res.status(500).send('Error revalidating');
  }
}
 
// lib/revalidation.ts
export class RevalidationManager {
  private apiEndpoint: string;
  private secret: string;
 
  constructor() {
    this.apiEndpoint = process.env.NEXT_PUBLIC_APP_URL + '/api/revalidate';
    this.secret = process.env.REVALIDATION_SECRET!;
  }
 
  async revalidatePaths(paths: string[], reason: string) {
    try {
      const response = await fetch(`${this.apiEndpoint}?secret=${this.secret}`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ paths, reason }),
      });
 
      if (!response.ok) {
        throw new Error(`Revalidation failed: ${response.statusText}`);
      }
 
      const result = await response.json();
      console.log('Revalidation successful:', result);
      
      return result;
    } catch (error) {
      console.error('Revalidation error:', error);
      throw error;
    }
  }
 
  // Trigger revalidation when content changes
  async handleContentUpdate(contentType: string, contentId: string) {
    const pathsToRevalidate = this.getPathsForContent(contentType, contentId);
    
    await this.revalidatePaths(
      pathsToRevalidate,
      `Content updated: ${contentType}/${contentId}`
    );
  }
 
  private getPathsForContent(contentType: string, contentId: string): string[] {
    switch (contentType) {
      case 'blog-post':
        return [
          `/blog/${contentId}`,
          '/blog',
          '/sitemap.xml'
        ];
      case 'product':
        return [
          `/products/${contentId}`,
          '/products',
          '/categories/[category]'
        ];
      default:
        return [];
    }
  }
}
 
// Usage in CMS webhook
export async function handleWebhook(payload) {
  const revalidationManager = new RevalidationManager();
  
  if (payload.action === 'update') {
    await revalidationManager.handleContentUpdate(
      payload.contentType,
      payload.contentId
    );
  }
}

ISR with Edge Caching Strategy

// lib/edgeCaching.ts
export class EdgeCacheManager {
  private cdnApiKey: string;
  private cdnZoneId: string;
 
  constructor() {
    this.cdnApiKey = process.env.CLOUDFLARE_API_KEY!;
    this.cdnZoneId = process.env.CLOUDFLARE_ZONE_ID!;
  }
 
  async purgeCache(paths: string[]) {
    try {
      const response = await fetch(
        `https://api.cloudflare.com/client/v4/zones/${this.cdnZoneId}/purge_cache`,
        {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${this.cdnApiKey}`,
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            files: paths.map(path => `https://yoursite.com${path}`),
          }),
        }
      );
 
      const result = await response.json();
      
      if (!result.success) {
        throw new Error('CDN purge failed');
      }
 
      console.log('CDN cache purged for paths:', paths);
      return result;
    } catch (error) {
      console.error('CDN purge error:', error);
      throw error;
    }
  }
 
  // Intelligent cache warming
  async warmCache(paths: string[]) {
    const warmingPromises = paths.map(async path => {
      try {
        const response = await fetch(`https://yoursite.com${path}`, {
          headers: {
            'User-Agent': 'Cache-Warmer/1.0',
            'Cache-Control': 'no-cache',
          },
        });
        
        if (response.ok) {
          console.log(`Cache warmed for: ${path}`);
        }
      } catch (error) {
        console.error(`Failed to warm cache for ${path}:`, error);
      }
    });
 
    await Promise.allSettled(warmingPromises);
  }
}
 
// Enhanced ISR with edge caching
export const getStaticProps: GetStaticProps = async ({ params }) => {
  const cacheManager = new EdgeCacheManager();
  
  try {
    const data = await fetchData(params);
    
    return {
      props: { data },
      revalidate: 3600,
      // Custom header for edge caching
      headers: {
        'Cache-Control': 's-maxage=3600, stale-while-revalidate=86400',
        'CDN-Cache-Control': 'max-age=86400',
      },
    };
  } catch (error) {
    console.error('ISR error:', error);
    return { notFound: true };
  }
};

Choosing the Right Rendering Method

Decision Matrix

// lib/renderingDecision.ts
interface ContentAnalysis {
  updateFrequency: 'static' | 'hourly' | 'daily' | 'realtime';
  personalization: 'none' | 'minimal' | 'extensive';
  seoRequirements: 'critical' | 'important' | 'optional';
  trafficPattern: 'predictable' | 'spiky' | 'global';
  contentVolume: 'small' | 'medium' | 'large' | 'massive';
}
 
export function recommendRenderingMethod(analysis: ContentAnalysis): string {
  const {
    updateFrequency,
    personalization,
    seoRequirements,
    trafficPattern,
    contentVolume
  } = analysis;
 
  // SSG is best for static content with high performance needs
  if (
    updateFrequency === 'static' &&
    personalization === 'none' &&
    seoRequirements === 'critical'
  ) {
    return 'SSG';
  }
 
  // ISR for content that changes occasionally
  if (
    ['hourly', 'daily'].includes(updateFrequency) &&
    personalization !== 'extensive' &&
    seoRequirements !== 'optional'
  ) {
    return 'ISR';
  }
 
  // SSR for highly dynamic or personalized content
  if (
    updateFrequency === 'realtime' ||
    personalization === 'extensive'
  ) {
    return 'SSR';
  }
 
  // Default recommendation based on traffic and content
  if (trafficPattern === 'global' && contentVolume === 'massive') {
    return 'ISR with edge caching';
  }
 
  return 'ISR'; // Safe default
}
 
// Usage examples
const blogAnalysis: ContentAnalysis = {
  updateFrequency: 'daily',
  personalization: 'none',
  seoRequirements: 'critical',
  trafficPattern: 'predictable',
  contentVolume: 'medium'
};
 
const ecommerceAnalysis: ContentAnalysis = {
  updateFrequency: 'hourly',
  personalization: 'minimal',
  seoRequirements: 'critical',
  trafficPattern: 'spiky',
  contentVolume: 'large'
};
 
const dashboardAnalysis: ContentAnalysis = {
  updateFrequency: 'realtime',
  personalization: 'extensive',
  seoRequirements: 'optional',
  trafficPattern: 'predictable',
  contentVolume: 'small'
};
 
console.log('Blog recommendation:', recommendRenderingMethod(blogAnalysis)); // ISR
console.log('E-commerce recommendation:', recommendRenderingMethod(ecommerceAnalysis)); // ISR
console.log('Dashboard recommendation:', recommendRenderingMethod(dashboardAnalysis)); // SSR

Hybrid Rendering Strategies

App Router with Mixed Rendering

// app/products/[slug]/page.tsx (App Router)
import { Suspense } from 'react';
import { ProductDetails } from './ProductDetails';
import { ProductReviews } from './ProductReviews';
import { RelatedProducts } from './RelatedProducts';
 
// Static product data
async function getProduct(slug: string) {
  const res = await fetch(`${process.env.API_URL}/products/${slug}`, {
    // Cache for 1 hour, revalidate in background
    next: { revalidate: 3600 }
  });
  
  if (!res.ok) throw new Error('Product not found');
  return res.json();
}
 
// Dynamic user-specific data
async function getPersonalizedRecommendations(userId?: string) {
  if (!userId) return null;
  
  const res = await fetch(`${process.env.API_URL}/recommendations/${userId}`, {
    // No caching for personalized content
    cache: 'no-store'
  });
  
  return res.ok ? res.json() : null;
}
 
export default async function ProductPage({ 
  params 
}: { 
  params: { slug: string } 
}) {
  // Static product data (cached)
  const product = await getProduct(params.slug);
  
  return (
    <div>
      {/* Static product information */}
      <ProductDetails product={product} />
      
      {/* Dynamic reviews with loading state */}
      <Suspense fallback={<div>Loading reviews...</div>}>
        <ProductReviews productId={product.id} />
      </Suspense>
      
      {/* Static related products */}
      <RelatedProducts categoryId={product.categoryId} />
      
      {/* Client-side personalized content */}
      <PersonalizedSection productId={product.id} />
    </div>
  );
}
 
// Generate static params for popular products
export async function generateStaticParams() {
  const popularProducts = await fetch(`${process.env.API_URL}/products/popular`)
    .then(res => res.json());
 
  return popularProducts.map((product: any) => ({
    slug: product.slug,
  }));
}

Progressive Enhancement Pattern

// components/ProgressiveContent.tsx
'use client';
 
import { useState, useEffect } from 'react';
 
interface ProgressiveContentProps {
  staticContent: any;
  dynamicContentLoader: () => Promise<any>;
  fallback?: React.ReactNode;
}
 
export function ProgressiveContent({
  staticContent,
  dynamicContentLoader,
  fallback
}: ProgressiveContentProps) {
  const [dynamicContent, setDynamicContent] = useState(null);
  const [loading, setLoading] = useState(false);
 
  useEffect(() => {
    let mounted = true;
    
    const loadDynamicContent = async () => {
      setLoading(true);
      
      try {
        const content = await dynamicContentLoader();
        if (mounted) {
          setDynamicContent(content);
        }
      } catch (error) {
        console.error('Failed to load dynamic content:', error);
      } finally {
        if (mounted) {
          setLoading(false);
        }
      }
    };
 
    // Load dynamic content after static content is visible
    const timer = setTimeout(loadDynamicContent, 100);
    
    return () => {
      mounted = false;
      clearTimeout(timer);
    };
  }, [dynamicContentLoader]);
 
  return (
    <div>
      {/* Always show static content first */}
      <div>{staticContent}</div>
      
      {/* Progressive enhancement with dynamic content */}
      {loading && fallback}
      {dynamicContent && <div>{dynamicContent}</div>}
    </div>
  );
}

Performance Monitoring

// lib/renderingMetrics.ts
export class RenderingMetricsCollector {
  private metrics: Map<string, number[]> = new Map();
 
  recordRenderTime(method: 'SSR' | 'SSG' | 'ISR', duration: number) {
    const key = method.toLowerCase();
    const times = this.metrics.get(key) || [];
    times.push(duration);
    this.metrics.set(key, times.slice(-100)); // Keep last 100 measurements
  }
 
  getAverageRenderTime(method: 'SSR' | 'SSG' | 'ISR'): number {
    const times = this.metrics.get(method.toLowerCase()) || [];
    if (times.length === 0) return 0;
    
    return times.reduce((sum, time) => sum + time, 0) / times.length;
  }
 
  getMetricsSummary() {
    const summary = {};
    
    for (const [method, times] of this.metrics) {
      summary[method] = {
        average: this.getAverageRenderTime(method.toUpperCase() as any),
        p95: this.getPercentile(times, 95),
        p99: this.getPercentile(times, 99),
        samples: times.length
      };
    }
    
    return summary;
  }
 
  private getPercentile(values: number[], percentile: number): number {
    const sorted = [...values].sort((a, b) => a - b);
    const index = Math.ceil((percentile / 100) * sorted.length) - 1;
    return sorted[index] || 0;
  }
}
 
// Usage in monitoring dashboard
export const metricsCollector = new RenderingMetricsCollector();
 
// Middleware to track render performance
export function withRenderMetrics(handler: any, method: 'SSR' | 'SSG' | 'ISR') {
  return async (req: any, res: any) => {
    const startTime = performance.now();
    
    try {
      const result = await handler(req, res);
      const duration = performance.now() - startTime;
      
      metricsCollector.recordRenderTime(method, duration);
      
      return result;
    } catch (error) {
      const duration = performance.now() - startTime;
      metricsCollector.recordRenderTime(method, duration);
      
      throw error;
    }
  };
}

The key to choosing the right rendering method is understanding your content patterns, user expectations, and performance requirements. SSG for maximum performance with static content, SSR for dynamic personalized experiences, and ISR as the sweet spot for most applications that need fresh content with great performance.

Remember: you don't have to choose just one method. Modern Next.js applications often use a hybrid approach, rendering different pages with different strategies based on their specific needs.