← All writing
Frontend

Rendering Strategies in Next.js, Explained With Real Projects

SSR, SSG, ISR, CSR — the acronyms hide a simple question: when should the HTML for a page be produced? A practical tour using an e-commerce store and a blog as running examples.

Every Next.js tutorial lists the four rendering modes. Almost none of them tell you which one to pick, because the honest answer depends on a question tutorials can't see: how fresh does this page's data need to be, and who is asking for it?

This post walks through all four modes — SSG, SSR, ISR, and CSR — with working code for both the Pages Router and the App Router, using two real projects as running examples: an e-commerce store (Zeestudio.pk) and the blog you're reading right now.

The core question: when is the HTML produced?

Strip away the acronyms and every rendering strategy is just an answer to one question — at what moment does the server (or browser) turn your data into HTML?

  • At build time, once, before anyone visits → SSG
  • At build time, but refreshed on a schedule → ISR
  • On every single request → SSR
  • In the visitor's browser, after the page loads → CSR

Each answer trades freshness against speed and server cost. Let's look at each one properly.

1. Static Site Generation (SSG)

SSG renders HTML once, at next build. The output is a plain file that can sit on a CDN edge node a few milliseconds from your visitor. Nothing you serve will ever be faster.

Pages Router

// pages/about.js
export async function getStaticProps() {
  const team = await fetchTeamMembers(); // runs ONCE, at build time

  return {
    props: { team },
  };
}

export default function AboutPage({ team }) {
  return (
    <main>
      <h1>About Us</h1>
      {team.map((member) => (
        <p key={member.id}>{member.name} — {member.role}</p>
      ))}
    </main>
  );
}

For dynamic routes — say, documentation pages — you pair it with getStaticPaths so Next.js knows which pages to pre-build:

// pages/docs/[slug].js
export async function getStaticPaths() {
  const docs = await getAllDocs();

  return {
    paths: docs.map((doc) => ({ params: { slug: doc.slug } })),
    fallback: false, // any unknown slug → 404
  };
}

export async function getStaticProps({ params }) {
  const doc = await getDocBySlug(params.slug);
  return { props: { doc } };
}

App Router

In the App Router, static is the default. A Server Component that fetches data with no caching directives is rendered at build time:

// app/docs/[slug]/page.js
export async function generateStaticParams() {
  const docs = await getAllDocs();
  return docs.map((doc) => ({ slug: doc.slug }));
}

export default async function DocPage({ params }) {
  const { slug } = await params;
  const doc = await getDocBySlug(slug);

  return (
    <article>
      <h1>{doc.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: doc.html }} />
    </article>
  );
}

Use it for: marketing pages, docs, an about page, legal pages — anything that's the same for everyone and changes only when a developer ships a change anyway.

The catch: content updates require a rebuild and redeploy. If your client edits copy through a CMS twice a day, pure SSG becomes a bottleneck. That's exactly the gap ISR fills.

2. Server-Side Rendering (SSR)

SSR runs your data fetching on every request, then renders fresh HTML before responding. You pay a server round-trip on each hit; in exchange, the page is never stale and can read the request itself — cookies, headers, sessions, query strings.

Pages Router

// pages/blog/[slug].js
export async function getServerSideProps({ params, req }) {
  const post = await db.posts.findOne({
    slug: params.slug,
    published: true,
  });

  if (!post) {
    return { notFound: true }; // proper 404, good for SEO
  }

  return {
    props: {
      post: JSON.parse(JSON.stringify(post)), // serialize MongoDB doc
    },
  };
}

export default function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.readMinutes} min read</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

App Router

Opt into dynamic rendering with cache: 'no-store', or automatically by reading request data like cookies():

// app/blog/[slug]/page.js
export const dynamic = 'force-dynamic'; // render on every request

export default async function BlogPost({ params }) {
  const { slug } = await params;
  const res = await fetch(`${process.env.API_URL}/posts/${slug}`, {
    cache: 'no-store',
  });
  const post = await res.json();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

This is exactly how the post pages on this blog work: the moment a new essay flips to published: true in the database, the very next request sees it. No rebuild, no cache window, no waiting.

Use it for: content that must appear instantly after publishing, pages personalized by cookies or geolocation that still need SEO, search results pages.

The catch: every visitor waits for your database. If a post gets 10,000 hits an hour, that's 10,000 identical queries for identical HTML. Under real traffic, that's the signal to move to ISR.

3. Incremental Static Regeneration (ISR)

ISR is SSG with a shelf life. Pages are served as static files, but after a revalidation window expires, the next request triggers a background rebuild. The visitor who triggers it still gets the old (fast) page; everyone after gets the fresh one. You get CDN speed and content that updates itself.

Pages Router

// pages/products/[slug].js
export async function getStaticPaths() {
  // Pre-build only the bestsellers; build the long tail on demand
  const topProducts = await getTopProducts(100);

  return {
    paths: topProducts.map((p) => ({ params: { slug: p.slug } })),
    fallback: 'blocking', // unknown slugs are SSR'd once, then cached
  };
}

export async function getStaticProps({ params }) {
  const product = await getProduct(params.slug);

  if (!product) return { notFound: true };

  return {
    props: { product },
    revalidate: 60, // rebuild in the background, at most once per minute
  };
}

export default function ProductPage({ product }) {
  return (
    <main>
      <h1>{product.name}</h1>
      <p>Rs. {product.price.toLocaleString()}</p>
      <AddToCartButton productId={product.id} />
    </main>
  );
}

App Router

// app/products/[slug]/page.js
export const revalidate = 60; // seconds

export default async function ProductPage({ params }) {
  const { slug } = await params;
  const res = await fetch(`${process.env.API_URL}/products/${slug}`, {
    next: { revalidate: 60 },
  });
  const product = await res.json();

  return <ProductDetails product={product} />;
}

On-demand revalidation: the admin panel trick

A 60-second window is fine for most edits, but sometimes you want a price change live immediately. Instead of shrinking the window (and losing the caching benefit), revalidate exactly the page that changed, from the admin panel's save handler:

// app/api/revalidate/route.js
import { revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';

export async function POST(request) {
  const { secret, path } = await request.json();

  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
  }

  revalidatePath(path); // e.g. '/products/black-oversized-tee'
  return NextResponse.json({ revalidated: true });
}

Then in the admin's product-update logic:

await updateProduct(id, data);

await fetch(`${process.env.SITE_URL}/api/revalidate`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    secret: process.env.REVALIDATE_SECRET,
    path: `/products/${data.slug}`,
  }),
});

This is the setup on Zeestudio.pk: product pages are static and indexable, served from cache in tens of milliseconds — but a price edit in the admin goes live in the next request, with zero redeploys.

Use it for: product pages, category listings, blog posts on high-traffic sites, anything "same for everyone, edited occasionally."

The catch: the first visitor after expiry sees stale content, and reasoning about cache states adds complexity. If true real-time freshness is a hard requirement, use SSR.

4. Client-Side Rendering (CSR)

CSR ships a minimal HTML shell and fetches data from the browser after hydration. Search engines see nothing useful — which is exactly why it's the right tool for pages search engines should never see: dashboards, carts, admin panels.

The cart, with SWR

// components/Cart.js
'use client';

import useSWR from 'swr';

const fetcher = (url) => fetch(url).then((r) => r.json());

export default function Cart() {
  const { data, error, isLoading, mutate } = useSWR('/api/cart', fetcher);

  if (isLoading) return <CartSkeleton />;
  if (error) return <p>Couldn't load your cart. Retry?</p>;

  async function removeItem(itemId) {
    // Optimistic update: UI changes instantly, server catches up
    mutate(
      { ...data, items: data.items.filter((i) => i.id !== itemId) },
      { revalidate: false }
    );
    await fetch(`/api/cart/items/${itemId}`, { method: 'DELETE' });
    mutate(); // re-sync with server
  }

  return (
    <ul>
      {data.items.map((item) => (
        <li key={item.id}>
          {item.name} × {item.quantity}
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </li>
      ))}
    </ul>
  );
}

The admin panel, behind an auth check

// app/admin/page.js
'use client';

import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

export default function AdminDashboard() {
  const router = useRouter();
  const [posts, setPosts] = useState(null);

  useEffect(() => {
    fetch('/api/admin/posts')
      .then((res) => {
        if (res.status === 401) {
          router.push('/login');
          return null;
        }
        return res.json();
      })
      .then((data) => data && setPosts(data));
  }, [router]);

  if (!posts) return <Spinner />;

  return <PostsTable posts={posts} />;
}

Use it for: per-user, per-click state — carts, dashboards, admin panels, anything behind a login.

The catch: no SEO, and a flash of loading state on first paint. If a page needs to rank on Google, CSR is disqualified before the conversation starts.

Putting it together: one app, four modes

Here's how the two projects actually slice up, page by page:

PageModeWhy
Store: homepage & landing pagesSSGSame for everyone, changes with deploys
Store: product & category pagesISRMust be fast and indexable; prices change without redeploys
Store: cart & checkoutCSROne user's data, changes every click, no SEO needed
Blog: post pagesSSRNew essays must appear the instant they're published
Blog: admin panelCSRBehind auth, interactive, invisible to crawlers
Pick per page, not per project. The best Next.js apps mix all four modes without apologising.

A decision rule that survives contact with clients

  • Is the content the same for everyone and does it change rarely? SSG.
  • Same for everyone but updated by an admin panel? ISR (with on-demand revalidation) or SSR if freshness is truly non-negotiable.
  • Different per user, needs SEO? SSR.
  • Different per user, behind a login? CSR.

And two follow-up questions worth asking before committing:

  • How much traffic? SSR on a page with heavy traffic and identical output for everyone is wasted compute — that's an ISR page wearing an SSR costume.
  • Who edits the content, and how often? If the answer is "a client, daily, through a CMS," anything that requires a redeploy will eventually make someone angry at 11pm.

The framework will let you do anything. The data's freshness requirement — not the framework — should make the call.

"
#nextjs#react#ssr#seo
AA
Written byAmanat Ali

A full-stack developer who builds websites end-to-end — Next.js, Node.js, and MongoDB — and writes buildlog in the margins.