Next.js Data Fetching: SSR, SSG, and ISR

Next.js gives you three distinct approaches to data fetching, each optimized for different scenarios. The choice between Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental...

Key Insights

  • Server-Side Rendering (SSR) fetches data on every request for personalized or real-time content, while Static Site Generation (SSG) pre-renders pages at build time for maximum performance
  • Incremental Static Regeneration (ISR) combines the best of both worlds by serving static pages that regenerate in the background at specified intervals
  • Choose SSR for user-specific data, SSG for content that rarely changes, and ISR for content that updates periodically but doesn’t need real-time accuracy

Understanding Next.js Rendering Strategies

Next.js gives you three distinct approaches to data fetching, each optimized for different scenarios. The choice between Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) fundamentally impacts your application’s performance, scalability, and user experience.

SSR generates HTML on every request, ensuring fresh data but requiring server resources for each page view. SSG pre-renders pages at build time, delivering blazing-fast performance but requiring rebuilds for content updates. ISR splits the difference, serving static pages while regenerating them in the background at defined intervals.

The performance implications are significant. SSG pages load instantly from a CDN with no server computation. SSR pages require server processing on every request, adding 100-500ms of latency. ISR pages load like SSG most of the time, with background regeneration ensuring content freshness without user-facing delays.

Server-Side Rendering with getServerSideProps

SSR excels when you need guaranteed fresh data on every request. User dashboards, personalized recommendations, and real-time inventory systems are ideal candidates. The server fetches data, renders the page, and sends complete HTML to the client.

Here’s a practical example of a blog post page that fetches data from an API on each request:

import { GetServerSideProps } from 'next';

interface Post {
  id: string;
  title: string;
  content: string;
  author: string;
  publishedAt: string;
  views: number;
}

interface PostPageProps {
  post: Post;
  userLocation: string;
}

export default function PostPage({ post, userLocation }: PostPageProps) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {post.author}  {post.publishedAt}</p>
      <p>Views: {post.views}  Reading from: {userLocation}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

export const getServerSideProps: GetServerSideProps<PostPageProps> = async (context) => {
  const { id } = context.params!;
  
  // Fetch post data on every request
  const res = await fetch(`https://api.example.com/posts/${id}`);
  const post: Post = await res.json();
  
  // Access request headers for personalization
  const userLocation = context.req.headers['x-user-location'] || 'Unknown';
  
  return {
    props: {
      post,
      userLocation: userLocation as string,
    },
  };
};

The key advantage here is accessing request-specific data like headers, cookies, or query parameters. You can personalize content based on the user’s authentication state, location, or preferences. The tradeoff is server load—every page view hits your server and external APIs.

Static Site Generation with getStaticProps

SSG pre-renders pages at build time, making it the fastest option for content that doesn’t change frequently. Marketing pages, documentation, and product catalogs are perfect use cases. The pages are generated once and served from a CDN globally.

Here’s a product catalog implementation:

import { GetStaticProps } from 'next';

interface Product {
  id: string;
  name: string;
  description: string;
  price: number;
  category: string;
}

interface ProductsPageProps {
  products: Product[];
  categories: string[];
}

export default function ProductsPage({ products, categories }: ProductsPageProps) {
  return (
    <div>
      <h1>Our Products</h1>
      <nav>
        {categories.map(cat => (
          <a key={cat} href={`#${cat}`}>{cat}</a>
        ))}
      </nav>
      <div className="product-grid">
        {products.map(product => (
          <div key={product.id}>
            <h2>{product.name}</h2>
            <p>{product.description}</p>
            <p>${product.price}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

export const getStaticProps: GetStaticProps<ProductsPageProps> = async () => {
  const res = await fetch('https://api.example.com/products');
  const products: Product[] = await res.json();
  
  const categories = [...new Set(products.map(p => p.category))];
  
  return {
    props: {
      products,
      categories,
    },
  };
};

For dynamic routes, combine getStaticProps with getStaticPaths:

import { GetStaticProps, GetStaticPaths } from 'next';

interface ProductPageProps {
  product: Product;
}

export default function ProductPage({ product }: ProductPageProps) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>Price: ${product.price}</p>
    </div>
  );
}

export const getStaticPaths: GetStaticPaths = async () => {
  const res = await fetch('https://api.example.com/products');
  const products: Product[] = await res.json();
  
  const paths = products.map(product => ({
    params: { id: product.id },
  }));
  
  return {
    paths,
    fallback: 'blocking', // or false, or true
  };
};

export const getStaticProps: GetStaticProps<ProductPageProps> = async (context) => {
  const { id } = context.params!;
  const res = await fetch(`https://api.example.com/products/${id}`);
  const product: Product = await res.json();
  
  return {
    props: {
      product,
    },
  };
};

The fallback option controls behavior for paths not pre-rendered. Use false to 404 on missing paths, true to show a loading state while generating, or 'blocking' to SSR on first request then cache.

Incremental Static Regeneration

ISR is the sweet spot for many applications. Pages are statically generated but regenerate in the background after a specified time. Users always get a fast, cached response while Next.js updates stale content behind the scenes.

Here’s a news article page with 60-second revalidation:

import { GetStaticProps, GetStaticPaths } from 'next';

interface Article {
  id: string;
  headline: string;
  content: string;
  publishedAt: string;
  updatedAt: string;
  viewCount: number;
}

interface ArticlePageProps {
  article: Article;
  generatedAt: string;
}

export default function ArticlePage({ article, generatedAt }: ArticlePageProps) {
  return (
    <article>
      <h1>{article.headline}</h1>
      <p>Published: {article.publishedAt}</p>
      <p>Last updated: {article.updatedAt}</p>
      <p>Views: {article.viewCount}</p>
      <p><small>Page generated at: {generatedAt}</small></p>
      <div dangerouslySetInnerHTML={{ __html: article.content }} />
    </article>
  );
}

export const getStaticPaths: GetStaticPaths = async () => {
  // Only pre-render most popular articles
  const res = await fetch('https://api.example.com/articles/popular?limit=100');
  const articles: Article[] = await res.json();
  
  const paths = articles.map(article => ({
    params: { id: article.id },
  }));
  
  return {
    paths,
    fallback: 'blocking', // Generate other articles on-demand
  };
};

export const getStaticProps: GetStaticProps<ArticlePageProps> = async (context) => {
  const { id } = context.params!;
  const res = await fetch(`https://api.example.com/articles/${id}`);
  const article: Article = await res.json();
  
  return {
    props: {
      article,
      generatedAt: new Date().toISOString(),
    },
    revalidate: 60, // Regenerate page every 60 seconds
  };
};

With revalidate: 60, the first request after 60 seconds still receives the cached page, but triggers background regeneration. Subsequent requests get the updated version. This “stale-while-revalidate” pattern ensures users never wait for data fetching.

Comparing Performance and Use Cases

Strategy Data Freshness Performance Build Time Best For
SSR Real-time 200-500ms N/A User dashboards, auth-required pages, real-time data
SSG Build-time <50ms Long for large sites Documentation, marketing pages, blogs
ISR Periodic <50ms (usually) Fast News sites, e-commerce, content that updates regularly

Choose SSR when data must be current on every request. Accept the server cost and latency for guaranteed freshness. Choose SSG when content changes infrequently and you can rebuild on updates. Maximize performance and minimize server costs. Choose ISR when content updates regularly but doesn’t require real-time accuracy. Balance performance with freshness.

Best Practices and Common Pitfalls

Always handle errors gracefully in your data fetching functions:

export const getServerSideProps: GetServerSideProps = async (context) => {
  try {
    const res = await fetch(`https://api.example.com/data/${context.params!.id}`);
    
    if (!res.ok) {
      return {
        notFound: true, // Shows 404 page
      };
    }
    
    const data = await res.json();
    
    return {
      props: { data },
    };
  } catch (error) {
    console.error('Data fetching failed:', error);
    
    return {
      redirect: {
        destination: '/error',
        permanent: false,
      },
    };
  }
};

Common mistakes to avoid:

Over-fetching with SSR: Don’t use SSR when SSG or ISR would suffice. Every SSR page view costs server resources. If data doesn’t need to be real-time, use ISR with appropriate revalidation.

Forgetting fallback handling: When using getStaticPaths with fallback: true, always check if the router is in fallback state and show a loading UI.

Exposing secrets: Data fetching functions run server-side, but don’t hardcode API keys. Use environment variables and never expose them to the client.

Ignoring caching headers: For SSR, leverage Cache-Control headers to reduce server load for pages that can be cached briefly.

Moving Forward

Next.js data fetching gives you precise control over the performance-freshness tradeoff. SSR guarantees fresh data at the cost of server processing. SSG delivers maximum performance for static content. ISR provides the best of both worlds for most applications.

Start with ISR as your default choice. Move to SSG for truly static content and SSR only when you need request-specific data or real-time accuracy. Measure your actual performance and adjust based on user behavior and business requirements.

Note that Next.js 13+ introduces the App Router with React Server Components, offering a new paradigm for data fetching. While the Pages Router and these patterns remain fully supported, consider exploring Server Components for new projects as they provide even more granular control over server and client rendering.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.