DB
ALPHA
Photos

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 example
import 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:

❌ Avoid for:

CSR Trade-offs

// Pros
const csrBenefits = {
richInteractivity: true,
fastNavigation: true, // After initial load
serverLoad: "minimal",
offlineCapability: "possible with PWA",
};
// Cons
const 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 example
export 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 request
export 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:

❌ Avoid for:

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 example
export 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 time
export 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 time
export 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

gatsby-node.js
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 template
import 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:

❌ Avoid for:

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 example
export 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 behaviors
export 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 component
export 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:

❌ Avoid for:

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 Suspense
import { 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 implementation
app.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 later
async 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:

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 only
const 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.tsx
import { 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:

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 page
export async function getStaticProps() {
return {
props: { marketingData: await fetchMarketingContent() },
revalidate: 3600, // 1 hour
};
}
// pages/dashboard.js - SSR for personalized content
export 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 pages
export async function getStaticProps({ params }) {
const product = await fetchProduct(params.id);
return {
props: { product },
revalidate: 300, // 5 minutes
};
}
// pages/app/[[...slug]].js - CSR for app pages
export default function App() {
return <ClientSideApp />;
}

Astro Hybrid Architecture

src/pages/blog/[slug].astro
---
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 route
export 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 usage
const 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 strategies
const 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 method
const 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",
},
};

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