The Three Rendering Strategies
Every web application must answer one question: where and when does HTML get generated? The answer determines your app's load speed, SEO ranking, server costs, and user experience.
+------------------------------------------------------------------+
| Rendering Strategies |
+------------------------------------------------------------------+
| |
| CSR (Client-Side Rendering) |
| Browser downloads JS --> JS generates HTML --> User sees content |
| |
| SSR (Server-Side Rendering) |
| Server generates HTML --> Browser displays it --> JS hydrates |
| |
| SSG (Static Site Generation) |
| Build step generates HTML --> CDN serves it --> JS hydrates |
| |
+------------------------------------------------------------------+
Client-Side Rendering (CSR)
CSR is what a default React app (Create React App, Vite) does. The server sends a minimal HTML shell with a <script> tag. The browser downloads JavaScript, executes it, and React renders the entire page in the browser.
How CSR Works
1. Browser requests page
2. Server sends minimal HTML:
<html>
<body>
<div id="root"></div>
<script src="/bundle.js"></script>
</body>
</html>
3. Browser downloads bundle.js (could be 200KB+)
4. JavaScript executes, React renders components into #root
5. User finally sees content
Timeline:
Request |------>|
HTML | |-->| (tiny, nearly empty)
JS | | |------------------->| (large bundle download)
Parse/ | | | |--------->|
Execute | | | | |
Content | | | | | VISIBLE
visible | | | | |
0 100ms 800ms 1200ms
CSR Implementation
// main.jsx (Vite or CRA entry point)
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(<App />);
// App.jsx โ all rendering happens in the browser
import { useState, useEffect } from 'react';
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 <Spinner />;
return (
<div>
<h1>Blog</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
CSR Trade-offs
| Advantage | Disadvantage |
|---|---|
| Simple deployment (static files) | Poor SEO (crawlers see empty HTML) |
| Rich interactivity from the start | Slow initial load (large JS bundle) |
| No server required at runtime | Blank screen while JS loads |
| Easy to host on CDN | Bad Core Web Vitals (LCP, FCP) |
| Full client-side navigation speed | Requires loading states for all data |
Server-Side Rendering (SSR)
SSR generates the full HTML on the server for every request. The browser receives ready-to-display HTML, so content is visible immediately. Then JavaScript loads and "hydrates" the page to make it interactive.
How SSR Works
1. Browser requests /blog
2. Server runs React components, fetches data, generates HTML
3. Server sends complete HTML with content
4. Browser displays content immediately (FCP is fast)
5. Browser downloads JS bundle in background
6. React hydrates: attaches event listeners to existing DOM
7. Page is now fully interactive
Timeline:
Request |------>|
Server | |------------>| (server renders HTML)
HTML | | |------>| (complete HTML with content)
Content | | | | VISIBLE (fast FCP!)
visible | | | |
JS load | | |-------|----------->|
Hydrate | | | | |---->|
Inter- | | | | | | INTERACTIVE
active | | | | | |
0 100ms 400ms 500ms 900ms 1000ms
SSR with Next.js (getServerSideProps)
// pages/blog.jsx
export async function getServerSideProps() {
// This runs on the server for EVERY request
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return {
props: { posts }, // passed to the component
};
}
export default function BlogPage({ posts }) {
// This component renders on the server AND the client
return (
<div>
<h1>Blog</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<a href={`/blog/${post.slug}`}>Read more</a>
</article>
))}
</div>
);
}
SSR with Express (No Framework)
// server.js
import express from 'express';
import { renderToString } from 'react-dom/server';
import App from './App';
const app = express();
app.get('*', async (req, res) => {
// Fetch data on the server
const data = await fetchDataForRoute(req.url);
// Render React to HTML string
const html = renderToString(<App url={req.url} data={data} />);
res.send(`
<!DOCTYPE html>
<html>
<head><title>My App</title></head>
<body>
<div id="root">${html}</div>
<script>
window.__INITIAL_DATA__ = ${JSON.stringify(data)};
</script>
<script src="/bundle.js"></script>
</body>
</html>
`);
});
app.listen(3000);
// client.js (hydration)
import { hydrateRoot } from 'react-dom/client';
import App from './App';
const data = window.__INITIAL_DATA__;
hydrateRoot(
document.getElementById('root'),
<App url={window.location.pathname} data={data} />
);
SSR Trade-offs
| Advantage | Disadvantage |
|---|---|
| Fast FCP (content visible immediately) | Server required at runtime |
| Great SEO (crawlers see full HTML) | Higher server costs (compute per request) |
| Works with dynamic, user-specific data | TTFB can be slow if data fetching is slow |
| Social sharing previews work perfectly | More complex deployment (not just static files) |
| Accessible without JavaScript | Server under load = slow for everyone |
Static Site Generation (SSG)
SSG generates HTML at build time, not at request time. The HTML files are pre-built and served from a CDN. No server computation per request.
How SSG Works
BUILD TIME:
1. Build process runs React components for every page
2. Fetches data from APIs/databases
3. Generates .html files with full content
4. Deploys HTML + JS to CDN
REQUEST TIME:
1. Browser requests /blog
2. CDN serves pre-built blog.html (no server computation)
3. Browser displays content immediately
4. JS loads and hydrates
Timeline:
Request |------>|
CDN | |->| (pre-built HTML served from edge, near-instant)
Content | | | VISIBLE (fastest FCP possible)
visible | | |
JS load |-------|--|----------->|
Hydrate | | | |---->|
Inter- | | | | | INTERACTIVE
active | | | | |
0 50ms 800ms
SSG with Next.js (getStaticProps)
// pages/blog.jsx
export async function getStaticProps() {
// This runs ONCE at build time
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return {
props: { posts },
revalidate: 3600, // ISR: rebuild this page every hour
};
}
export default function BlogPage({ posts }) {
return (
<div>
<h1>Blog</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
SSG with Dynamic Routes
// pages/blog/[slug].jsx
export async function getStaticPaths() {
// Define which pages to pre-build
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: 'blocking', // new slugs: SSR on first request, then cache
};
}
export async function getStaticProps({ params }) {
const res = await fetch(`https://api.example.com/posts/${params.slug}`);
const post = await res.json();
if (!post) {
return { notFound: true };
}
return {
props: { post },
revalidate: 3600,
};
}
export default function BlogPost({ post }) {
return (
<article>
<h1>{post.title}</h1>
<time dateTime={post.publishedAt}>{post.publishedAt}</time>
<div>{post.content}</div>
</article>
);
}
Incremental Static Regeneration (ISR)
ISR is Next.js's hybrid approach: pages are statically generated at build time, then regenerated in the background after a configured interval.
First request after build:
CDN serves cached HTML --> fast
Background (after revalidate period):
Next.js regenerates the page with fresh data
New HTML replaces the cached version
Next request:
CDN serves the newly generated HTML
export async function getStaticProps() {
const data = await fetchData();
return {
props: { data },
revalidate: 60, // regenerate at most every 60 seconds
};
}
SSG Trade-offs
| Advantage | Disadvantage |
|---|---|
| Fastest possible FCP (CDN-served) | Cannot serve user-specific content |
| Cheapest hosting (no server compute) | Build time grows with page count |
| Best Core Web Vitals scores | Data can be stale between rebuilds |
| Survives traffic spikes (CDN scales) | Dynamic features need client-side fetch |
| Works perfectly for SEO | Not suitable for real-time data |
Hydration Explained
Hydration is the process where React attaches to server-rendered HTML and makes it interactive. The server sends HTML with content; the client's JavaScript then "hydrates" that HTML by attaching event listeners and re-creating React's internal state.
Server HTML:
<button class="btn">Click me (0)</button>
^
|
Static HTML โ visible but NOT interactive
Clicking does nothing
After Hydration:
<button class="btn">Click me (0)</button>
^
|
Same DOM node โ now has onClick handler attached
React's virtual DOM is synced with this DOM
Clicking increments the counter
Hydration Mismatch Errors
If the server-rendered HTML does not match what React tries to render on the client, React logs a hydration mismatch warning and falls back to client-side rendering.
Common causes:
// BAD: Date.now() produces different values on server and client
function Timestamp() {
return <p>Generated at: {Date.now()}</p>;
}
// BAD: window does not exist on the server
function ScreenWidth() {
return <p>Width: {window.innerWidth}</p>; // crashes on server
}
// FIX: use useEffect for client-only values
function ScreenWidth() {
const [width, setWidth] = useState(0);
useEffect(() => {
setWidth(window.innerWidth);
}, []);
return <p>Width: {width}</p>;
}
// FIX: use suppressHydrationWarning for intentional mismatches
function Timestamp() {
return <p suppressHydrationWarning>Generated at: {Date.now()}</p>;
}
Selective Hydration (React 18)
React 18 with Suspense enables selective hydration โ components wrapped in <Suspense> can hydrate independently, without blocking the rest of the page.
import { Suspense } from 'react';
function Page() {
return (
<div>
<Header /> {/* hydrates immediately */}
<MainContent /> {/* hydrates immediately */}
<Suspense fallback={<Spinner />}>
<Comments /> {/* hydrates later, does not block above */}
</Suspense>
<Suspense fallback={<Spinner />}>
<Sidebar /> {/* hydrates independently */}
</Suspense>
</div>
);
}
If the user clicks on Comments before it hydrates, React prioritizes hydrating that component first.
SEO Implications
Search engine crawlers need to read your page content to index it. How you render determines what crawlers see.
+----------+--------------------+----------------------------------+
| Strategy | What Crawlers See | SEO Impact |
+----------+--------------------+----------------------------------+
| CSR | Empty <div> | Poor โ content not in initial |
| | (until JS runs) | HTML. Google can execute JS but |
| | | delays indexing, others cannot. |
+----------+--------------------+----------------------------------+
| SSR | Full HTML with | Excellent โ all content in |
| | content | initial response. Fast indexing. |
+----------+--------------------+----------------------------------+
| SSG | Full HTML with | Excellent โ same as SSR but |
| | content | even faster response times. |
+----------+--------------------+----------------------------------+
SEO-Critical Elements
// These must be in the initial HTML (not client-rendered)
// 1. Page title and meta description
<head>
<title>Blog Post Title | My Site</title>
<meta name="description" content="Post excerpt here..." />
</head>
// 2. Open Graph tags for social sharing
<meta property="og:title" content="Blog Post Title" />
<meta property="og:description" content="Post excerpt here..." />
<meta property="og:image" content="https://mysite.com/og-image.jpg" />
// 3. Semantic HTML with content
<article>
<h1>Blog Post Title</h1>
<p>Actual content that crawlers can read...</p>
</article>
// 4. Structured data (JSON-LD)
<script type="application/ld+json">
{JSON.stringify({
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: "Blog Post Title",
author: { "@type": "Person", name: "Author Name" },
})}
</script>
With CSR, none of these are in the initial HTML. With SSR/SSG, they are all present from the first byte.
Implementing SEO in Next.js
import Head from 'next/head';
function BlogPost({ post }) {
return (
<>
<Head>
<title>{post.title} | My Blog</title>
<meta name="description" content={post.excerpt} />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.excerpt} />
<meta property="og:image" content={post.coverImage} />
<meta property="og:type" content="article" />
<link rel="canonical" href={`https://myblog.com/blog/${post.slug}`} />
</Head>
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
</>
);
}
Performance Trade-offs
Time to First Byte (TTFB)
CSR: Fast TTFB (server just sends a tiny HTML shell)
SSR: Slower TTFB (server must render HTML, possibly fetch data)
SSG: Fastest TTFB (pre-built HTML served from CDN edge)
First Contentful Paint (FCP)
CSR: Slow FCP (download JS + execute + render)
SSR: Fast FCP (HTML arrives with content)
SSG: Fastest FCP (HTML arrives with content, from CDN)
Time to Interactive (TTI)
CSR: Same as FCP (JS is already loaded when content appears)
SSR: Slower TTI than FCP (content visible but not interactive until hydration)
SSG: Same as SSR (content visible early, interactive after hydration)
Server Cost
CSR: $0 runtime (static files on CDN)
SSR: $$$ (server CPU per request)
SSG: $0 runtime (static files on CDN), $ at build time
ISR: $ (occasional regeneration, mostly CDN)
Choosing the Right Strategy
Decision Tree
Does the page content change per user?
YES --> Is it SEO-critical?
YES --> SSR (getServerSideProps)
NO --> CSR (fetch in useEffect)
NO --> Does content change frequently?
YES (every minute) --> SSR or ISR (revalidate: 60)
YES (hourly/daily) --> SSG with ISR (revalidate: 3600)
NO (rarely) --> SSG (getStaticProps)
Strategy by Page Type
| Page Type | Recommended | Why |
|---|---|---|
| Marketing / Landing | SSG | Static content, max performance, SEO |
| Blog posts | SSG + ISR | Content changes infrequently |
| Product pages (e-commerce) | SSG + ISR | SEO critical, inventory changes slowly |
| Search results | SSR | Dynamic based on query, SEO important |
| User dashboard | CSR | User-specific, behind auth, no SEO need |
| Social feed | CSR or SSR | Real-time data; SSR if SEO matters |
| Documentation | SSG | Static content, fast load, SEO |
| Checkout flow | CSR | User-specific, no SEO need |
Mixing Strategies in One App
Next.js allows different strategies per page. A single app can use SSG, SSR, and CSR simultaneously.
// pages/index.jsx โ SSG (marketing page)
export async function getStaticProps() {
const testimonials = await fetchTestimonials();
return { props: { testimonials }, revalidate: 86400 };
}
export default function Home({ testimonials }) { /* ... */ }
// pages/blog/[slug].jsx โ SSG + ISR (blog posts)
export async function getStaticPaths() { /* ... */ }
export async function getStaticProps({ params }) {
return { props: { post: await fetchPost(params.slug) }, revalidate: 3600 };
}
export default function BlogPost({ post }) { /* ... */ }
// pages/search.jsx โ SSR (dynamic search)
export async function getServerSideProps({ query }) {
const results = await searchProducts(query.q);
return { props: { results, query: query.q } };
}
export default function Search({ results, query }) { /* ... */ }
// pages/dashboard.jsx โ CSR (user-specific, behind auth)
export default function Dashboard() {
const { data, loading } = useFetch('/api/dashboard');
if (loading) return <Spinner />;
return <DashboardContent data={data} />;
}
Next.js App Router (React Server Components)
Next.js 13+ introduced the App Router with React Server Components (RSC), which blurs the line between SSR and CSR.
// app/blog/page.jsx โ Server Component (default)
// This component runs ONLY on the server
// No useState, no useEffect, no event handlers
async function BlogPage() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return (
<div>
<h1>Blog</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<LikeButton postId={post.id} /> {/* client component */}
</article>
))}
</div>
);
}
export default BlogPage;
// app/blog/LikeButton.jsx โ Client Component
'use client'; // This directive marks it as a client component
import { useState } from 'react';
export function LikeButton({ postId }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? 'Liked' : 'Like'}
</button>
);
}
Server Components send zero JavaScript to the client. Only components marked 'use client' add to the client bundle. This fundamentally changes the CSR/SSR trade-off: you get SSR performance with CSR interactivity, and only the interactive parts cost bundle size.
Traditional SSR:
Server renders ALL components to HTML
Client downloads JS for ALL components
Client hydrates ALL components
React Server Components:
Server renders server components (no JS sent)
Server renders client components to HTML
Client downloads JS for ONLY client components
Client hydrates ONLY client components
Summary Comparison
+--------+--------+--------+--------+---------+----------+
| | TTFB | FCP | TTI | SEO | Cost |
+--------+--------+--------+--------+---------+----------+
| CSR | Fast | Slow | = FCP | Poor | Low |
| SSR | Medium | Fast | Medium | Great | High |
| SSG | Fast | Fast | Medium | Great | Very Low |
| ISR | Fast | Fast | Medium | Great | Low |
+--------+--------+--------+--------+---------+----------+
Key Takeaways
- CSR renders everything in the browser โ simple but bad for SEO and initial load
- SSR renders on the server per request โ great SEO, but adds server costs and latency
- SSG pre-renders at build time โ fastest possible load, but only works for static or slowly changing content
- ISR combines SSG with periodic regeneration โ near-static performance with fresh data
- Hydration bridges server-rendered HTML and client-side interactivity
- Hydration mismatches happen when server and client output differ โ use useEffect for client-only values
- SEO-critical pages need SSR or SSG โ crawlers cannot reliably execute JavaScript
- Mix strategies within one app: SSG for marketing, SSR for search, CSR for dashboards
- React Server Components (Next.js App Router) reduce client JS by running components on the server
- Choose based on data freshness needs, SEO requirements, and infrastructure budget