Rendering Strategies
Rendering Strategies
Choosing the right rendering strategy dramatically impacts performance, SEO, and user experience. Modern frameworks offer multiple approaches—understanding when to use each is essential.
The Rendering Spectrum
Static ←───────────────────────────────────────→ Dynamic
Build Time Hybrid Request Time
│ │ │
▼ ▼ ▼
SSG ISR / Partial SSR / CSR
(Static) (Mix of strategies) (Dynamic)Client-Side Rendering (CSR)
The browser downloads minimal HTML, then JavaScript builds the entire page.
<!-- Initial HTML sent by server -->
<!DOCTYPE html>
<html>
<head>
<title>App</title>
</head>
<body>
<div id="root"></div>
<script src="/bundle.js"></script>
</body>
</html>// React renders after JS loads
ReactDOM.createRoot(document.getElementById("root")).render(<App />);CSR Performance Characteristics
Timeline:
├── HTML Download (fast, tiny)
├── JS Bundle Download (slow, large)
├── JS Parse & Execute (slow)
├── Data Fetch (network)
├── Render (JS work)
└── Interactive
Problems:
- Slow Time to First Contentful Paint (FCP)
- Slow Largest Contentful Paint (LCP)
- SEO challenges (crawlers see empty page)
- Long Time to Interactive (TTI)When to Use CSR
- Dashboards and admin panels (behind auth)
- Complex interactive applications
- Applications where SEO doesn't matter
- Offline-first applications
// CSR with data fetching
function Dashboard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDashboardData()
.then(setData)
.finally(() => setLoading(false));
}, []);
if (loading) return <DashboardSkeleton />;
return <DashboardContent data={data} />;
}Server-Side Rendering (SSR)
The server renders full HTML for each request. JavaScript then "hydrates" the page for interactivity.
// Next.js Server Component (App Router)
export default async function Page({ params }) {
// Runs on server for each request
const data = await fetchData(params.id);
return (
<article>
<h1>{data.title}</h1>
<p>{data.content}</p>
</article>
);
}
// With dynamic data that can't be cached
export const dynamic = "force-dynamic";SSR Performance Characteristics
Timeline:
├── Request to Server
├── Server fetches data
├── Server renders HTML
├── HTML sent to client
├── First Contentful Paint (fast!)
├── JS Download
├── Hydration
└── Interactive
Benefits:
+ Fast FCP and LCP
+ Good SEO (full HTML)
+ Works without JS
Drawbacks:
- Server load per request
- TTFB slower than static
- Hydration costStreaming SSR
Send HTML chunks as they're ready, don't wait for everything.
// Next.js with Suspense streaming
import { Suspense } from "react";
export default function Page() {
return (
<div>
{/* Header streams immediately */}
<Header />
{/* Main content streams when ready */}
<Suspense fallback={<MainSkeleton />}>
<MainContent />
</Suspense>
{/* Comments stream last */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</div>
);
}
async function MainContent() {
const data = await fetchMainContent(); // Async server component
return <article>{data.content}</article>;
}
async function Comments() {
const comments = await fetchComments(); // Can be slow
return <CommentsList comments={comments} />;
}Streaming Timeline:
├── Request
├── Header HTML sent immediately
├── [Main content loading...]
├── Main content HTML sent
├── [Comments loading...]
├── Comments HTML sent
└── Complete
User sees content progressively!Static Site Generation (SSG)
SSG renders pages at build time, generating plain HTML files that can be served directly from a CDN. This is the fastest possible delivery method—no server-side computation happens when users request pages.
Why SSG is so fast:
- Pages are just static files (HTML, CSS, JS)
- CDNs cache and serve files from edge locations worldwide
- No database queries or API calls at request time
- No server processing delay
The trade-off: Content is frozen at build time. If your data changes, you need to rebuild and redeploy. For sites with thousands of pages, builds can take a long time.
Ideal for:
- Content that rarely changes
- When you want maximum performance and reliability
- Sites that can tolerate some staleness
// Next.js Static Generation
export default function BlogPost({ post }) {
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
// Runs at build time
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
// Fetch data at build time
async function getPost(slug) {
const post = await fetchPost(slug);
return post;
}
export default async function Page({ params }) {
const post = await getPost(params.slug);
return <BlogPost post={post} />;
}SSG Performance Characteristics
Build Time:
├── Fetch all data
├── Render all pages
└── Output static HTML/CSS/JS
Request Time:
├── CDN serves static file
└── Done! (incredibly fast)
Benefits:
+ Fastest possible TTFB
+ Zero server compute per request
+ Maximum cacheability
+ Works on any static host
Drawbacks:
- Build time scales with page count
- Data can become stale
- Not suitable for user-specific contentWhen to Use SSG
- Marketing pages
- Documentation
- Blog posts
- Product listings (that don't change often)
Incremental Static Regeneration (ISR)
ISR solves SSG's biggest problem: stale data. It combines static performance with the ability to update content without rebuilding the entire site.
How ISR works:
- Pages are statically generated (like SSG)
- After a specified time (
revalidate), the page becomes "stale" - The next request still serves the stale page instantly
- In the background, the page is regenerated with fresh data
- Subsequent requests get the fresh version
Why this is powerful:
- Users always get fast responses (never wait for generation)
- Content stays reasonably fresh
- No full rebuilds needed
- New pages are generated on-demand
Example use cases:
- E-commerce product pages (prices update hourly)
- News sites (articles regenerate every few minutes)
- Large sites with millions of pages (impossible to pre-generate all)
// Next.js ISR
export const revalidate = 3600; // Revalidate every hour
export default async function Page({ params }) {
const product = await fetchProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>Stock: {product.stock}</p>
</div>
);
}
// Generate initial set at build time
export async function generateStaticParams() {
const popularProducts = await getPopularProducts();
return popularProducts.map((p) => ({ id: p.id }));
}ISR Behavior
First request (cache miss):
├── Generate page on-demand
├── Cache the result
└── Serve to user
Subsequent requests (cache hit):
├── Serve cached page (instant!)
├── If stale, trigger background regeneration
└── Next request gets fresh page
Benefits:
+ Static-like performance
+ Fresh data (within revalidation window)
+ Scales to millions of pages
+ On-demand generation for new contentOn-Demand Revalidation
Time-based revalidation works, but sometimes you need immediate updates. On-demand revalidation lets you trigger page regeneration programmatically—typically via a webhook from your CMS.
When to use on-demand revalidation:
- Content editor publishes an article → regenerate that article page
- Product price changes in admin → regenerate product page
- User updates their profile → regenerate their public profile
Security note: Always protect revalidation endpoints with a secret token to prevent abuse.
// API route to trigger revalidation
// pages/api/revalidate.js (Next.js)
export default async function handler(req, res) {
const { secret, path } = req.query;
if (secret !== process.env.REVALIDATION_SECRET) {
return res.status(401).json({ message: "Invalid token" });
}
try {
await res.revalidate(path);
return res.json({ revalidated: true });
} catch (err) {
return res.status(500).json({ message: "Error revalidating" });
}
}
// Call from CMS webhook
// POST /api/revalidate?secret=xxx&path=/blog/my-postPartial Prerendering (PPR)
PPR is the evolution of rendering strategies—it recognizes that most pages have both static and dynamic parts. Instead of choosing one strategy for the entire page, PPR lets you mix them.
The insight: On a product page, the header, footer, product description, and images rarely change (static). But the price, stock level, and personalized recommendations change constantly (dynamic).
How PPR works:
- Static parts are prerendered at build time (instant delivery)
- Dynamic parts show a skeleton/fallback immediately
- Dynamic content streams in as it becomes ready
- Result: Static-speed initial load + fresh dynamic data
This is the future of rendering—you get the best of every strategy in one page.
// Next.js PPR (experimental)
export const experimental_ppr = true;
export default function ProductPage({ params }) {
return (
<div>
{/* Static shell - prerendered */}
<Header />
<ProductInfo productId={params.id} />
{/* Dynamic - rendered at request time */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPrice productId={params.id} />
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<LiveStock productId={params.id} />
</Suspense>
{/* Static footer */}
<Footer />
</div>
);
}
// Runs at request time
async function DynamicPrice({ productId }) {
const price = await fetchCurrentPrice(productId); // Real-time
return <span>${price}</span>;
}Hydration Deep Dive
Hydration is one of the most misunderstood concepts in modern frontend development. When the server sends HTML, it's just static content—clicking buttons does nothing. Hydration is the process where React "attaches" to that HTML and makes it interactive.
What happens during hydration:
- Server sends fully rendered HTML (user sees content immediately)
- Browser downloads JavaScript bundle
- React "walks" the existing HTML and attaches event listeners
- React builds its internal representation (virtual DOM) to match the HTML
- Page becomes interactive
The critical requirement: The HTML React generates on the client must exactly match what the server sent. If there's a mismatch, React throws warnings and may re-render parts of the page, negating SSR benefits.
The Hydration Process
// 1. Server renders HTML
const html = ReactDOMServer.renderToString(<App />);
// 2. Client receives HTML (visible, not interactive)
// 3. Client loads JS and hydrates
ReactDOM.hydrateRoot(document.getElementById("root"), <App />);
// React attaches event listeners, reconciles with server HTMLHydration Problems
Hydration mismatches occur when the server and client render different content. This is surprisingly easy to cause:
- Using
Date.now()ornew Date()(different times server vs client) - Reading
window.innerWidth(doesn't exist on server) - Using
Math.random()(different values each time) - Accessing localStorage (server doesn't have it)
Why mismatches are bad:
- React logs warnings to console
- React may throw away server HTML and re-render (defeating SSR)
- Users may see content "flash" as it changes
- Performance suffers
// ❌ Hydration mismatch - different content server vs client
function BadComponent() {
return <div>{new Date().toString()}</div>; // Different on server/client!
}
// ✅ Handle dynamic content properly
function GoodComponent() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div>Loading...</div>; // Same on server and client
}
return <div>{new Date().toString()}</div>;
}
// ✅ Or use suppressHydrationWarning for known mismatches
function TimeComponent() {
return <time suppressHydrationWarning>{new Date().toString()}</time>;
}Selective Hydration
Not every part of a page needs interactivity. A blog post's text doesn't need JavaScript—only the comment form and share buttons do. Selective hydration (also called "Islands Architecture") means only hydrating the interactive parts.
Benefits:
- Much smaller JavaScript bundles
- Faster Time to Interactive (TTI)
- Main content works without JS
- Better performance on slow devices
Frameworks using this pattern:
- Astro (pioneered Islands Architecture)
- Fresh (Deno framework)
- Qwik (resumability instead of hydration)
// Islands Architecture concept
// Static HTML with hydrated "islands"
function Page() {
return (
<div>
{/* Static - no hydration needed */}
<StaticHeader />
<StaticContent />
{/* Interactive island - hydrated */}
<ClientOnly>
<InteractiveWidget />
</ClientOnly>
{/* Static footer */}
<StaticFooter />
</div>
);
}
// ClientOnly wrapper
function ClientOnly({ children }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return mounted ? children : null;
}React Server Components (RSC)
RSC represents a fundamental shift in how we think about React. Instead of all components running on both server and client, Server Components run ONLY on the server and send their rendered output (not their code) to the client.
The key insight: Most components just fetch data and render HTML—they don't need event handlers or state. Why send their JavaScript to the client at all?
Server Components can:
- Use async/await directly (no useEffect for data fetching)
- Access databases, file systems, and secrets directly
- Import large dependencies without affecting bundle size
- Never re-render on the client (they're just HTML)
Server Components cannot:
- Use useState, useEffect, or other React hooks
- Use browser APIs (window, document)
- Handle events (onClick, onChange)
- Use context that updates on the client
When a component needs interactivity, make it a Client Component by adding 'use client' at the top. The key is to push Client Components down to the leaves of your component tree.
// Server Component (default in App Router)
// - Can use async/await directly
// - Can access backend resources
// - No client-side JavaScript
async function ProductList() {
const products = await db.query("SELECT * FROM products");
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} - ${p.price}
{/* Client component for interactivity */}
<AddToCartButton productId={p.id} />
</li>
))}
</ul>
);
}
// Client Component (needs 'use client' directive)
("use client");
function AddToCartButton({ productId }) {
const [adding, setAdding] = useState(false);
const handleClick = async () => {
setAdding(true);
await addToCart(productId);
setAdding(false);
};
return (
<button onClick={handleClick} disabled={adding}>
{adding ? "Adding..." : "Add to Cart"}
</button>
);
}RSC Benefits
Server Components:
+ Zero client-side JavaScript
+ Direct database/filesystem access
+ Smaller bundle size
+ Better security (secrets stay on server)
Client Components:
+ Interactivity (onClick, onChange, etc.)
+ Browser APIs (localStorage, etc.)
+ useState, useEffect, etc.Choosing a Rendering Strategy
┌─────────────────────────────────────────────────────────────┐
│ Decision Tree │
├─────────────────────────────────────────────────────────────┤
│ │
│ Is content user-specific? │
│ YES → SSR or CSR │
│ NO ↓ │
│ │
│ Does content change frequently? │
│ REAL-TIME → SSR with streaming │
│ HOURLY → ISR (revalidate = 3600) │
│ DAILY → ISR or SSG + redeploy │
│ RARELY → SSG │
│ │
│ Is SEO important? │
│ YES → SSR, SSG, or ISR (avoid pure CSR) │
│ NO → CSR is fine │
│ │
│ Is interactivity the primary feature? │
│ YES → CSR with skeleton loading │
│ NO → SSR/SSG with selective hydration │
│ │
└─────────────────────────────────────────────────────────────┘Strategy Comparison
| Strategy | TTFB | FCP | SEO | Dynamic Data | Scalability |
|---|---|---|---|---|---|
| CSR | Fast | Slow | Poor | Yes | Excellent |
| SSR | Medium | Fast | Good | Yes | Moderate |
| SSG | Fastest | Fastest | Good | Build only | Excellent |
| ISR | Fast | Fast | Good | Stale-while-revalidate | Excellent |
| Streaming SSR | Fast | Progressive | Good | Yes | Good |
Performance Optimization
Prefetching
// Next.js auto-prefetches links in viewport
import Link from 'next/link';
<Link href="/about" prefetch>About</Link>
// Disable prefetch for less important links
<Link href="/terms" prefetch={false}>Terms</Link>
// Manual prefetch
import { useRouter } from 'next/router';
function NavigationButton() {
const router = useRouter();
const handleMouseEnter = () => {
router.prefetch('/dashboard');
};
return (
<button
onMouseEnter={handleMouseEnter}
onClick={() => router.push('/dashboard')}
>
Go to Dashboard
</button>
);
}Loading States
// Next.js App Router loading.js
// Automatically shows during page transitions
// app/dashboard/loading.js
export default function Loading() {
return <DashboardSkeleton />;
}
// Granular loading with Suspense
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
</div>
);
}Route Segment Config
// Per-route rendering configuration (Next.js)
// Force static generation
export const dynamic = "force-static";
// Force dynamic rendering
export const dynamic = "force-dynamic";
// Revalidate every hour
export const revalidate = 3600;
// Don't cache at all
export const revalidate = 0;
// Generate at build time
export const dynamicParams = false; // 404 for unknown params
// Allow on-demand generation
export const dynamicParams = true; // Generate unknown params on first requestSummary
| Strategy | Best For | Trade-offs |
|---|---|---|
| CSR | SPAs, dashboards, no SEO | Slow initial load |
| SSR | Dynamic, personalized content | Server load |
| SSG | Static content, maximum speed | Build time |
| ISR | Semi-dynamic content | Staleness window |
| Streaming | Large pages with slow parts | Complexity |
| RSC | Data-heavy, minimal JS | Learning curve |
Key Principles:
- Start with static, add dynamism as needed
- Use streaming for pages with mixed loading times
- Prefer Server Components for data fetching
- Keep Client Components small and focused
- Measure Core Web Vitals to guide decisions