The Complete Guide to Web Rendering Strategies: SSG, SSR, ISR, and Beyond
Modern web development offers a bewildering array of rendering strategies, each with its own acronym and use case. From the traditional Server-Side Rendering (SSR) to cutting-edge approaches like Incremental Static Regeneration (ISR), understanding these patterns is crucial for building performant, scalable web applications.
This guide breaks down each rendering strategy, explains when to use them, and provides practical examples to help you make informed architectural decisions.
The Rendering Spectrum
Before diving into specifics, it’s helpful to understand that rendering strategies exist on a spectrum from fully static to fully dynamic:
Static ←――――――――――――――――――――――――――――――――――――――――――――――――――――――→ Dynamic
CSG → SSG → ISR → SSR → Streaming SSR → CSR → Islands → Partial Hydration
Each approach makes different trade-offs between performance, complexity, and dynamic capability.
Client-Side Rendering (CSR)
What it is: The browser downloads a minimal HTML shell and JavaScript bundle, then renders the entire application on the client.
How CSR Works
// Initial HTML (minimal)<!DOCTYPE html><html><head> <title>My App</title></head><body> <div id="root"></div> <script src="bundle.js"></script></body></html>
// React CSR exampleimport React, { useState, useEffect } from 'react';import { createRoot } from 'react-dom/client';
function App() { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true);
useEffect(() => { fetch('/api/posts') .then(res => res.json()) .then(data => { setPosts(data); setLoading(false); }); }, []);
if (loading) return <div>Loading...</div>;
return ( <div> <h1>My Blog</h1> {posts.map(post => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.excerpt}</p> </article> ))} </div> );}
const root = createRoot(document.getElementById('root'));root.render(<App />);
When to Use CSR
✅ Perfect for:
- Web applications with heavy interactivity
- Admin dashboards and internal tools
- Applications behind authentication
- Real-time applications (chat, collaboration tools)
❌ Avoid for:
- SEO-critical content
- Content-heavy websites
- Users on slow connections
- Applications requiring fast initial load
CSR Trade-offs
// Prosconst csrBenefits = { richInteractivity: true, fastNavigation: true, // After initial load serverLoad: "minimal", offlineCapability: "possible with PWA",};
// Consconst csrDrawbacks = { seoFriendly: false, initialLoadTime: "slow", performanceOnSlowDevices: "poor", blankPageFlash: true,};
Server-Side Rendering (SSR)
What it is: The server generates the complete HTML for each request, sending fully rendered pages to the client.
How SSR Works
// Next.js SSR exampleexport default function BlogPost({ post, comments }) { return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.content }} />
<section> <h3>Comments ({comments.length})</h3> {comments.map((comment) => ( <div key={comment.id}> <strong>{comment.author}</strong> <p>{comment.text}</p> </div> ))} </section> </article> );}
// This runs on every requestexport async function getServerSideProps(context) { const { slug } = context.params;
// Fresh data on every request const [post, comments] = await Promise.all([ fetch(`${API_URL}/posts/${slug}`).then((r) => r.json()), fetch(`${API_URL}/posts/${slug}/comments`).then((r) => r.json()), ]);
return { props: { post, comments }, };}
SSR with Express.js
import express from "express";import React from "react";import { renderToString } from "react-dom/server";import App from "./App.js";
const server = express();
server.get("*", async (req, res) => { try { // Fetch data needed for this route const initialData = await fetchDataForRoute(req.path);
// Render React component to string const appHtml = renderToString( <App initialData={initialData} url={req.url} /> );
// Send complete HTML res.send(` <!DOCTYPE html> <html> <head> <title>My SSR App</title> <script> window.__INITIAL_DATA__ = ${JSON.stringify(initialData)}; </script> </head> <body> <div id="root">${appHtml}</div> <script src="/client.js"></script> </body> </html> `); } catch (error) { res.status(500).send("Server Error"); }});
When to Use SSR
✅ Perfect for:
- SEO-critical pages with dynamic content
- Personalized content that changes frequently
- Real-time data display
- Applications requiring authentication-based content
❌ Avoid for:
- Highly interactive applications
- Static content that rarely changes
- High-traffic sites (server load concerns)
Static Site Generation (SSG)
What it is: HTML pages are pre-generated at build time and served as static files.
How SSG Works
// Next.js SSG exampleexport default function BlogPost({ post }) { return ( <article> <h1>{post.title}</h1> <p>Published: {post.date}</p> <div dangerouslySetInnerHTML={{ __html: post.content }} /> </article> );}
// Generate static paths at build timeexport async function getStaticPaths() { const posts = await fetch(`${API_URL}/posts`).then((r) => r.json());
const paths = posts.map((post) => ({ params: { slug: post.slug }, }));
return { paths, fallback: false, // 404 for paths not returned here };}
// Generate static props at build timeexport async function getStaticProps({ params }) { const post = await fetch(`${API_URL}/posts/${params.slug}`).then((r) => r.json() );
return { props: { post }, // Optional: regenerate page if older than 60 seconds revalidate: 60, };}
Gatsby SSG Example
exports.createPages = async ({ graphql, actions }) => { const { createPage } = actions;
const result = await graphql(` query { allMarkdownRemark { nodes { frontmatter { slug } } } } `);
result.data.allMarkdownRemark.nodes.forEach((node) => { createPage({ path: node.frontmatter.slug, component: path.resolve("./src/templates/blog-post.js"), context: { slug: node.frontmatter.slug, }, }); });};
// Blog post templateimport React from "react";import { graphql } from "gatsby";
export default function BlogPost({ data }) { const post = data.markdownRemark;
return ( <article> <h1>{post.frontmatter.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.html }} /> </article> );}
export const query = graphql` query ($slug: String!) { markdownRemark(frontmatter: { slug: { eq: $slug } }) { html frontmatter { title date } } }`;
When to Use SSG
✅ Perfect for:
- Blogs and documentation sites
- Marketing pages and landing pages
- E-commerce product catalogs (with ISR)
- Portfolio and company websites
❌ Avoid for:
- Highly personalized content
- Real-time data applications
- User-generated content that changes frequently
Incremental Static Regeneration (ISR)
What it is: Combines SSG benefits with the ability to update static pages after deployment without rebuilding the entire site.
How ISR Works
// Next.js ISR exampleexport default function ProductPage({ product, reviews }) { return ( <div> <h1>{product.name}</h1> <p>Price: ${product.price}</p> <p>Stock: {product.stock}</p>
<section> <h3>Recent Reviews</h3> {reviews.map((review) => ( <div key={review.id}> <strong>{review.rating}/5</strong> <p>{review.comment}</p> </div> ))} </section> </div> );}
export async function getStaticProps({ params }) { const [product, reviews] = await Promise.all([ fetch(`${API_URL}/products/${params.id}`).then((r) => r.json()), fetch(`${API_URL}/products/${params.id}/reviews`).then((r) => r.json()), ]);
return { props: { product, reviews }, // Regenerate at most once every 30 seconds revalidate: 30, };}
export async function getStaticPaths() { // Pre-generate popular products const popularProducts = await fetch(`${API_URL}/products/popular`).then((r) => r.json() );
return { paths: popularProducts.map((product) => ({ params: { id: product.id.toString() }, })), // Enable ISR for other products fallback: "blocking", };}
ISR Fallback Strategies
// Different fallback behaviorsexport async function getStaticPaths() { return { paths: ["/popular-page-1", "/popular-page-2"], fallback: false, // 404 for other paths // fallback: true // Show loading, then render // fallback: 'blocking' // SSR-like behavior for new paths };}
// Handling fallback in componentexport default function Page({ product }) { const router = useRouter();
// Show loading state for fallback: true if (router.isFallback) { return <div>Loading product...</div>; }
return <ProductDetails product={product} />;}
When to Use ISR
✅ Perfect for:
- E-commerce sites with frequent price/stock changes
- News sites with regular content updates
- Large sites where full rebuilds are expensive
- Content that’s mostly static but occasionally updates
❌ Avoid for:
- Real-time applications
- Highly personalized content
- Simple static sites (plain SSG is simpler)
Streaming SSR
What it is: Server starts sending HTML immediately and streams additional content as it becomes available.
How Streaming SSR Works
// React 18 Streaming with Suspenseimport { renderToPipeableStream } from "react-dom/server";
function App() { return ( <html> <body> <Navigation /> <main> <h1>Welcome to My Site</h1>
{/* This renders immediately */} <section> <h2>Latest News</h2> <p>Static content renders first...</p> </section>
{/* This streams in when data is ready */} <Suspense fallback={<div>Loading comments...</div>}> <Comments /> </Suspense>
<Suspense fallback={<div>Loading recommendations...</div>}> <Recommendations /> </Suspense> </main> </body> </html> );}
// Server implementationapp.get("/", (req, res) => { const { pipe, abort } = renderToPipeableStream(<App />, { onShellReady() { // Start streaming immediately res.setHeader("Content-Type", "text/html"); pipe(res); }, onError(error) { console.error(error); res.status(500).send("Server Error"); }, });
// Cleanup on client disconnect req.on("close", abort);});
// Async component that streams in laterasync function Comments() { const comments = await fetchComments(); // This doesn't block initial render
return ( <div> {comments.map((comment) => ( <div key={comment.id}>{comment.text}</div> ))} </div> );}
When to Use Streaming SSR
✅ Perfect for:
- Pages with mix of fast and slow data
- Improving perceived performance
- Complex pages with multiple data sources
- Applications where Time to First Byte matters
Islands Architecture
What it is: Mostly static pages with isolated interactive components that hydrate independently.
How Islands Work
// Astro Islands example---// This runs on the server onlyconst posts = await fetch('https://api.example.com/posts').then(r => r.json());---
<html> <head> <title>My Blog</title> </head> <body> <!-- Static content --> <header> <h1>My Blog</h1> <nav> <a href="/">Home</a> <a href="/about">About</a> </nav> </header>
<!-- Static list --> <main> {posts.map(post => ( <article> <h2>{post.title}</h2> <p>{post.excerpt}</p>
<!-- Interactive island --> <LikeButton client:load postId={post.id} initialLikes={post.likes} />
<!-- Another interactive island --> <CommentForm client:visible postId={post.id} /> </article> ))} </main>
<!-- Interactive search island --> <SearchWidget client:idle /> </body></html>
Island Hydration Strategies
// Different hydration triggers<Component client:load /> // Hydrate immediately<Component client:idle /> // Hydrate when browser is idle<Component client:visible /> // Hydrate when component is visible<Component client:media="(max-width: 768px)" /> // Conditional hydration
// Fresh (Deno) Islands// islands/Counter.tsximport { useState } from "preact/hooks";
export default function Counter({ start }: { start: number }) { const [count, setCount] = useState(start);
return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>+</button> <button onClick={() => setCount(count - 1)}>-</button> </div> );}
// routes/index.tsx (mostly static)import Counter from "../islands/Counter.tsx";
export default function Home() { return ( <div> <h1>Welcome to Fresh</h1> <p>This is a static paragraph.</p>
{/* Only this component is interactive */} <Counter start={3} />
<p>This is also static.</p> </div> );}
When to Use Islands
✅ Perfect for:
- Content sites with occasional interactivity
- Documentation with interactive examples
- Marketing sites with forms/widgets
- Performance-critical applications
Comparing All Strategies
Performance Characteristics
const performanceComparison = { CSR: { timeToFirstByte: "fast", firstContentfulPaint: "slow", timeToInteractive: "slow", subsequentNavigation: "fast", seoFriendly: false, },
SSR: { timeToFirstByte: "slow", firstContentfulPaint: "fast", timeToInteractive: "medium", subsequentNavigation: "medium", seoFriendly: true, },
SSG: { timeToFirstByte: "fastest", firstContentfulPaint: "fastest", timeToInteractive: "fast", subsequentNavigation: "fast", seoFriendly: true, },
ISR: { timeToFirstByte: "fast", firstContentfulPaint: "fast", timeToInteractive: "fast", subsequentNavigation: "fast", seoFriendly: true, },
StreamingSSR: { timeToFirstByte: "fastest", firstContentfulPaint: "fastest", timeToInteractive: "progressive", subsequentNavigation: "medium", seoFriendly: true, },
Islands: { timeToFirstByte: "fast", firstContentfulPaint: "fast", timeToInteractive: "selective", subsequentNavigation: "fast", seoFriendly: true, },};
Use Case Matrix
const useCaseMatrix = { "Blog/Content Site": { best: ["SSG", "Islands"], good: ["ISR"], avoid: ["CSR", "SSR"], },
"E-commerce": { best: ["ISR", "SSR"], good: ["SSG + CSR hybrid"], avoid: ["Pure SSG", "Pure CSR"], },
"Social Media App": { best: ["SSR", "Streaming SSR"], good: ["CSR"], avoid: ["SSG", "ISR"], },
"Admin Dashboard": { best: ["CSR"], good: ["SSR"], avoid: ["SSG", "ISR"], },
Documentation: { best: ["SSG", "Islands"], good: ["ISR"], avoid: ["CSR", "SSR"], },
"News Site": { best: ["ISR", "Streaming SSR"], good: ["SSR"], avoid: ["CSR"], },};
Hybrid Approaches
Modern applications often combine multiple strategies:
Next.js Hybrid Example
// pages/index.js - SSG for marketing pageexport async function getStaticProps() { return { props: { marketingData: await fetchMarketingContent() }, revalidate: 3600, // 1 hour };}
// pages/dashboard.js - SSR for personalized contentexport async function getServerSideProps(context) { const user = await authenticateUser(context.req); const dashboardData = await fetchUserDashboard(user.id);
return { props: { user, dashboardData } };}
// pages/products/[id].js - ISR for product pagesexport async function getStaticProps({ params }) { const product = await fetchProduct(params.id);
return { props: { product }, revalidate: 300, // 5 minutes };}
// pages/app/[[...slug]].js - CSR for app pagesexport default function App() { return <ClientSideApp />;}
Astro Hybrid Architecture
---export const prerender = true; // SSG
const { slug } = Astro.params;const post = await getPost(slug);---
<Layout> <article> <h1>{post.title}</h1> <div set:html={post.content} />
<!-- Interactive island --> <LikeButton client:visible postId={post.id} /> </article></Layout>
// src/pages/api/comments.js// SSR API routeexport async function post({ request }) { const data = await request.json(); return new Response(JSON.stringify(await createComment(data)));}
Choosing the Right Strategy
Decision Framework
function chooseRenderingStrategy(requirements) { const { seoRequired, contentFrequency, interactivityLevel, personalization, trafficVolume, buildTime, serverCapacity, } = requirements;
if (!seoRequired && interactivityLevel === "high") { return "CSR"; }
if (contentFrequency === "static" && buildTime === "acceptable") { return interactivityLevel === "minimal" ? "SSG" : "Islands"; }
if (contentFrequency === "occasional" && buildTime === "concern") { return "ISR"; }
if (personalization === "high" || contentFrequency === "realtime") { return serverCapacity === "high" ? "StreamingSSR" : "SSR"; }
return "Hybrid"; // Combine strategies}
// Example usageconst blogRequirements = { seoRequired: true, contentFrequency: "occasional", interactivityLevel: "low", personalization: "none", trafficVolume: "medium", buildTime: "acceptable", serverCapacity: "low",};
console.log(chooseRenderingStrategy(blogRequirements)); // 'ISR'
Implementation Considerations
SEO and Core Web Vitals
// Optimizing for Core Web Vitals across strategiesconst coreWebVitalsOptimization = { LCP: { SSG: "Excellent - content loads immediately", SSR: "Good - requires server processing", CSR: "Poor - requires JS execution", Islands: "Excellent - static content loads fast", },
FID: { SSG: "Good - minimal JS to parse", SSR: "Good - server-rendered content", CSR: "Poor - large JS bundles", Islands: "Excellent - minimal interactive JS", },
CLS: { SSG: "Excellent - no layout shifts", SSR: "Good - complete HTML sent", CSR: "Poor - content renders progressively", StreamingSSR: "Good - progressive enhancement", },};
Caching Strategies
// Different caching approaches by rendering methodconst cachingStrategies = { SSG: { location: "CDN", duration: "Long (months/years)", invalidation: "Build-time", cost: "Lowest", },
ISR: { location: "CDN + Origin", duration: "Medium (minutes/hours)", invalidation: "Time-based + on-demand", cost: "Low", },
SSR: { location: "Origin + Reverse Proxy", duration: "Short (seconds/minutes)", invalidation: "Time-based", cost: "High", },
CSR: { location: "CDN (assets) + API Cache", duration: "Long (assets) + Short (data)", invalidation: "Mixed", cost: "Medium", },};
Future Trends
Emerging Patterns
// Server Components (React)// Server component (runs on server)async function BlogPosts() { const posts = await db.posts.findMany();
return ( <div> {posts.map((post) => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.excerpt}</p> {/* Client component for interactivity */} <LikeButton postId={post.id} /> </article> ))} </div> );}
// Resumable frameworks (Qwik)export const Counter = component$(() => { const count = useSignal(0);
return ( <div> <p>Count: {count.value}</p> <button onClick$={() => count.value++}>Increment</button> </div> );}); // No hydration needed - resumes from server state