Server-Side Rendering (SSR) Basics
Question 1: What is SSR and how does it differ from CSR?
Main Answer
Server-Side Rendering (SSR) is the process of rendering React components on the server and sending fully formed HTML to the browser, rather than sending a blank HTML file and having JavaScript build the DOM on the client. In CSR (Client-Side Rendering), the browser receives minimal HTML and a JavaScript bundle that constructs the entire UI.
Key differences:
| Aspect | SSR | CSR |
|---|---|---|
| Initial HTML | Contains full markup | Minimal, mostly empty |
| JavaScript bundle | Still required for interactivity | Full app logic |
| First Contentful Paint | Faster (server renders HTML) | Slower (JS must parse and execute) |
| SEO | Better (search engines see full HTML) | Challenging (crawlers may miss content) |
| Server load | Higher (rendering on every request) | Lower (static files only) |
| Time to Interactive | Slower (hydration overhead) | Faster (once JS loads) |
| Caching | Page-specific | More consistent |
SSR Flow:
- Browser requests page from server
- Server renders React component to HTML string
- Server sends HTML + inline CSS + JavaScript bundle
- Browser displays HTML immediately (faster FCP)
- JavaScript "hydrates" the DOM with event listeners
- Page becomes interactive (TTI)
CSR Flow:
- Browser receives minimal HTML
- Downloads and parses JavaScript bundle
- JavaScript runs and builds the entire DOM
- Page becomes interactive
- Slower FCP and TTI, but no server overhead
<details> <summary><strong>🔍 Deep Dive: SSR Architecture and Rendering Mechanisms</strong></summary>
SSR Architecture and Rendering Mechanisms
Server-Side Rendering Process:
React provides two main APIs for SSR:
ReactDOMServer.renderToString()- Synchronous, full document rendering
// Express server example
import ReactDOMServer from 'react-dom/server';
import App from './App';
app.get('*', (req, res) => {
const html = ReactDOMServer.renderToString(<App />);
// Send complete HTML document
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div id="root">${html}</div>
<script src="/bundle.js"></script>
</body>
</html>
`);
});
ReactDOMServer.renderToPipeableStream()- Streaming, progressive rendering
// Better for larger apps - starts sending HTML before rendering completes
import { renderToPipeableStream } from 'react-dom/server';
app.get('*', (req, res) => {
const { pipe } = renderToPipeableStream(<App />);
res.setHeader('Content-type', 'text/html');
pipe(res); // Streams HTML progressively
});
The renderToString vs renderToPipeableStream trade-off:
renderToString()waits for all components to render before sending (blocks TTFB)renderToPipeableStream()sends HTML chunks as they're ready (better TTFB, progressive enhancement)
Hydration Mechanism
Hydration is the process of attaching event listeners and state to server-rendered HTML.
Without hydration:
// Server sent this HTML
<button>Click me</button>
// Client-side, if you only do this:
// ReactDOM.createRoot(document.getElementById('root')).render(<App />)
// React REPLACES the entire DOM, discarding server HTML (wasteful!)
With hydration (correct approach):
// Client-side
import { hydrateRoot } from 'react-dom/client';
import App from './App';
hydrateRoot(document.getElementById('root'), <App />);
// React walks the DOM tree, attaches event listeners without re-rendering
// Preserves server HTML structure
Hydration algorithm:
- React renders component tree in memory (using same logic as server)
- Compares in-memory tree with server HTML in DOM
- Attaches event listeners to matching nodes
- If mismatch detected → causes hydration error (critical issue)
- Once complete, app is interactive
Critical: Component behavior must be identical on server and client:
// ❌ WRONG - Will cause hydration mismatch
function Counter() {
const [count, setCount] = useState(0);
// Date.now() produces different values on server vs client
const uniqueId = Date.now();
useEffect(() => {
// This runs AFTER hydration, state diverges
setCount(Math.random() * 100);
}, []);
return <div>{count} - {uniqueId}</div>;
}
// ✅ CORRECT - Deterministic
function Counter() {
const [count, setCount] = useState(0);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true); // Marks when hydration is complete
}, []);
if (!mounted) return <div>0</div>; // Server-matching render
return <div>{Math.random()}</div>; // Client-only content
}
Double Rendering Problem
SSR requires rendering components twice:
- Server-side render - Generate HTML string
- Client-side hydration - Re-render in memory to attach listeners
This is computationally expensive:
// For a complex app with 1000 components:
// Server: 500ms to renderToString()
// Client: 300ms to hydrateRoot() + attach listeners
// Total waste: ~300ms of duplicate work
// renderToPipeableStream helps:
// Sends initial HTML after first 50ms
// Browser can start parsing while server still rendering
// Reduces Time to First Byte (TTFB)
Performance implications:
- Server-side rendering: 500-800ms CPU time per request
- Client-side parsing: 200-400ms JavaScript parsing
- Total Time to Interactive: 1000-2000ms (vs CSR which might be 2000-3000ms after bundle loads)
</details>
<details> <summary><strong>🐛 Real-World Scenario: Hydration Mismatch Bug</strong></summary>
Incident: E-commerce platform product page showing wrong prices
Context:
- Product page uses SSR for SEO
- Price component displays prices from global currency state
- Deployed to production, users reporting wrong prices on initial load
The Bug:
// components/ProductPrice.jsx
import { useContext } from 'react';
import { CurrencyContext } from './CurrencyContext';
export function ProductPrice({ price }) {
const { currency } = useContext(CurrencyContext);
// Price in USD: 100
// User in India, currency should be INR: 8000
return <div>${price * exchangeRate[currency]}</div>;
}
// Server-side (Node.js running in US timezone/region)
// Currency defaults to USD: renders "$100"
// Client-side (User's browser in India)
// Currency context initialized to INR: hydration expects "$8000"
// Result: React detects mismatch!
// Console error: "Hydration failed for: <div>$100</div> != <div>$8000</div>"
// React discards server HTML, re-renders from scratch (kills SSR benefits)
Debugging steps taken:
# 1. Check hydration warnings in browser console
# "Unexpected non-hydatable content inside <div>"
# "Hydration expected a <span> but the server had <div>"
# 2. Compare server-rendered HTML with client render
# curl http://localhost:3000/product/123 > server.html
# Open browser DevTools, right-click element > "Inspect HTML in different format"
# 3. Add logging to identify divergence
const ProductPrice = ({ price }) => {
const { currency } = useContext(CurrencyContext);
console.log('Rendering price with currency:', currency);
// Client console: "INR"
// Server logs: "USD" (or undefined)
return <div>{price}</div>;
};
# 4. Check CurrencyContext initialization
// ❌ Problem: Context value derived from browser API at render time
const CurrencyProvider = ({ children }) => {
const currency = navigator.language === 'en-IN' ? 'INR' : 'USD';
// navigator not available on server!
return <CurrencyContext.Provider value={{ currency }}>{children}</CurrencyContext.Provider>;
};
The Fix:
// server.js
import express from 'express';
import { renderToPipeableStream } from 'react-dom/server';
app.get('/product/:id', (req, res) => {
// Detect user's currency from Accept-Language header or IP geolocation
const userCurrency = detectCurrencyFromRequest(req); // "INR" for Indian users
const { pipe } = renderToPipeableStream(
<CurrencyContext.Provider value={{ currency: userCurrency }}>
<ProductPage productId={req.params.id} />
</CurrencyContext.Provider>
);
res.setHeader('Content-type', 'text/html');
pipe(res);
});
// client.js
import { hydrateRoot } from 'react-dom/client';
// CRITICAL: Must use SAME currency context value as server
const userCurrency = document.querySelector('script[data-currency]').dataset.currency;
hydrateRoot(
document.getElementById('root'),
<CurrencyContext.Provider value={{ currency: userCurrency }}>
<ProductPage productId={window.INITIAL_DATA.productId} />
</CurrencyContext.Provider>
);
Metrics Impact:
-
Before fix: 40% of users experienced hydration mismatches, causing full re-renders
- TTFB: 200ms (server rendering good)
- TTI: 3200ms (hydration failure + full client re-render)
- CLS (Cumulative Layout Shift): 0.15 (bad, price shifts during hydration)
-
After fix: Eliminated hydration mismatches
- TTFB: 200ms (unchanged)
- TTI: 1800ms (hydration succeeds, no re-render)
- CLS: 0.02 (excellent, correct content rendered from start)
Result: 43% improvement in TTI for affected users, 0% hydration failures
</details>
<details> <summary><strong>⚖️ Trade-offs: SSR vs CSR vs SSG vs ISR</strong></summary>
Rendering Strategy Comparison:
| Metric | SSR | CSR | SSG | ISR |
|---|---|---|---|---|
| TTFB | Slow (render on request) | Fast (serve static) | Fast (pre-built HTML) | Fast (pre-built) |
| FCP | Fast (HTML has content) | Slow (blank + JS) | Fast (HTML has content) | Fast (HTML has content) |
| TTI | Slow (hydration overhead) | Slower (JS parsing) | Medium (less JS) | Medium (less JS) |
| Time to Latest Content | Instant | Instant | Rebuild delay (hours) | Rebuild interval (minutes) |
| SEO | Excellent (dynamic content) | Poor (crawlers miss JS) | Excellent (static HTML) | Excellent (static + updates) |
| Server Cost | High (CPU per request) | Low (CDN only) | Low (build cost) | Medium (builds + serving) |
| Database Queries | Per request | Browser-side | Build time | Build time + incremental |
| Dynamic Content | Yes (real-time) | Yes (client-fetched) | No (static) | Limited (cached) |
| User-Specific Data | Yes (cookies/headers) | Yes (client-side) | No (shared) | No (shared) |
| Data Freshness | Always fresh | Depends on API | Stale (rebuild delay) | Fresher than SSG |
Decision Tree:
Is content personalized per user?
├─ YES → SSR (render per request with user data)
│ └─ Is server load high? → Consider CSR (serve API, render client)
└─ NO → Is content frequently updated?
├─ YES (minutes) → ISR (pre-built + incremental updates)
└─ NO (static) → SSG (pre-build all at deploy time)
Real-world examples:
-
E-commerce Product Page → SSR
- Product info changes frequently
- User-specific data (price, inventory, wishlist status)
- SEO critical (product discovery)
- Real-time inventory updates
-
Blog → SSG + ISR
- Content changes weekly
- No personalization
- SEO critical
- Incremental Statistic Generation: rebuild on new posts
-
Logged-in Dashboard → CSR
- All content personalized
- SEO not needed (behind auth)
- Faster TTI (less server rendering)
- Real-time updates (WebSockets)
-
Marketing Homepage → SSG
- Static content
- Changed monthly
- SEO critical
- Serve from CDN globally
-
Real-time Chat Application → CSR
- All content personalized per user
- SEO not relevant
- Needs WebSocket for updates
- Server rendering unnecessary
Performance Trade-off Matrix:
Server Load (CPU)
↑
│
SSR ●│● ISR
│ ●
│ CSR
● SSG
│
└────────────→ Initial Render Speed (TTI)
Quadrants:
- Upper Right: High load, fast initial (SSR during spikes)
- Upper Left: High load, slow initial (SSR problematic)
- Lower Left: Low load, slow initial (SSG best, CSR acceptable)
- Lower Right: Low load, fast initial (CSR ideal for dynamic)
Cost Analysis (Annual):
Assuming 1M monthly active users, 10 requests per user:
-
SSR (Node.js server)
- Server: $2,000/month × 12 = $24,000
- Database: $500/month × 12 = $6,000
- CDN (for static assets): $1,000/month × 12 = $12,000
- Total: $42,000/year
-
CSR (Static hosting + API)
- Static hosting (CDN): $500/month × 12 = $6,000
- API server (lightweight): $1,000/month × 12 = $12,000
- Database: $500/month × 12 = $6,000
- Total: $24,000/year (43% cheaper)
-
SSG (Static hosting)
- Static hosting (CDN): $500/month × 12 = $6,000
- Build server (CI/CD): $200/month × 12 = $2,400
- Database (if needed for build): $300/month × 12 = $3,600
- Total: $12,000/year (71% cheaper than SSR)
Recommendation by use case:
| Scenario | Best Choice | Why |
|---|---|---|
| News site (frequent updates, SEO important) | SSG + ISR | Builds incrementally, always fresh |
| E-commerce (real-time inventory, SEO) | SSR | Must reflect current state |
| SaaS dashboard (personalized, no SEO) | CSR | Faster TTI, no server rendering |
| Static blog/docs | SSG | Cheapest, fastest CDN delivery |
| High-traffic e-commerce | SSG + API + CSR | Hybrid: static shell, client fetches data |
</details>
<details> <summary><strong>💬 Explain to Junior: Understanding SSR vs CSR</strong></summary>
Restaurant Analogy:
Imagine three ways to serve dinner to customers:
-
Server-Side Rendering (SSR) = Hot food delivered immediately
- Customer sits down, kitchen prepares their meal (render on server)
- Waiter brings hot plate (server sends HTML immediately)
- Customer can see and taste food right away (fast FCP)
- But kitchen was busy preparing just for them (high server cost)
-
Client-Side Rendering (CSR) = DIY meal
- Customer gets raw ingredients (blank HTML + JS bundle)
- Customer reads recipe and cooks at table (browser renders page)
- Slower to start (gathering ingredients = downloading JS)
- But kitchen is free to help others (low server load)
-
Static Site Generation (SSG) = Catering platters
- Kitchen prepares meals in advance (build time)
- Customer gets pre-made meal from shelf (serve pre-built HTML)
- Instant serving (fastest TTFB)
- But if menu changes, must remake platters (rebuild delay)
Hydration Explained Simply:
Think of hydration like a staged construction project:
-
Server delivers a sketch (HTML structure)
- Like a blueprint of a building without utilities
- Browser can see the "building" immediately (fast FCP)
-
Client adds electricity and plumbing (JavaScript hydration)
- Adds all interactive features (event listeners)
- Takes time but the building is already visible while this happens
- If electrician's blueprint doesn't match the building sketch → disaster (hydration mismatch)
Common Hydration Mistakes:
// ❌ Mistake 1: Different content on server vs client
function Greeting() {
return <h1>Hello {Math.random()}</h1>; // Different number each render!
}
// ✅ Fix: Make server and client content identical
function Greeting() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true); // Mark when client hydration complete
}, []);
return <h1>Hello {isClient ? Math.random() : 'World'}</h1>;
}
// ❌ Mistake 2: Accessing browser APIs on server
function Profile() {
const isMobile = window.innerWidth < 768; // window doesn't exist on Node.js!
return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>;
}
// ✅ Fix: Use useEffect for browser-only code
function Profile() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile(window.innerWidth < 768);
}, []);
return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>;
}
Interview Answer Template:
"SSR is Server-Side Rendering, where React components are rendered to HTML on the server before sending to the browser. The main benefit is faster First Contentful Paint—users see content immediately instead of waiting for JavaScript to download and parse.
Here's how it works: The server uses ReactDOMServer.renderToString() to convert React components into an HTML string, then sends that HTML along with the JavaScript bundle. When the browser loads this HTML, the user sees content right away (fast FCP). Then, JavaScript 'hydrates' the page—React walks the DOM, attaches event listeners, and makes the page interactive (TTI).
The key difference from CSR is timing. With CSR, the browser gets a blank HTML page and must download and execute JavaScript before showing anything. With SSR, the browser renders HTML immediately, then JavaScript kicks in for interactivity.
Main trade-offs: SSR improves SEO and FCP but increases server load and TTFB. If the server is slow to render, you get worse overall performance. CSR is the opposite—slower initial render but lower server costs and potentially faster TTI once JavaScript is loaded.
A real-world example: An e-commerce product page needs SSR for SEO (search engines see prices and descriptions immediately) and to show user-specific data like prices in their currency. A logged-in dashboard doesn't need SSR because it's always personalized and doesn't need SEO.
Common gotcha: Hydration mismatches. If the server renders one thing but the client renders something different, React will detect the mismatch and discard the server HTML, defeating the entire SSR benefit. This happens when you use non-deterministic values like Math.random() or access browser APIs like window during render."
Key Interview Questions:
-
"Explain SSR and when to use it"
- Answer: Rendering on server for initial HTML, use when SEO matters or content is dynamic per user
-
"What's the difference between TTFB and TTI?"
- TTFB (Time to First Byte): Time until server responds with HTML
- TTI (Time to Interactive): Time until page is fully interactive
- SSR improves TTFB but can hurt TTI if server is slow
-
"What's hydration and why does it fail?"
- Hydration: Attaching listeners to server HTML
- Fails when server renders different content than client
- Solutions: Make renders deterministic, use useEffect for browser APIs
-
"SSR vs CSR—which is better?"
- Neither—use right tool for use case
- SSR: When SEO or initial render speed critical
- CSR: When personalization or low server cost matters
</details>
Question 2: How to implement SSR with hydration in React?
Main Answer
Implementing SSR with hydration involves three key components: a server that renders React components to HTML, a client that hydrates that HTML with JavaScript interactivity, and proper setup of shared context and state.
Basic implementation:
// server.js (Express + React SSR)
import express from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import App from './App.jsx';
const app = express();
app.use(express.static('dist')); // Serve static assets
app.get('*', (req, res) => {
// Stream HTML progressively instead of waiting for full render
const { pipe } = renderToPipeableStream(<App />);
res.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>My SSR App</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div id="root">
`);
pipe(res.pipe); // Stream rendered HTML
res.write(`
</div>
<script src="/client.js"></script>
</body>
</html>
`);
});
app.listen(3000, () => console.log('SSR server running on port 3000'));
// client.js (Browser hydration)
import { hydrateRoot } from 'react-dom/client';
import App from './App.jsx';
// Hydrate server-rendered HTML with event listeners
hydrateRoot(document.getElementById('root'), <App />);
Critical requirements for successful hydration:
- Deterministic rendering: Server and client must produce identical HTML
- Shared state initialization: Both must start with same initial state
- No browser APIs during render: Use
useEffectforwindow,localStorage, etc. - Matching component structure: Same components rendered in same order
Common pitfalls to avoid:
- Using
Math.random()or timestamps during render - Reading from
localStorageduring render phase - Conditional rendering based on browser detection
- Different CSS-in-JS implementations on server vs client
<details> <summary><strong>🔍 Deep Dive: Complete SSR Implementation Architecture</strong></summary>
Complete SSR Implementation Architecture
Full-stack setup with Next.js (modern approach):
Next.js handles SSR automatically, but understanding the manual process reveals what's happening:
// pages/product/[id].js (Next.js automatically does SSR)
export default function ProductPage({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>Price: ${product.price}</p>
<button onClick={() => addToCart(product.id)}>Buy</button>
</div>
);
}
export async function getServerSideProps({ params }) {
// Runs on server BEFORE rendering
const product = await fetchProduct(params.id);
return {
props: { product },
revalidate: 60, // ISR: revalidate every 60 seconds
};
}
Manual Express + React implementation:
// server.js
import express from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';
import { StaticRouter } from 'react-router-dom/server';
const app = express();
app.use(express.static('dist'));
// Function to fetch initial data on server
async function getInitialData(pathname) {
// Server-side data fetching
if (pathname.includes('/product/')) {
const productId = pathname.split('/').pop();
return {
product: await fetchProduct(productId),
user: await fetchUserFromSession(request),
};
}
return {};
}
app.get('*', async (req, res) => {
try {
const initialData = await getInitialData(req.path);
// IMPORTANT: Pass initial data to both rendering and hydration
const markup = (
<StaticRouter location={req.url}>
<App initialData={initialData} />
</StaticRouter>
);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.write('<!DOCTYPE html><html><head>');
res.write('<meta charset="utf-8" />');
res.write('<title>SSR App</title>');
res.write('<link rel="stylesheet" href="/styles.css" />');
res.write('</head><body><div id="root">');
// Stream the rendered markup
const { pipe } = renderToPipeableStream(markup, {
onShellReady() {
// Called when critical markup is ready
pipe(res);
},
onError(err) {
console.error('Render error:', err);
res.statusCode = 500;
res.send('Server error');
},
});
} catch (err) {
console.error(err);
res.statusCode = 500;
res.send('Error rendering page');
}
});
app.listen(3000);
Hydration Implementation Details
The hydration process step-by-step:
// client.js - Entry point for browser
import { hydrateRoot } from 'react-dom/client';
import App from './App';
// CRITICAL: Extract initial data that server included
const initialData = window.__INITIAL_DATA__;
// Hydrate with EXACT same props as server used
hydrateRoot(
document.getElementById('root'),
<App initialData={initialData} />
);
// server.js - Include initial data in HTML
res.write(`<script>
window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};
</script>`);
Hydration mismatch detection and recovery:
React compares server HTML with client render:
// ❌ CAUSES MISMATCH
function TimeComponent() {
const timestamp = Date.now(); // Different value on server vs client!
return <div>{timestamp}</div>;
}
// Server renders: <div>1699999000000</div>
// Client expects: <div>1700000000000</div> (milliseconds later)
// Result: Hydration fails, React re-renders entire app
// ✅ SOLUTION 1: Suppress hydration for dynamic content
function TimeComponent() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div>Loading...</div>; // Server + initial client match
}
return <div>{Date.now()}</div>; // Client-only rendering
}
// ✅ SOLUTION 2: Use deterministic values
function TimeComponent({ timestamp }) {
// Passed from server, same on client
return <div>{new Date(timestamp).toLocaleString()}</div>;
}
Handling complex state with hydration:
// Creating a context provider that works with SSR
import { createContext, useContext, useState } from 'react';
const AppContext = createContext();
export function AppProvider({ children, initialState }) {
const [state, setState] = useState(initialState);
return (
<AppContext.Provider value={{ state, setState }}>
{children}
</AppContext.Provider>
);
}
// server.js - Server initializes same state
const initialState = {
user: await fetchUser(req),
products: await fetchProducts(),
theme: req.cookies.theme || 'light',
};
const markup = (
<AppProvider initialState={initialState}>
<App />
</AppProvider>
);
Streaming vs Non-Streaming SSR
renderToString (blocking):
// Waits for entire component tree to render before sending
import { renderToString } from 'react-dom/server';
app.get('/', (req, res) => {
const html = renderToString(<App />); // Blocks until complete
res.send(`<!DOCTYPE html>...${html}...</html>`);
});
// Timing:
// - Large app takes 800ms to render
// - User waits 800ms for first byte
// - Slow TTFB
renderToPipeableStream (streaming):
// Streams HTML as it becomes available
import { renderToPipeableStream } from 'react-dom/server';
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(<App />);
// Shell renders first (~50ms)
res.write(htmlShell); // Send immediately
pipe(res); // Stream content as ready
});
// Timing:
// - First 50ms: header/nav renders, sent to browser
// - Browser starts parsing/rendering while server continues
// - Remaining 750ms: server renders rest, browser displays progressively
// - Better TTFB and progressive enhancement
Streaming benefits:
- TTFB reduced by 50-70% (browser sees content earlier)
- Progressive rendering (users see partial content while loading)
- Better perceived performance
- Reduces Time to First Meaningful Paint
</details>
<details> <summary><strong>🐛 Real-World Scenario: Hydration Mismatch During Authentication</strong></summary>
Incident: Users logging in receive blank page after SSR render
Context:
- SSR e-commerce app with protected product pages
- User authentication via JWT token in cookies
- Protected pages check if user is logged in during render
- Multiple users experiencing blank pages after login redirect
The Bug:
// components/ProtectedPage.jsx
export function ProtectedPage() {
const token = document.cookie.split('; ').find(row => row.startsWith('token='));
if (!token) {
return <Redirect to="/login" />;
}
return <Dashboard />;
}
// PROBLEM 1: Using document.cookie during render
// - Server has no access to client cookies (from request.headers.cookie)
// - Server: No token found → renders <Redirect to="/login" />
// - Client: Token in cookie → renders <Dashboard />
// - HYDRATION MISMATCH! React discards server HTML
// PROBLEM 2: Redirect doesn't work in SSR
// StaticRouter can't handle client-side navigation during hydration
Debug logs:
Browser console errors:
- "Hydration failed for: <div class="redirect">..."
- "The server could not finish this Suspense boundary"
- Text content mismatch on <div id="root">
Server logs:
- Rendered ProtectedPage as <Redirect />
- Sent 200 status with login redirect HTML
Network:
- Server responded with 200 OK (not 302 redirect)
- Page shows login form, but browser gets dashboard HTML
Root cause analysis:
// Bad cookie handling in SSR
app.get('/protected', (req, res) => {
// Server: Extract token from request headers
const token = req.headers.cookie
?.split('; ')
.find(row => row.startsWith('token='))
?.split('=')[1];
// Client: Will check document.cookie (browser's cookies)
// These are the SAME cookies, but:
// 1. Server gets them from HTTP headers
// 2. Client gets them from browser's cookie store
// 3. If they differ → hydration mismatch
// Possible causes of difference:
// - Cookie set AFTER page navigation but BEFORE hydration
// - Different cookie domains/paths on server vs client
// - Secure/HttpOnly cookies missing on client side
});
The Fix:
// server.js - Proper cookie handling
app.get('/protected', (req, res) => {
// Extract token from request
const cookieString = req.headers.cookie || '';
const token = cookieString
.split('; ')
.find(row => row.startsWith('token='))
?.split('=')[1];
// Verify token on server
const user = token ? verifyToken(token) : null;
// Pass verified auth state to client
const initialAuthState = { isAuthenticated: !!user, user };
// Server-side redirect if not authenticated
if (!token) {
res.redirect(302, '/login');
return;
}
const markup = (
<AuthProvider initialState={initialAuthState}>
<ProtectedPage />
</AuthProvider>
);
const { pipe } = renderToPipeableStream(markup);
res.write('<!DOCTYPE html>...');
res.write(`<script>
window.__AUTH_STATE__ = ${JSON.stringify(initialAuthState)};
</script>`);
pipe(res);
});
// client.js - Use server-provided auth state
import { hydrateRoot } from 'react-dom/client';
import { AuthProvider } from './AuthContext';
import App from './App';
const initialAuthState = window.__AUTH_STATE__;
hydrateRoot(
document.getElementById('root'),
<AuthProvider initialState={initialAuthState}>
<App />
</AuthProvider>
);
// AuthContext.jsx - Share auth state between server and client
import { createContext, useContext, useState } from 'react';
const AuthContext = createContext();
export function AuthProvider({ children, initialState }) {
const [auth, setAuth] = useState(initialState);
return (
<AuthContext.Provider value={{ auth, setAuth }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
// components/ProtectedPage.jsx - Use context instead of direct cookie access
export function ProtectedPage() {
const { auth } = useAuth();
// No mismatch: uses server-provided state
if (!auth.isAuthenticated) {
return <Redirect to="/login" />;
}
return <Dashboard user={auth.user} />;
}
Metrics Impact:
Before fix:
- 35% of users after login experienced hydration failure
- Page rendered login form (bad UX)
- React re-rendered entire dashboard client-side (600ms extra)
- TTFB: 150ms
- TTI: 2500ms (hydration failure caused full re-render)
After fix:
- 0% hydration failures
- Server correctly handles redirect before rendering
- Page renders correctly on first attempt
- TTFB: 150ms (unchanged)
- TTI: 1200ms (no re-render needed)
- CLS: 0 (no shifting during hydration)
Result: 52% reduction in TTI, 0 user-facing hydration errors
</details>
<details> <summary><strong>⚖️ Trade-offs: Manual SSR vs Framework Solutions</strong></summary>
Decision Matrix for SSR Implementation:
| Aspect | Manual (Express + React) | Next.js | Remix | Astro |
|---|---|---|---|---|
| Setup complexity | High (build config needed) | Low (convention over config) | Medium | Low |
| Learning curve | Steep (understand hydration) | Medium (framework abstracts) | Medium | Low |
| Bundle size | Minimal (choose deps) | Larger (built-in features) | Medium | Smallest |
| Flexibility | Maximum (full control) | Limited (conventions) | High | Medium |
| Data fetching | Manual (fetch + pass props) | Built-in (getServerSideProps) | Loaders | Built-in (Astro.props) |
| API routes | Manual (separate server) | Built-in (/api routes) | Built-in | Endpoints |
| Streaming SSR | Possible (renderToPipeableStream) | Automatic | Automatic | Automatic |
| Development speed | Slower | Fastest | Fast | Fast |
| Production ready | Yes (but requires work) | Yes (out of box) | Yes (out of box) | Yes (out of box) |
| Database integration | Custom | Custom | Custom | Custom |
| Deployment | Any Node.js host | Vercel optimal | Any Node.js host | Static (Netlify, etc.) |
Performance comparison (identical app):
Manual Express Implementation:
- Initial HTML size: 45KB
- JavaScript bundle: 120KB
- Server render time: 350ms
- TTFB: 400ms
- FCP: 450ms
- TTI: 2100ms
Next.js Implementation:
- Initial HTML size: 55KB (includes Next.js framework overhead)
- JavaScript bundle: 180KB (includes Next.js runtime)
- Server render time: 280ms (optimized)
- TTFB: 350ms
- FCP: 400ms
- TTI: 1900ms
Trade-off: Next.js is larger but faster due to optimization
Cost analysis of implementation approach:
| Factor | Manual | Next.js |
|---|---|---|
| Development hours | 200-300h (understand SSR, set up) | 50-80h (quick start) |
| Maintenance burden | High (custom hydration logic) | Low (framework handles) |
| Bug fixes for SSR issues | Internal engineers (10h/month) | Community fixes |
| Server cost | Baseline | +15% (slightly larger JS) |
| Performance tuning | Required (manual optimization) | Mostly automatic |
| Total Year 1 cost | Dev (250h × $100) + Infra = $25k | Dev (65h × $100) + Infra = $6.5k |
When to choose each approach:
-
Manual SSR (Express + React)
- Need maximum control over rendering
- Non-standard architecture (custom server)
- Learning exercise to understand hydration
- Special requirements (real-time subscriptions, custom caching)
-
Next.js
- Best for most projects (fastest dev)
- Team already knows React
- Standard app structure
- Want battery-included solution
-
Remix
- Complex forms and mutations
- Custom server logic
- Great data loading patterns
- Building web apps (not content sites)
-
Astro
- Mostly static with some dynamic islands
- Minimal JavaScript needed
- Content-heavy sites
- Best performance possible
Streaming SSR considerations:
// Manual streaming setup
import { renderToPipeableStream } from 'react-dom/server';
import { Suspense } from 'react';
function App() {
return (
<html>
<body>
<header>{/* Quick to render */}</header>
<Suspense fallback={<LoadingSpinner />}>
<SlowComponent />
</Suspense>
</body>
</html>
);
}
// Streaming benefits:
// 1. Header renders immediately (50ms)
// 2. Sent to browser immediately
// 3. Browser can render header while server renders SlowComponent (700ms)
// 4. When SlowComponent ready, rest of HTML streams to browser
// 5. User sees header almost instantly instead of waiting 750ms
// TTFB reduced from 750ms to 50ms!
// FCP improved because header visible while loading
</details>
<details> <summary><strong>💬 Explain to Junior: Understanding SSR Hydration</strong></summary>
Step-by-step SSR Hydration Process:
Think of building a house:
-
Server builds the house (renderToString/renderToPipeableStream)
- Renders entire React component tree
- Produces HTML blueprint
-
Delivers blueprint to builder (sends HTML to browser)
- Browser starts building from blueprint immediately
- User can see the house structure (fast FCP)
-
Builder adds utilities (hydration)
- Client-side React walks the HTML
- Adds electricity (event listeners)
- Adds plumbing (state management)
- House becomes fully functional
-
Critical rule: Blueprint must be accurate
- If server builds a 3-bedroom house but client expects 4-bedroom
- Builder has to demolish and rebuild (hydration mismatch)
- Wastes time and resources (defeats SSR benefits)
Common mistakes and fixes:
// ❌ MISTAKE 1: Random content (blueprint mismatch)
function Quote() {
const quotes = ['Hello', 'Hi', 'Hey'];
return <div>{quotes[Math.floor(Math.random() * 3)]}</div>;
}
// Server renders: <div>Hello</div>
// Client might expect: <div>Hi</div>
// Mismatch!
// ✅ FIX: Pass specific content
function Quote({ quote }) {
return <div>{quote}</div>;
}
// ❌ MISTAKE 2: Browser-only code during render
function TimeZone() {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return <div>{tz}</div>;
}
// Works on client, crashes on server!
// ✅ FIX: Use useEffect for browser code
function TimeZone() {
const [tz, setTz] = useState('UTC');
useEffect(() => {
setTz(Intl.DateTimeFormat().resolvedOptions().timeZone);
}, []);
return <div>{tz}</div>;
}
// ❌ MISTAKE 3: Different libraries server vs client
// server.js: const md = require('markdown-it')
// client.js: const md = require('marked')
// Different HTML output = mismatch!
// ✅ FIX: Same library on both
// Use same markdown library everywhere
Interview Walkthrough:
Question: "Walk me through implementing SSR with hydration"
"First, I'd set up a Node.js server using Express. The server would:
- On each request, render the React component tree to an HTML string using
renderToPipeableStream() - Send that HTML to the browser along with JavaScript bundle and initial state
- Include initial state in a script tag so client can use same data
Then on the client:
- Instead of
createRoot().render(), I'd usehydrateRoot() - This tells React 'attach listeners to existing HTML, don't re-create it'
- React compares server HTML with what it renders, adds event listeners if they match
The critical piece is making sure server and client render identically. If I use random values or browser APIs during render, they'll produce different HTML and cause hydration mismatch.
For example, if the server renders <div>2024</div> because it's 2024 on the server, but client renders <div>2025</div> because it's now 2025, React sees a mismatch and discards the server HTML—defeating the purpose.
So I'd use techniques like:
- Passing initial data from server to client (remove render differences)
- Using
useEffectfor any code that needs browser APIs - Checking a 'mounted' flag for client-only content
Modern frameworks like Next.js handle most of this automatically, but understanding the manual process is important for debugging."
Key concepts to remember:
- Rendering: Converting React components to HTML
- Hydration: Adding interactivity (listeners) to server-rendered HTML
- Determinism: Server and client must produce identical HTML
- TTFB vs TTI: SSR improves first, hydration affects second
- Mismatch = disaster: Different content on server vs client causes re-renders
Follow-up interview questions:
-
"What happens if hydration fails?"
- React detects mismatch, logs warning
- Discards server HTML, re-renders from scratch (kills SSR benefits)
- Page becomes interactive slower (defeats purpose)
-
"Why use streaming instead of renderToString?"
- Streaming sends HTML as ready, instead of waiting for entire render
- Reduces TTFB (user sees content faster)
- Browser can parse/render while server still working
-
"How do you pass initial data to client?"
- Include in HTML as
<script>window.__INITIAL_DATA__ = {...}</script> - Client reads this data before hydrating
- Ensures client hydrates with same data server used
- Include in HTML as
-
"When would you NOT use SSR?"
- App is entirely personalized (logged-in dashboard) → CSR better
- No SEO needed
- Server resources limited
- Real-time updates needed (WebSockets)
Key Takeaways
- SSR is rendering on server to send HTML immediately, reducing Time to First Contentful Paint
- Hydration attaches event listeners to server HTML without re-rendering the DOM
- Determinism is critical — server and client must produce identical HTML
- renderToPipeableStream is better than renderToString for progressive rendering and TTFB
- Use frameworks like Next.js for SSR unless you need complete custom control
- Common bugs are hydration mismatches caused by random values, browser APIs, or different initial state
- SSR trades server cost for better SEO and initial performance — not always the right choice
- Streaming SSR significantly improves perceived performance by sending content progressively
</details>
Source: ssr-01-basics.md