Understanding Web Rendering Methods - CSR, SSR, SSG, and ISR

A comprehensive comparison of different web rendering approaches

Featured image



Overview

Web rendering is the process of generating visual content from web code, including HTML, CSS, and JavaScript. Let’s explore the four main rendering approaches: CSR, SSR, SSG, and ISR.

The way web content is rendered significantly impacts user experience, performance, search engine optimization, and developer workflows. Modern web development has evolved beyond traditional server-side rendering to offer a spectrum of rendering strategies, each with distinct advantages and trade-offs. Understanding these approaches helps developers select the optimal rendering strategy for specific use cases.


Client-Side Rendering (CSR)

Client-Side Rendering shifts the rendering burden from servers to browsers, making it ideal for highly interactive, dynamic web applications.

Process:

  1. Browser downloads minimal HTML
  2. JavaScript loads and executes
  3. UI renders dynamically
  4. Handles user interactions
How CSR Works

In client-side rendering, the server sends a minimal HTML file with links to JavaScript bundles. The browser downloads and executes these JavaScript files, which then:

  1. Initialize the JavaScript framework/library (React, Vue, Angular, etc.)
  2. Make API calls to fetch necessary data
  3. Generate DOM elements based on the received data
  4. Insert these elements into the page
  5. Attach event listeners for interactivity

This approach places most of the rendering logic in the client's browser.


Technical Implementation

A basic React CSR implementation looks like this:

Server response (minimal HTML):

<!DOCTYPE html>
<html>
<head>
  <title>CSR App</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <div id="root">
    <!-- App will be rendered here -->
  </div>

  <!-- JavaScript bundles -->
  <script src="/runtime.js"></script>
  <script src="/vendor.js"></script>
  <script src="/main.js"></script>
</body>
</html>

Client-side React code:

// App.jsx
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';

function App() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Fetch data after component mounts
    fetch('https://api.example.com/data')
      .then(response => response.json())
      .then(result => {
        setData(result);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>Loading...</div>;

  return (
    <div className="app">
      <h1>{data.title}</h1>
      <p>{data.description}</p>
      {/* More UI elements */}
    </div>
  );
}

// Render the app when DOM is ready
ReactDOM.render(<App />, document.getElementById('root'));


Advantages

Disadvantages


Server-Side Rendering (SSR)

Server-Side Rendering generates complete HTML pages on the server for each request, delivering a fully rendered page to the browser.

Process:

  1. Server processes request
  2. Fetches necessary data
  3. Generates complete HTML
  4. Sends rendered page
  5. Browser displays content
  6. Hydration adds interactivity
Hydration in SSR

Hydration is a critical process in modern SSR applications that bridges server rendering and client interactivity. After the server sends pre-rendered HTML, the JavaScript bundle loads and:

  1. Reconnects event handlers to the existing DOM elements
  2. Sets up internal state to match the rendered output
  3. Makes the page interactive without re-rendering all content

Effective hydration is key to the performance benefits of SSR while maintaining interactivity.


Technical Implementation

A basic Next.js SSR implementation:

// pages/products/[id].js in Next.js
import React from 'react';

function ProductPage({ product }) {
  return (
    <div className="product">
      <h1>{product.name}</h1>
      <p className="price">${product.price}</p>
      <p className="description">{product.description}</p>
      <button onClick={() => console.log('Add to cart clicked')}>
        Add to Cart
      </button>
    </div>
  );
}

// This function runs on the server for every request
export async function getServerSideProps(context) {
  const { id } = context.params;
  
  // Fetch data from API or database
  const response = await fetch(`https://api.example.com/products/${id}`);
  const product = await response.json();
  
  // Pass data to the page component as props
  return {
    props: { product }
  };
}

export default ProductPage;


Advantages

Disadvantages

sequenceDiagram participant User participant Browser participant Server participant Database User->>Browser: Request Page Browser->>Server: Request HTML Server->>Database: Fetch Data Database-->>Server: Return Data Server->>Server: Render HTML with Data Server->>Browser: Send Rendered HTML Note over Browser: First Contentful Paint
(Content visible but not interactive) Browser->>Browser: Parse HTML and CSS Browser->>Server: Request JavaScript Server-->>Browser: Send JavaScript Browser->>Browser: Execute JavaScript
and Hydrate DOM Note over Browser: Time to Interactive Browser->>User: Fully Interactive Page


Static Site Generation (SSG)

Static Site Generation pre-builds all pages at build time rather than on-demand, resulting in extremely fast load times and efficient scaling.

Process:

  1. Pages built at build time
  2. Pre-renders all HTML/CSS/JS
  3. Serves via CDN
  4. Instant page loads
  5. Optional client-side hydration

Technical Implementation

A Gatsby (React-based SSG) implementation:

// src/templates/product.js in Gatsby
import React from 'react';
import { graphql } from 'gatsby';

function ProductTemplate({ data }) {
  const product = data.productsApi.product;
  
  return (
    <div className="product">
      <h1>{product.name}</h1>
      <p className="price">${product.price}</p>
      <p className="description">{product.description}</p>
      <button onClick={() => console.log('Add to cart clicked')}>
        Add to Cart
      </button>
    </div>
  );
}

// This query runs at build time
export const query = graphql`
  query ProductPage($id: String!) {
    productsApi {
      product(id: $id) {
        id
        name
        price
        description
      }
    }
  }
`;

export default ProductTemplate;

Build process configuration (gatsby-node.js):

exports.createPages = async ({ graphql, actions }) => {
  const { createPage } = actions;
  
  // Query all products at build time
  const result = await graphql(`
    {
      productsApi {
        products {
          id
          slug
        }
      }
    }
  `);
  
  // Create a page for each product
  result.data.productsApi.products.forEach(product => {
    createPage({
      path: `/product/${product.slug}`,
      component: require.resolve('./src/templates/product.js'),
      context: {
        id: product.id
      }
    });
  });
};

Advantages

Disadvantages

graph LR A[🛠️ Build Time] --> B[📂 Fetch All Data] B --> C[Generate Static HTML Pages] C --> D[Optimize Assets] D --> E[Deploy to CDN] F[User Request] --> G[CDN Edge Server] G --> H[Deliver Pre-built HTML] H --> I[Browser Renders Page] I --> J[Load JavaScript for Interactivity]


Incremental Static Regeneration (ISR)

Incremental Static Regeneration combines the benefits of static generation with the ability to update content without rebuilding the entire site.

Process:

  1. Initial static generation
  2. Serve cached pages
  3. Background revalidation
  4. Selective page updates
  5. Update the cache
ISR Revalidation Strategies

ISR offers multiple approaches to updating content:

Time-based Revalidation:
  • Pages are regenerated after a specified time period
  • Useful for content that changes on a predictable schedule
  • Configured with a revalidation window (e.g., every hour)
On-demand Revalidation:
  • Pages are regenerated when explicitly triggered
  • Useful for content managed through a CMS
  • Can be triggered by webhooks or API calls

Both strategies serve stale content while regenerating in the background, ensuring uninterrupted user experience.

Technical Implementation

A Next.js ISR implementation:

// pages/posts/[slug].js in Next.js
import React from 'react';

function Post({ post, lastUpdated }) {
  return (
    <div className="post">
      <h1>{post.title}</h1>
      <p className="metadata">Last updated: {new Date(lastUpdated).toLocaleString()}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </div>
  );
}

export async function getStaticProps({ params }) {
  const { slug } = params;
  
  // Fetch data for this specific post
  const response = await fetch(`https://api.example.com/posts/${slug}`);
  const post = await response.json();
  
  return {
    props: {
      post,
      lastUpdated: Date.now()
    },
    // Revalidate the page every 60 seconds
    revalidate: 60
  };
}

export async function getStaticPaths() {
  // Fetch the most important posts at build time
  const response = await fetch('https://api.example.com/posts/popular');
  const popularPosts = await response.json();
  
  // Pre-render only popular posts
  const paths = popularPosts.map(post => ({
    params: { slug: post.slug }
  }));
  
  return {
    paths,
    // Generate other pages on-demand
    fallback: 'blocking'
  };
}

export default Post;

On-demand revalidation API route (Next.js):

// pages/api/revalidate.js
export default async function handler(req, res) {
  // Check for secret to confirm this is a valid request
  if (req.query.secret !== process.env.REVALIDATION_TOKEN) {
    return res.status(401).json({ message: 'Invalid token' });
  }

  try {
    // Extract slug from request body
    const { slug } = req.body;
    
    // Revalidate the specific post page
    await res.revalidate(`/posts/${slug}`);
    
    return res.json({ revalidated: true });
  } catch (err) {
    // If there was an error, Next.js will continue to show the last successfully generated page
    return res.status(500).send('Error revalidating');
  }
}

Advantages

Disadvantages

graph TD A[User Requests Page] --> B{Is Page in Cache?} B -- Yes --> C[Serve Cached Version] B -- No --> D[Generate Page on Demand] D --> E[Store in Cache] E --> C F[Revalidation Timer] -.-> G[Check if Content Changed] G -- Changed --> H[Regenerate Page in Background] H --> I[Update Cache] G -- Unchanged --> J[Keep Existing Cache]



Performance Impact

Each rendering method has different impacts on key performance metrics:

Metric CSR SSR SSG ISR
Time to First Byte (TTFB) Fast Slow Fastest Fast
First Contentful Paint (FCP) Slow Medium Fast Fast
Largest Contentful Paint (LCP) Slow Medium Fast Fast
Time to Interactive (TTI) Slow Medium Fast Fast
Total Blocking Time (TBT) High Medium Low Low
Cumulative Layout Shift (CLS) High Medium Low Low


Rendering Methods Comparison

Feature CSR SSR SSG ISR
Initial Load Slow Fast Fastest Fast
SEO Poor Good Excellent Good
Dynamic Content Excellent Good Poor Good
Server Load Low High Lowest Medium
Build Time N/A N/A Long Medium
Developer Experience Simple Complex Medium Complex
Infrastructure Requirements Static hosting Node.js server Static hosting Specialized hosting
Data Freshness Real-time Real-time Static Periodically updated


Use Cases and Implementations

Client-Side Rendering (CSR)
  • Use Cases: SPAs, dashboards, admin interfaces, highly interactive applications
  • Tools/Frameworks: React, Angular, Vue.js, Svelte
  • Implementation Example: Create React App, Vue CLI projects
  • Security Considerations: Exposed API keys risk, CORS issues, XSS vulnerabilities
  • Real-world Examples: Gmail, Facebook, Twitter web app
Server-Side Rendering (SSR)
  • Use Cases: News websites, e-commerce product pages, content-heavy sites, SEO-critical pages
  • Tools/Frameworks: Next.js, Nuxt.js, Angular Universal, Remix
  • Implementation Example: Next.js with getServerSideProps
  • Security Considerations: Better control over data flow, server exposure, CSRF protection needed
  • Real-world Examples: The New York Times, Amazon product pages
Static Site Generation (SSG)
  • Use Cases: Blogs, documentation sites, marketing websites, portfolio sites
  • Tools/Frameworks: Gatsby, Next.js, Hugo, Jekyll, 11ty
  • Implementation Example: Next.js with getStaticProps, Gatsby
  • Security Considerations: Secure with minimal server interaction, reduces attack vectors
  • Real-world Examples: React documentation, Stripe documentation
Incremental Static Regeneration (ISR)
  • Use Cases: E-commerce product pages with frequent updates, blogs with comments, news with moderate update frequency
  • Tools/Frameworks: Next.js (ISR), Nuxt 3, Gatsby (DSG)
  • Implementation Example: Next.js with revalidate option in getStaticProps
  • Security Considerations: Cache invalidation risks, revalidation endpoint security
  • Real-world Examples: Shopify storefronts built with Hydrogen


Hybrid Approaches

Modern web applications often combine multiple rendering strategies for optimal results:

Next.js App Router

Next.js 13+ introduces a hybrid rendering model with granular control:

// Server Component (SSR by default)
export default async function ProductPage({ params }) {
  const product = await fetchProduct(params.id);
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      
      {/* Client Component for interactive elements */}
      <AddToCartButton productId={product.id} />
    </div>
  );
}

// Client Component for interactivity
'use client'
import { useState } from 'react';

export function AddToCartButton({ productId }) {
  const [isAdding, setIsAdding] = useState(false);
  
  async function handleAddToCart() {
    setIsAdding(true);
    await addToCart(productId);
    setIsAdding(false);
  }
  
  return (
    <button 
      onClick={handleAddToCart}
      disabled={isAdding}
    >
      {isAdding ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

Islands Architecture

Partial hydration with interactive islands in an otherwise static page:

<!-- Static HTML from SSG -->
<header>
  <h1>Product Catalog</h1>
</header>

<main>
  <div class="product-grid">
    <!-- Static product cards -->
  </div>
  
  <!-- Interactive shopping cart island -->
  <div 
    data-island="shopping-cart"
    data-props='{"initialItems": []}'
  >
    <p>Loading cart...</p>
  </div>
</main>

<script type="module">
  // Only hydrate specific islands
  import { ShoppingCart } from '/components/shopping-cart.js';
  
  // Find all islands
  document.querySelectorAll('[data-island="shopping-cart"]').forEach(island => {
    const props = JSON.parse(island.dataset.props);
    // Hydrate just this component
    render(ShoppingCart, props, island);
  });
</script>


Making the Right Choice

When selecting a rendering approach, consider these factors:

  1. Content Type: Is your content static, dynamic, or a mix?
  2. Update Frequency: How often does your content change?
  3. SEO Importance: How critical is search engine visibility?
  4. User Interaction: How interactive is your application?
  5. Performance Requirements: What are your performance targets?
  6. Development Resources: What technical expertise is available?
  7. Infrastructure Constraints: What hosting options do you have?
Decision Framework for Rendering Selection

Start by answering these questions:

  • Is SEO critical? If yes, avoid pure CSR
  • Is content highly dynamic (changes with each user)? If yes, use SSR or CSR
  • Is content updated frequently but the same for all users? Consider ISR
  • Is the site mostly static with infrequent updates? SSG is ideal
  • Do you need maximum interactivity? Use CSR or a hybrid approach
  • Are server resources limited? Prefer SSG or ISR over SSR

The best solution is often a hybrid approach, using different rendering strategies for different parts of your application.



Reference