SSR, SSG, ISR & SPA โ€” Next.js Rendering Modes Explained with Real Examples

In the world of modern web development, choosing the right rendering strategy can significantly impact your application's performance, SEO, and user experience. Next.js, a popular React framework, offers multiple rendering modes to help developers optimize their applications for different use cases.

In this article, we'll dive deep into the four primary rendering strategies in Next.js: Server-Side Rendering (SSR), Static Site Generation (SSG), Incremental Static Regeneration (ISR), and Single-Page Applications (SPA). We'll explain each with real-world examples and practical code snippets to help you understand when and how to use each approach.

Understanding Rendering in Next.js

Before we dive into the specific rendering modes, let's clarify what "rendering" means in the context of web applications.

Rendering is the process of generating HTML from your React components. This can happen at different times:

  • Build time: HTML is generated when you build your application

  • Request time: HTML is generated when a user requests a page

  • Client-side: HTML is generated in the browser using JavaScript

Next.js gives developers the flexibility to choose when and where rendering happens for each page or component in their application. Let's explore each option in detail.

Server-Side Rendering (SSR)

SSR generates HTML for each request on the server; this means that every time a user visits a page, the server computes the HTML specifically for that request.

When to use SSR:

  • Pages that need access to request-specific data (like cookies or headers)

  • Pages with frequently changing data

  • Pages that require user-specific content

  • When SEO is important and content changes often

Real-world example: E-commerce Product Page

An e-commerce product page is a perfect candidate for SSR because:

  • Product availability and pricing might change frequently

  • You want to show personalized recommendations

  • SEO is critical for product discoverability

Code example:

// pages/products/[id].js
export default function ProductPage({ product, recommendations }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>${product.price}</p>
      <div className="stock-status">
        {product.inStock ? 'In Stock' : 'Out of Stock'}
      </div>

      <h2>You might also like</h2>
      <div className="recommendations">
        {recommendations.map(item => (
          <ProductCard key={item.id} product={item} />
        ))}
      </div>
    </div>
  );
}

// This function runs on every request
export async function getServerSideProps(context) {
  const { id } = context.params;
  const { req } = context;

  // Get user's cookie for personalization
  const userToken = req.cookies.userToken;

  // Fetch the latest product data
  const product = await fetchProduct(id);

  // Get personalized recommendations based on user history
  const recommendations = await fetchRecommendations(id, userToken);

  return {
    props: {
      product,
      recommendations,
    },
  };
}

Performance characteristics:

  • Time to First Byte (TTFB): Slower than static methods because the server needs to generate HTML for each request

  • SEO: Excellent, as search engines receive fully rendered HTML

  • Data freshness: Always up-to-date

  • Server load: Higher, as the server processes each request

Static Site Generation (SSG)

Static Site Generation pre-renders pages at build time. The resulting HTML is stored on a CDN and reused for each request, making page loads extremely fast.

When to use SSG:

  • Marketing pages, blog posts, documentation

  • Content that doesn't change frequently

  • Pages that don't need to be personalized

  • When maximum performance is a priority

Real-world example: Blog Post

A blog post is an ideal candidate for SSG because:

  • Content rarely changes after publication

  • SEO is crucial for discoverability

  • Fast loading time improves user experience

Code example:

// pages/blog/[slug].js
export default function BlogPost({ post, author }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div className="author-info">
        <img src={author.avatar} alt={author.name} />
        <span>By {author.name}</span>
      </div>
      <div className="post-date">{new Date(post.date).toLocaleDateString()}</div>
      <div className="content" dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

// This function runs at build time
export async function getStaticProps({ params }) {
  const { slug } = params;

  // Fetch blog post data
  const post = await fetchBlogPost(slug);

  // Fetch author data
  const author = await fetchAuthor(post.authorId);

  return {
    props: {
      post,
      author,
    },
  };
}

// This function determines which paths to pre-render
export async function getStaticPaths() {
  // Fetch list of all blog posts
  const posts = await fetchAllBlogPosts();

  // Generate paths for each post
  const paths = posts.map(post => ({
    params: { slug: post.slug },
  }));

  return {
    paths,
    // fallback: false means 404 for any paths not returned by getStaticPaths
    fallback: false,
  };
}

Performance characteristics:

  • TTFB: Very fast, as HTML is pre-generated and served from CDN

  • SEO: Excellent, search engines receive complete HTML

  • Data freshness: Data can be stale if content changes after build

  • Build time: Increases with the number of pages

  • Server load: Minimal during user requests, as pages are pre-rendered

Incremental Static Regeneration (ISR)

ISR combines the benefits of SSG and SSR. It allows you to create or update static pages after you've built your site. This means you get the performance benefits of static generation while still showing fresh content.

When to use ISR:

  • Content that changes occasionally but not on every request

  • Pages with high traffic where performance is critical

  • E-commerce category pages, news sites, etc.

Real-world example: News Article Page

News articles are perfect for ISR because:

  • Content doesn't change after publication, but new comments might be added

  • Articles receive most traffic shortly after publication

  • You want the performance benefits of static generation

Code example:

// pages/news/[slug].js
export default function NewsArticle({ article, comments }) {
  return (
    <div>
      <h1>{article.headline}</h1>
      <div className="byline">By {article.author}</div>
      <div className="published-date">
        {new Date(article.publishedAt).toLocaleString()}
      </div>
      <div className="content" dangerouslySetInnerHTML={{ __html: article.content }} />

      <section className="comments">
        <h2>Comments ({comments.length})</h2>
        {comments.map(comment => (
          <div key={comment.id} className="comment">
            <strong>{comment.username}</strong>
            <p>{comment.text}</p>
          </div>
        ))}
      </section>
    </div>
  );
}

// This runs at build time and then re-runs at most every 10 minutes
export async function getStaticProps({ params }) {
  const { slug } = params;

  // Fetch article data
  const article = await fetchArticle(slug);

  // Fetch comments
  const comments = await fetchComments(article.id);

  return {
    props: {
      article,
      comments,
    },
    // Re-generate page at most once every 10 minutes
    revalidate: 600,
  };
}

export async function getStaticPaths() {
  // Only pre-render the most important articles at build time
  const featuredArticles = await fetchFeaturedArticles();

  const paths = featuredArticles.map(article => ({
    params: { slug: article.slug },
  }));

  return {
    paths,
    // fallback: 'blocking' means ungenerated pages will be rendered on-demand (like SSR)
    // and then cached for subsequent requests
    fallback: 'blocking',
  };
}

Performance characteristics:

  • TTFB: Fast for cached pages, similar to SSR for the first request on uncached pages

  • SEO: Excellent, search engines receive complete HTML

  • Data freshness: Periodically updated based on the revalidate interval

  • Server load: Moderate, as pages are regenerated periodically, not on every request

Single-Page Application (SPA)

In the SPA model, rendering happens entirely on the client side. The initial HTML is minimal, and JavaScript takes over to render the UI in the browser.

Next.js supports this approach through client-side data fetching with SWR or React Query or by using the new App Router with the 'use client'directive.

When to use SPA:

  • Highly interactive applications

  • Private, authenticated pages where SEO isn't a concern

  • Dashboard interfaces with real-time updates

  • When you want to minimize server load

Real-world example: User Dashboard

A user dashboard is ideal for client-side rendering because

  • It's behind authentication, so SEO isn't needed

  • It often requires real-time updates

  • Interactive elements benefit from client-side state management

Code example (using App Router):

'use client'

import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'

export default function Dashboard() {
  const [activeTab, setActiveTab] = useState('overview');

  // Client-side data fetching
  const { data: stats, isLoading: statsLoading } = useQuery({
    queryKey: ['dashboard-stats'],
    queryFn: async () => {
      const res = await fetch('/api/dashboard/stats');
      if (!res.ok) throw new Error('Failed to fetch stats');
      return res.json();
    }
  });

  const { data: activities, isLoading: activitiesLoading } = useQuery({
    queryKey: ['activities'],
    queryFn: async () => {
      const res = await fetch('/api/dashboard/activities');
      if (!res.ok) throw new Error('Failed to fetch activities');
      return res.json();
    }
  });

  if (statsLoading || activitiesLoading) {
    return <div className="dashboard-loading">Loading dashboard data...</div>;
  }

  return (
    <div className="dashboard">
      <nav className="dashboard-tabs">
        <button 
          className={activeTab === 'overview' ? 'active' : ''} 
          onClick={() => setActiveTab('overview')}
        >
          Overview
        </button>
        <button 
          className={activeTab === 'activities' ? 'active' : ''} 
          onClick={() => setActiveTab('activities')}
        >
          Activities
        </button>
        <button 
          className={activeTab === 'settings' ? 'active' : ''} 
          onClick={() => setActiveTab('settings')}
        >
          Settings
        </button>
      </nav>

      {activeTab === 'overview' && (
        <div className="dashboard-overview">
          <div className="stats-grid">
            <div className="stat-card">
              <h3>Total Users</h3>
              <div className="stat-value">{stats.totalUsers.toLocaleString()}</div>
            </div>
            <div className="stat-card">
              <h3>Active Today</h3>
              <div className="stat-value">{stats.activeToday.toLocaleString()}</div>
            </div>
            {/* More stat cards */}
          </div>
        </div>
      )}

      {activeTab === 'activities' && (
        <div className="activities-list">
          {activities.map(activity => (
            <div key={activity.id} className="activity-item">
              <div className="activity-icon">{activity.type === 'comment' ? '๐Ÿ’ฌ' : '๐Ÿ‘'}</div>
              <div className="activity-details">
                <strong>{activity.user}</strong> {activity.action}
                <div className="activity-time">{new Date(activity.timestamp).toLocaleString()}</div>
              </div>
            </div>
          ))}
        </div>
      )}

      {activeTab === 'settings' && (
        <div className="settings-panel">
          <h2>Dashboard Settings</h2>
          {/* Settings form */}
        </div>
      )}
    </div>
  );
}

Performance characteristics:

  • TTFB: Fast, but users see a loading state until data is fetched

  • SEO: Poor, as search engines might not execute JavaScript

  • Data freshness: Always up-to-date as data is fetched on the client

  • Server load: Low for HTML serving, but API endpoints might receive heavy traffic

  • Interactivity: Excellent, as the application runs in the browser

Choosing the Right Rendering Strategy

To help you decide which rendering strategy to use, consider these factors:

  1. SEO requirements: If SEO is critical, prefer SSR, SSG, or ISR over client-side rendering

  2. Data freshness: For real-time data, use SSR or client-side rendering

  3. Performance needs: For maximum performance, use SSG or ISR

  4. Build time constraints: If you have many pages, SSG might lead to long build times

  5. Interactivity: Highly interactive pages benefit from client-side rendering

  6. User experience: Consider the loading experience for each approach

Here's a quick reference table:

Rendering ModeSEOPerformanceData FreshnessBuild TimeBest For
SSRExcellentGoodExcellentN/APersonalized content, frequently updated data
SSGExcellentExcellentStaticIncreases with pagesMarketing sites, blogs, documentation
ISRExcellentExcellentGoodModerateE-commerce, news sites with occasional updates
SPAPoorVariableExcellentFastDashboards, authenticated applications

Mixing Strategies for Optimal Performance

Next.js allows you to use different rendering strategies for different parts of your application. This hybrid approach lets you optimize each page according to its specific requirements.

Example hybrid approach:

  • Homepage: SSG for fast loading

  • Product listing: ISR to balance performance and freshness

  • Product detail: SSR for real-time inventory and pricing

  • User dashboard: Client-side rendering for interactivity

Conclusion

Next.js offers unparalleled flexibility in how you render your applications. By understanding the tradeoffs between SSR, SSG, ISR, and SPA rendering, you can make informed decisions that optimize for performance, SEO, and user experience.

Remember that there's no one-size-fits-all solution. The best approach often involves mixing rendering strategies based on the specific requirements of each page or component.

As you build your Next.js applications, continually evaluate your rendering choices and measure their impact on real-world performance metrics like Core Web Vitals. This data-driven approach will help you refine your rendering strategy over time.

Happy coding!

0
Subscribe to my newsletter

Read articles from Timothy George Benjamindas directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Timothy George Benjamindas
Timothy George Benjamindas

I am a freelance web developer. I provide easier solution for your SaaS ideas and cms with personal website. I use Next.js primarily and the PERN stack based on the client requirements. I am here to find a good place to share and grow my knowledge as well as good projects.