Mastering Rendering in Next.js: Static, Dynamic, and Incremental Techniques (SSR, SSG and ISR)


Introduction to Next.js Pre-Rendering

Next.js, a React framework, supports several key pre-rendering strategies that help developers optimize web applications. These strategies include Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR). Understanding these methods allows you to choose the most appropriate solution based on the nature of your content and its update frequency.

Let's start by discussing the main features of the rendering techniques in Next.js, along with some examples for each:

Static rendering

This is the default strategy. Routes are pre-rendered at build time to a static page, but the result is not cached. You can also prevent the pre-rendering by adding the export const dynamic = 'force-dynamic' or use some Dynamic API (like cookies, headers, connection, draftMode, searchParams prop or unstable_noStore)

// STATIC RENDERING
// ✅ pre-rendered at build time
// ❌ not-cached
export default async function Page() {
  const data = await fetch("https://api.vercel.app/blog");
  const posts = await data.json();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Dynamic rendering

Routes are rendered for each user at request time. It means that the data is fresh (or getted in real-time) in every request. During rendering, if a fetch option of { cache: 'no-store' } is discovered or use some Dynamic API (like cookies, headers, connection, draftMode, searchParams prop or unstable_noStore), Next.js will switch to dynamically rendering.

Server-side Rendering (SSR) involves pre-rendering pages on the server for every request. This ensures that the page always serves up-to-date data, but it also leads to slower Time to First Byte (TTFB) as the server must regenerate the page with every request.

// DYNAMIC RENDERING
// ❌ pre-rendered at build time
// ❌ not-cached
export default async function Page() {
  const data = await fetch("https://api.vercel.app/blog", {
    cache: "no-store",
  });
  const posts = await data.json();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Static + Dynamic = Incremental Statics Regeneration (ISR)

The generateStaticParams function can be used in combination with dynamic route segments to statically generate routes at build time instead of on-demand at request time.

// INCREMENTAL STATIC REGENERATION

// ✅ cached - during 60s
// Next.js will invalidate the cache when a
// request comes in, at most once every 60 seconds.
export const revalidate = 60

// see next section of `Fallback Behavior` to learn more.
// false -> All posts not indicated by generateStaticParams will be a 404 page
// true -> All posts not indicated by generateStaticParams will server-render them dinamically
export const dynamicParams = true

// ✅ pre-rendered - only the params from generateStaticParams at build time
// If a request comes in for a path that hasn't been generated,
// Next.js will server-render the page on-demand.
export async function generateStaticParams() {
  const posts: Post[] = await fetch('https://api.vercel.app/blog').then((res) =>
    res.json()
  )
  return posts.map((post) => ({
    id: String(post.id),
  }))
}

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const id = (await params).id
  const post: Post = await fetch(`https://api.vercel.app/blog/${id}`).then(
    (res) => res.json()
  )
  return (
    <main>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </main>
  )
}

Fallback Behavior - dynamicParams

When a user requests a page that hasn't been pre-rendered, Next.js can display a fallback version of the page, such as a loading indicator, while the page is being rendered in the background. Once the rendering is complete, the loading state is replaced with the fully rendered page.

For example, if you have 50,000 articles, pre-rendering all of them at once could be too slow. Instead, you can lazily pre-render pages like "How to Grow a Garden" only when the user requests it.

  1. A user requests the "How to Grow a Garden" page.
  2. Since it hasn't been pre-rendered, Next.js shows a loading indicator.
  3. Next.js pre-renders the page in the background.
  4. Once pre-rendering is complete, the loading page is replaced with the rendered article.
  5. The next time anyone requests the page, the pre-rendered version is served instantly, just like static pages.
// INCREMENTAL STATIC REGENERATION
// ✅ pre-rendered at build time
// ✅ cached - during 3600ms
import { unstable_cache } from "next/cache";
import { db, posts } from "@/lib/db";

const getPosts = unstable_cache(
  async () => {
    return await db.select().from(posts);
  },
  ["posts"],
  { revalidate: 3600, tags: ["posts"] }
);

export default async function Page() {
  const allPosts = await getPosts();

  return (
    <ul>
      {allPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

On-Demand Revalidation

As you can see in the previous examples, on-demand revalidation allows updates to be triggered manually based on specific events, such as form submissions. This ensures that the latest content is displayed immediately, especially in scenarios like content management systems (CMS) where updates may occur regularly.

By tagging certain data during fetch operations, you can trigger a revalidation when needed.

export default async function Page() {
  const res = await fetch("https://...", { next: { tags: ["collection"] } });
  const data = await res.json();
  // ...
}

You can also revalidate this fetch call tagged with a collection by calling revalidateTag in a Server Action:

"use server";

import { revalidateTag } from "next/cache";

export default async function action() {
  // ...
  revalidateTag("collection");
}

Using getStaticProps

getStaticProps is a Next.js function used exclusively in the pages/ directory (pre-Next.js 13) to enable Static Site Generation (SSG). It allows pages to be pre-rendered at build time, ensuring the generated HTML is served to users with optimal performance. This function is ideal for pages that rely on data fetched from external APIs, databases, or other asynchronous sources.

When getStaticProps is used, Next.js generates the HTML and JSON (containing the props) at build time and serves them statically. This ensures fast page loads and SEO-friendly content. Key features of getStaticProps:

  1. Build-Time Execution: Runs only during the build process, never on the client side.
  2. Static Data: Suitable for pages where the data changes infrequently or can remain static until the next build.
  3. Optimized for Performance: Pre-rendered pages reduce server load and improve response times.
  4. Props Injection: Passes fetched data as props to the page component.

Below is an example that fetches product data at build time and pre-renders a page displaying a product list.

// INCREMENTAL STATIC REGENERATION
// ✅ pre-rendered at build time - indicated by getStaticProps

// This function runs at build time on the build server.
// It fetches data and provides it as props to the page component.
export async function getStaticProps() {
  const products = await getProductsFromDatabase(); // Simulates fetching data from a database

  return {
    props: {
      products, // This data will be injected into the page as props
    },
  };
}

// The page component receives the `products` prop from getStaticProps at build time.
export default function Products({ products }) {
  return (
    <>
      <h1>Products</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
    </>
  );
}

Using getStaticPaths

getStaticPaths is another function used in the pages/ directory for handling dynamic routes in combination with getStaticProps. It enables Next.js to pre-render pages for dynamic paths based on the data fetched during the build process.

Key Features of getStaticPaths
  1. Define Dynamic Routes: Specifies which dynamic routes should be pre-rendered at build time.
  2. Works with getStaticProps: Fetches the dynamic paths and provides them to getStaticProps.
  3. Fallback Handling: Handles paths not included in the pre-rendered list using the fallback option.
Example: Generating Dynamic Product Pages

Below is an example where product pages are pre-rendered dynamically based on the product IDs fetched from a database.

// This function defines the dynamic routes to be pre-rendered at build time.
export async function getStaticPaths() {
  const products = await getProductsFromDatabase(); 

  return {
    paths: products.map((product) => ({
      params: { id: product.id.toString() },
    })),
    fallback: false, // Indicates that only paths returned here will be pre-rendered
  };
}

// This function fetches data for each dynamic route at build time.
export async function getStaticProps({ params }) {
  const product = await getProductById(params.id); // Fetch product details based on the ID

  return {
    props: {
      product, // Pass the product data to the page component as props
    },
  };
}

// The dynamic page component receives `product` as a prop.
export default function ProductPage({ product }) {
  return (
    <>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>Price: ${product.price}</p>
    </>
  );
}

Both getStaticProps and getStaticPaths are designed for applications using the pages/ directory in versions prior to Next.js 13. For the new App Router in app/, refer to generateStaticParams for similar functionality.

Conclusion

Next.js offers powerful strategies for pre-rendering pages, each suited to different use cases. By using SSR, SSG, and ISR appropriately, you can achieve optimal performance and data freshness for your applications, whether you're working with static content or highly dynamic data. Understanding when and how to apply each strategy is key to building fast, scalable web applications.