Web Engineering ·

Server-Side Rendering vs Static Generation vs ISR: A Rendering Strategy Decision Framework

Every route in your app has different data freshness, personalization, and scale requirements. This is a practical decision framework for choosing between SSR, SSG, and ISR, covering internals, tradeoffs, and production considerations.

Server-Side Rendering vs Static Generation vs ISR: A Rendering Strategy Decision Framework

One rendering strategy rarely fits an entire application. A marketing homepage, a user dashboard, a product listing page, and a checkout flow each have different requirements around data freshness, personalization, and load distribution. Picking one approach for all routes and calling it a day is how you end up with either a slow, over-engineered SSR setup for content that never changes or a stale, confusing SSG setup for pages that depend on user state. The right question is not “which rendering mode should we use?” but “which rendering mode fits each category of route?” This article covers SSR, SSG, and ISR from first principles, shows how they work in Next.js and Astro, and provides a decision framework you can apply route by route.

Server-Side Rendering: Rendering on Request

SSR means the HTML for a page is generated at request time, on the server, for every incoming request. The browser receives a fully-formed HTML document, which it can display immediately, and then React hydrates it so the page becomes interactive.

Request
  │
  ▼
Server (renders HTML per-request)
  │
  ▼
Client (receives HTML, hydrates)

How it works in Next.js (App Router)

In the Next.js App Router, every Server Component that reads request-time data becomes an SSR route. The key is that dynamic data access (reading cookies, headers, or fetching without a cache policy) opts the route into dynamic rendering.

// app/dashboard/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

type User = { id: string; name: string; plan: "free" | "pro" };
type Activity = { id: string; event: string; timestamp: string };

async function getUser(token: string): Promise<User | null> {
  const res = await fetch(`${process.env.API_URL}/me`, {
    headers: { Authorization: `Bearer ${token}` },
    cache: "no-store", // opt out of Next.js fetch cache
  });
  if (!res.ok) return null;
  return res.json();
}

async function getActivity(userId: string): Promise<Activity[]> {
  const res = await fetch(`${process.env.API_URL}/activity/${userId}`, {
    cache: "no-store",
  });
  return res.json();
}

export default async function DashboardPage() {
  const cookieStore = cookies();
  const token = cookieStore.get("session")?.value;
  if (!token) redirect("/login");

  const user = await getUser(token);
  if (!user) redirect("/login");

  const activity = await getActivity(user.id);

  return (
    <main>
      <h1>Welcome back, {user.name}</h1>
      <ul>
        {activity.map((item) => (
          <li key={item.id}>
            {item.event} at {item.timestamp}
          </li>
        ))}
      </ul>
    </main>
  );
}

Streaming with React Server Components

One underused capability in SSR is streaming. Instead of waiting for all data before sending any HTML, you can stream shell HTML immediately and flush content as data resolves. This improves Time to First Byte (TTFB) and perceived load time for pages with multiple independent data sources.

// app/dashboard/page.tsx with streaming
import { Suspense } from "react";
import { ActivityFeed } from "./ActivityFeed";
import { UserStats } from "./UserStats";

export default async function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      {/* Shell renders immediately */}
      <Suspense fallback={<p>Loading stats...</p>}>
        {/* These fetch independently and stream in as they resolve */}
        <UserStats />
      </Suspense>
      <Suspense fallback={<p>Loading activity...</p>}>
        <ActivityFeed />
      </Suspense>
    </main>
  );
}

Each Suspense boundary lets Next.js flush that section of HTML as soon as its data resolves, without blocking the rest of the page.

Where SSR breaks down

Every request hits your server or serverless function. At high traffic you are paying for compute on every page view, and cold starts on serverless add latency spikes under bursts. You cannot cache SSR responses on a CDN without surrendering the personalization that motivated SSR. If a page is not personalized and does not need real-time data, SSR is the expensive option for no gain.


Static Site Generation: Build Once, Serve Forever

SSG generates HTML at build time. The output is a set of static files that you deploy to a CDN. Every user gets the same pre-rendered HTML served from the edge, which means near-zero TTFB, no cold starts, and effectively unlimited scale.

Build time
  │
  ├─► Page A HTML
  ├─► Page B HTML
  └─► Page C HTML
          │
          ▼
       CDN (all edge nodes)
          │
          ▼
       User (cache hit, instant response)

SSG in Next.js

In the App Router, a route is statically generated when it has no dynamic data dependencies. You can also explicitly control this.

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";

type Post = {
  slug: string;
  title: string;
  content: string;
  publishedAt: string;
};

// Tell Next.js to pre-render all slugs at build time
export async function generateStaticParams(): Promise<{ slug: string }[]> {
  const res = await fetch(`${process.env.API_URL}/posts?fields=slug`);
  const posts: Post[] = await res.json();
  return posts.map((post) => ({ slug: post.slug }));
}

async function getPost(slug: string): Promise<Post | null> {
  const res = await fetch(`${process.env.API_URL}/posts/${slug}`, {
    next: { revalidate: false }, // static, no revalidation
  });
  if (res.status === 404) return null;
  return res.json();
}

export default async function BlogPostPage({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPost(params.slug);
  if (!post) notFound();

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

SSG in Astro

Astro is particularly well-suited for SSG because its default output mode is static. You pay zero JavaScript hydration cost unless you opt specific components into interactivity.

// src/pages/blog/[slug].astro
---
import type { GetStaticPaths } from "astro";

export interface Post {
  slug: string;
  title: string;
  content: string;
  publishedAt: string;
}

export const getStaticPaths: GetStaticPaths = async () => {
  const res = await fetch(`${import.meta.env.API_URL}/posts`);
  const posts: Post[] = await res.json();

  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }));
};

const { post } = Astro.props as { post: Post };
---

<article>
  <h1>{post.title}</h1>
  <time>{post.publishedAt}</time>
  <div set:html={post.content} />
</article>

Astro sends zero JavaScript for this page. If you need a like button or a comment form, you add a client-side interactive island using client:load or client:visible, and only that component hydrates. The rest stays as plain HTML.

Where SSG breaks down

Build time scales with page count. 50,000 blog posts means rebuilding 50,000 pages on every content change. Between deploys, pages go stale. A post edited at 9am shows outdated content until the next deploy runs. That gap is acceptable for long-form editorial content but not for pricing pages, inventory counts, or anything that changes faster than your CI/CD cycle.


Incremental Static Regeneration: The Middle Ground

ISR is SSG with a revalidation mechanism. Pages are still pre-built and served from a CDN, but they have a time-to-live. When a request comes in after the TTL expires, the CDN serves the stale page immediately (no latency for the user) and triggers a background regeneration. The next request gets the fresh version.

First request (stale)
  │
  ▼
CDN: serve stale HTML + trigger background revalidation
  │
  ▼
Server: re-fetches data, builds new HTML, stores in CDN
  │
  ▼
Next request: fresh HTML from CDN

This is the stale-while-revalidate pattern applied at the page level. The user always gets a fast response. The data is eventually consistent, not real-time.

ISR in Next.js

// app/products/[id]/page.tsx
type Product = {
  id: string;
  name: string;
  price: number;
  stock: number;
  updatedAt: string;
};

export async function generateStaticParams(): Promise<{ id: string }[]> {
  const res = await fetch(`${process.env.API_URL}/products?fields=id`);
  const products: Product[] = await res.json();
  return products.map((p) => ({ id: p.id }));
}

async function getProduct(id: string): Promise<Product | null> {
  const res = await fetch(`${process.env.API_URL}/products/${id}`, {
    next: { revalidate: 60 }, // revalidate every 60 seconds
  });
  if (!res.ok) return null;
  return res.json();
}

export default async function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  const product = await getProduct(params.id);
  if (!product) notFound();

  return (
    <main>
      <h1>{product.name}</h1>
      <p>${product.price}</p>
      <p>{product.stock > 0 ? "In stock" : "Out of stock"}</p>
    </main>
  );
}

On-demand revalidation

Time-based ISR is eventually consistent within your TTL window. For cases where you need to push freshness on a specific event (a CMS publish, an inventory update), Next.js supports on-demand revalidation via a route handler.

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const secret = req.nextUrl.searchParams.get("secret");

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

  const body = await req.json();

  if (body.type === "product" && body.id) {
    // Revalidate a specific product page
    revalidatePath(`/products/${body.id}`);
    return NextResponse.json({ revalidated: true, path: `/products/${body.id}` });
  }

  if (body.tag) {
    // Revalidate all pages tagged with a given cache tag
    revalidateTag(body.tag);
    return NextResponse.json({ revalidated: true, tag: body.tag });
  }

  return NextResponse.json({ error: "Missing type or tag" }, { status: 400 });
}

Your CMS or backend service calls this endpoint on content publish. The relevant pages get regenerated within the next request cycle, not on a fixed TTL schedule.

ISR in Astro (Server Islands and hybrid rendering)

Astro 4+ introduced server islands, which let you mix static rendering with on-demand server-rendered components. The page shell is static (instant from CDN), and personalized or dynamic content loads as a server island.

// src/pages/products/[id].astro
---
import type { GetStaticPaths } from "astro";
import StockStatus from "../../components/StockStatus.astro";

export const getStaticPaths: GetStaticPaths = async () => {
  const res = await fetch(`${import.meta.env.API_URL}/products`);
  const products = await res.json();
  return products.map((p: { id: string }) => ({ params: { id: p.id } }));
};

const { id } = Astro.params;
const res = await fetch(`${import.meta.env.API_URL}/products/${id}`);
const product = await res.json();
---

<main>
  <h1>{product.name}</h1>
  <p>${product.price}</p>
  <!-- This component renders server-side on each request, not at build time -->
  <StockStatus productId={id} server:defer />
</main>

The server:defer directive tells Astro to stream this component from the server on request while the rest of the page is served statically from the CDN. It is the ISR hybrid pattern at the component level rather than the page level.


Tradeoffs at a Glance

DimensionSSRSSGISR
Data freshnessReal-timeStale until redeployStale within TTL window
TTFBMedium (compute required)Very low (CDN hit)Very low (CDN hit, stale)
PersonalizationFullNone (same page for all)None at page level, possible via islands
Server loadHigh (per-request compute)None (after deploy)Very low (background only)
Build time costNoneScales with page countScales with pre-rendered set
CDN cacheabilityOnly with shared contentFullFull for TTL window
Cold start riskYes (serverless)NoneMinimal (background revalidation)
Infrastructure costScales with trafficFlatNear-flat

Hybrid Approaches: Mixing Strategies Per Route

Real applications mix all three. Next.js makes this explicit: each route in app/ is independently configured. You do not choose one rendering mode for the whole application.

app/
  page.tsx              (SSG: static marketing homepage)
  blog/
    page.tsx            (ISR: blog index, revalidate: 300)
    [slug]/
      page.tsx          (ISR: individual posts, revalidate: 3600)
  products/
    page.tsx            (ISR: product listing, revalidate: 60)
    [id]/
      page.tsx          (ISR + on-demand: product detail)
  dashboard/
    page.tsx            (SSR: authenticated, personalized)
  checkout/
    page.tsx            (SSR: real-time inventory and pricing)
  api/
    revalidate/
      route.ts          (API route: on-demand revalidation webhook)

This is the architecture most production Next.js apps converge on after a few months. SSG for pure content, ISR for content that changes on a known schedule, SSR for anything personalized or requiring real-time state.


Decision Framework

Start here: what does this route show, and to whom?

Is the content the same for every user and changes only at deploy time? Use SSG. Marketing pages, documentation, long-form blog content, terms of service. Build it once, cache it forever, pay nothing at runtime.

Is the content the same for every user but changes more frequently than your deploy cycle? Use ISR with a TTL that matches your acceptable staleness window. A product catalog that updates daily can use revalidate: 3600. A news feed that updates every few minutes needs a shorter TTL or on-demand revalidation triggered by your CMS publish webhook.

Is the content personalized, requires real-time state, or depends on request-time context (cookies, headers, auth tokens)? Use SSR. Authenticated dashboards, shopping carts, user settings pages, checkout flows. Do not try to work around SSR here with client-side fetching on top of SSG; you will just end up with a blank SSG shell that does a waterfall fetch after hydration, which is worse than SSR on every metric that matters.

Do you need the static shell of a page to be fast but have a small dynamic component? Use ISR at the page level plus a server island (Astro) or a client-side fetch for the dynamic part (Next.js). A product page where 95% is static but the “in stock” indicator changes frequently is a good candidate. Serve the static shell from the CDN, load the dynamic bit separately.

Are you building a content-heavy site (documentation, marketing) where JavaScript weight matters? Reach for Astro instead of Next.js. Astro’s zero-JS-by-default approach gives you smaller bundles and simpler mental models for static content. Use Next.js when you need tight React integration, complex routing, or the full React ecosystem.


Production Considerations

Cold starts and serverless

SSR on serverless (Vercel Functions, AWS Lambda) adds 500ms to 1.5s to TTFB on cold invocations. Mitigations: long-lived containers (App Router with Node.js runtime), edge functions (lower cold start, constrained runtime), or moving content that only appears personalized into a static shell plus client fetch.

Cache invalidation

The edge case to plan for with ISR is stale content after an urgent update: a pricing error, a recalled product. Time-based TTLs alone are not enough. Implement on-demand revalidation from your CMS on day one. Tag cache entries by content type so you can purge by tag, not just by URL.

// Tagging fetches for granular revalidation
async function getProduct(id: string) {
  const res = await fetch(`${process.env.API_URL}/products/${id}`, {
    next: {
      revalidate: 300,
      tags: ["products", `product-${id}`],
    },
  });
  return res.json();
}

When a product is updated, call revalidateTag(product-${id}). When your entire product catalog changes (a global price update), call revalidateTag("products").

Revalidation stampede

When a TTL expires, concurrent requests can each trigger background regeneration simultaneously. Next.js deduplicates this internally, but if you are rolling your own ISR layer (Cloudflare Workers KV, custom cache), implement a lock to prevent N parallel regenerations of the same page.

Cost model

SSG is free at runtime. ISR adds marginal background compute. SSR scales linearly with request volume. At millions of page views, the cost gap between SSR and ISR for cacheable content becomes meaningful. Profile routes before defaulting to SSR.

Build time for large SSG sets

If generateStaticParams returns 100,000 paths, your build is going to take a long time and consume significant memory. Next.js 14+ supports dynamicParams = true (the default), which means routes not in generateStaticParams are generated on first request and then cached like ISR. Use this to build only your most-trafficked pages at build time and let the long tail be generated on demand.

// app/blog/[slug]/page.tsx
// Allow slugs not in generateStaticParams to be rendered on demand
export const dynamicParams = true;

export async function generateStaticParams() {
  // Only pre-build the 100 most recent posts
  const res = await fetch(`${process.env.API_URL}/posts?limit=100&sort=recent`);
  const posts = await res.json();
  return posts.map((p: { slug: string }) => ({ slug: p.slug }));
}

The rendering strategy conversation is really a data freshness and caching conversation wearing a framework hat. SSG is a CDN cache with build-time population. ISR is a CDN cache with background revalidation. SSR is no cache at all (or a very short-lived shared one). Once you frame it that way, the tradeoffs become straightforward: cache when you can, revalidate when you must, and render on request only when you have no other option.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.