← Back to DevBytes

Server-Side Rendering with Next.js: SSR, SSG, ISR

Server-Side Rendering with Next.js: SSR, SSG, ISR

Next.js has transformed how developers build React applications by offering multiple rendering strategies out of the box. Instead of forcing you into a single approach, Next.js lets you choose between Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) on a per-route basis. This flexibility means you can optimize each page for performance, SEO, and freshness independently.

In this tutorial, you will learn what each rendering strategy is, why it matters, how to implement it in Next.js, and the best practices that will keep your application fast and maintainable.

Why Rendering Strategy Matters

Traditional client-side React applications send a nearly empty HTML file to the browser, then hydrate the page after JavaScript loads. This approach hurts time-to-first-byte, delays meaningful content, and makes SEO harder because crawlers may not execute JavaScript reliably. Server-rendered approaches solve these problems by generating HTML on the server before it reaches the client.

Choosing the right strategy depends on your data:

Static Site Generation (SSG)

SSG generates HTML at build time. The output is cached and served as static files from a CDN, making it the fastest option. Use SSG whenever your page content can be known ahead of time.

Basic SSG with getStaticProps

In the Pages Router, SSG is enabled by exporting an async function called getStaticProps. Next.js calls this function at build time, passes the returned props to your page component, and renders the HTML once.

// pages/blog/index.js
export default function BlogList({ posts }) {
  return (
    <main>
      <h1>Blog</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </main>
  );
}

export async function getStaticProps() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

  return {
    props: {
      posts,
    },
  };
}

Dynamic Routes with getStaticPaths

For dynamic routes like /blog/[slug], you must tell Next.js which paths to pre-render. Export getStaticPaths alongside getStaticProps.

// pages/blog/[slug].js
export default function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

export async function getStaticPaths() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

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

  return {
    paths,
    fallback: false,
  };
}

export async function getStaticProps({ params }) {
  const res = await fetch(`https://api.example.com/posts/${params.slug}`);
  const post = await res.json();

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

The fallback option controls behavior for paths not returned by getStaticPaths. Setting it to false returns a 404 for unknown paths. Setting it to true or 'blocking' generates the page on demand and caches it for future requests.

Server-Side Rendering (SSR)

SSR generates HTML on every request. This guarantees fresh content but adds server compute cost and latency. Use SSR when page content depends on the request, such as user-specific data, query parameters, or frequently updated information.

Implementing SSR with getServerSideProps

Export getServerSideProps to enable SSR. This function runs on the server for every incoming request.

// pages/dashboard.js
export default function Dashboard({ user, stats }) {
  return (
    <main>
      <h1>Welcome, {user.name}</h1>
      <p>Total orders: {stats.orders}</p>
      <p>Revenue: ${stats.revenue}</p>
    </main>
  );
}

export async function getServerSideProps(context) {
  const { req, res } = context;

  const token = req.cookies.token || null;
  if (!token) {
    return {
      redirect: {
        destination: '/login',
        permanent: false,
      },
    };
  }

  const userRes = await fetch('https://api.example.com/me', {
    headers: { Authorization: `Bearer ${token}` },
  });

  if (!userRes.ok) {
    return {
      redirect: {
        destination: '/login',
        permanent: false,
      },
    };
  }

  const user = await userRes.json();
  const statsRes = await fetch(`https://api.example.com/stats/${user.id}`);
  const stats = await statsRes.json();

  res.setHeader(
    'Cache-Control',
    'public, s-maxage=10, stale-while-revalidate=59'
  );

  return {
    props: {
      user,
      stats,
    },
  };
}

Notice the Cache-Control header. Even with SSR, you can cache responses at the CDN level for short periods. This pattern gives you fresh content while still benefiting from edge caching.

Incremental Static Regeneration (ISR)

ISR combines the speed of SSG with the freshness of SSR. Pages are generated statically at build time, then revalidated in the background at a configurable interval. When a request comes in after the revalidation period, Next.js serves the stale page immediately and triggers a regeneration. The next request receives the fresh page.

Time-Based Revalidation

Add a revalidate property to getStaticProps to enable ISR. The value is the number of seconds before revalidation.

// pages/products/[id].js
export default function Product({ product }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>Price: ${product.price}</p>
    </div>
  );
}

export async function getStaticPaths() {
  const res = await fetch('https://api.example.com/products');
  const products = await res.json();

  return {
    paths: products.map((p) => ({ params: { id: String(p.id) } })),
    fallback: 'blocking',
  };
}

export async function getStaticProps({ params }) {
  const res = await fetch(`https://api.example.com/products/${params.id}`);

  if (!res.ok) {
    return {
      notFound: true,
    };
  }

  const product = await res.json();

  return {
    props: {
      product,
    },
    revalidate: 60,
  };
}

With revalidate: 60, the page is regenerated at most once per minute. The first visitor after 60 seconds gets the cached version instantly while the regeneration happens in the background. This keeps your pages fast without serving stale data indefinitely.

On-Demand Revalidation

Sometimes you need to regenerate a page immediately, such as when a product price changes in your CMS. Next.js supports on-demand revalidation through an API route.

// pages/api/revalidate.js
export default async function handler(req, res) {
  const { secret, path } = req.query;

  if (secret !== process.env.REVALIDATION_SECRET) {
    return res.status(401).json({ message: 'Invalid token' });
  }

  try {
    await res.revalidate(path);
    return res.json({ revalidated: true });
  } catch (err) {
    return res.status(500).send('Error revalidating');
  }
}

To trigger regeneration, call /api/revalidate?secret=YOUR_SECRET&path=/products/42 from your CMS webhook or backend service. This invalidates the cache and regenerates the page on the next request.

Rendering in the App Router

Next.js 13+ introduced the App Router, which changes how rendering works. By default, all components in the app directory are server components. SSG, SSR, and ISR are controlled through route segment configuration and the fetch API's caching options.

Static Rendering by Default

// app/blog/page.js
export default async function BlogPage() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

  return (
    <main>
      <h1>Blog</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </main>
  );
}

This page is statically rendered at build time because fetch caches its result by default.

SSR with Dynamic Rendering

To force dynamic rendering on every request, use the no-store cache option or export a dynamic route segment config.

// app/dashboard/page.js
import { cookies } from 'next/headers';

export const dynamic = 'force-dynamic';

export default async function Dashboard() {
  const cookieStore = cookies();
  const token = cookieStore.get('token')?.value;

  const res = await fetch('https://api.example.com/me', {
    headers: { Authorization: `Bearer ${token}` },
    cache: 'no-store',
  });

  const user = await res.json();

  return (
    <main>
      <h1>Welcome, {user.name}</h1>
    </main>
  );
}

ISR in the App Router

Use the revalidate option on fetch or export a revalidate value from the route segment.

// app/products/[id]/page.js

export const revalidate = 60;

export default async function ProductPage({ params }) {
  const res = await fetch(`https://api.example.com/products/${params.id}`, {
    next: { revalidate: 60 },
  });
  const product = await res.json();

  return (
    <div>
      <h1>{product.name}</h1>
      <p>Price: ${product.price}</p>
    </div>
  );
}

Best Practices

Conclusion

Next.js gives you a powerful toolkit for rendering React on the server. SSG delivers the best performance for stable content, SSR ensures freshness for personalized or rapidly changing data, and ISR bridges the gap by regenerating static pages in the background. By understanding the trade-offs of each strategy and applying them per route, you can build applications that are fast, SEO-friendly, and always up to date. Start with static generation, introduce ISR when content changes periodically, and reserve SSR for the cases where every request truly needs fresh data. With these patterns in place, your Next.js application will scale gracefully while keeping both users and search engines happy.

— Ad —

Google AdSense will appear here after approval

← Back to all articles