Why I Studied This
I built a personal blog with React and published posts for about a month, but it never showed up in Google search. When I checked Google Search Console, I got a bizarre message: "Crawled but found no content." But there was clearly content!
After some research, I found articles saying "SPAs built with Create React App have poor SEO." They said CSR was the problem. What's SSR then? People suggested using Next.js to solve it, but I couldn't understand why.
What does "rendering pages on the server" even mean? Doesn't React run in the browser? This curiosity kicked off my SSR/CSR learning journey.
What Confused Me Initially
When I first started learning about SSR/CSR, several things puzzled me.
1. The Word "Rendering" Was Ambiguous
I understood "render" means "draw the screen," but I couldn't grasp what "server renders the screen" meant. Doesn't the server just fetch data from the database and send JSON? Doesn't the browser draw the screen?
2. Isn't React Browser-Only?
I thought React was a library that manipulates the DOM using document.getElementById('root'). But servers don't have DOM, so how can you run React on the server?
3. Why SEO Only Works Well with SSR
Aren't Google bots just browsers? If they execute JavaScript like browsers, why can't they crawl CSR apps?
4. "Hydration" Was Too Unfamiliar
The word means "adding water" literally, but are we pouring water on code? It seemed like a metaphor for "injecting" JavaScript into HTML, but I didn't know exactly what process it described.
The core issue was understanding how servers and browsers divide their responsibilities.
The Aha Moment: "The Cooking Analogy"
My first moment of understanding came from a cooking analogy I read on someone's blog.
CSR (Client Side Rendering): Restaurant (server) gives only ingredients (JSON data) → Guest (browser) cooks at home (renders)
- Pros: Less restaurant burden, guests can cook as they like
- Cons: Takes time from receiving ingredients to finishing cooking, can't smell the food from the street (no SEO)
SSR (Server Side Rendering): Restaurant (server) gives a cooked meal (complete HTML)
- Pros: Can eat immediately (fast initial load), can smell the food (SEO works)
- Cons: Heavy restaurant burden (server load), kitchen must cook for every order
This analogy clicked because mapping "rendering = cooking" made clear who does what and where. CSR means guests cook with ingredients; SSR means the kitchen cooks and delivers. This difference affects performance and SEO.
Deep Dive into CSR (Client Side Rendering)
How CSR Works
CSR is essentially "the browser creates the DOM with JavaScript." Apps built with Create React App are typical examples.
1. Browser: "GET / HTTP/1.1 (give me index.html)"
2. Server: "Here you go" (almost empty HTML file)
<!-- HTML the server sends -->
<html>
<head>
<title>My App</title>
</head>
<body>
<div id="root"></div> <!-- Empty! -->
<script src="/static/js/bundle.js"></script> <!-- 2.5MB -->
</body>
</html>
3. Browser: Starts downloading bundle.js (2.5MB, takes 2 seconds on 3G)
4. Browser: Executes bundle.js (parsing + compilation 0.5 seconds)
5. React runs ReactDOM.render(<App />, document.getElementById('root'))
6. Creates Virtual DOM → Manipulates Real DOM → Renders to screen
7. User: Finally sees the screen (3-4 seconds total elapsed)
Key point: HTML is just a shell; JavaScript creates all the UI.
How React SPAs Work
Understanding how React SPAs (the quintessential CSR approach) work helps you understand CSR itself.
Virtual DOM and Rendering
// src/App.js
function App() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
// When executed in browser
ReactDOM.render(<App />, document.getElementById('root'));
When this code runs:
- React calls
<App />component → Transforms JSX into React.createElement() calls - Creates Virtual DOM tree (JavaScript object)
- Compares with Real DOM (Diffing)
- Applies only changes to Real DOM (Reconciliation)
Initially <div id="root"></div> is empty, then React fills it when executed. That's the essence of CSR.
Client-Side Routing
// React Router example
import { BrowserRouter, Route, Link } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
<Route path="/" exact component={Home} />
<Route path="/about" component={About} />
</BrowserRouter>
);
}
When you click the /about link:
- Prevents browser default behavior (page refresh) with e.preventDefault()
- Changes only the URL using History API (window.history.pushState)
- React Router detects URL change → Renders About component
- No server request! → Fast page transition
This is what "Single Page" in SPA means. There's actually only one page; JavaScript swaps the screen content.
Code Splitting and Lazy Loading
CSR's problem was large initial bundle size. Code Splitting emerged to solve this.
// Traditional import (everything in bundle)
import HeavyComponent from './HeavyComponent';
// Lazy import (load only when needed)
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
);
}
This approach:
- Initial bundle: 500KB (App + Router + essential components)
- HeavyComponent bundle: 1.5MB (loads when needed)
- Reduces initial load time!
But the problem of first page being empty HTML still remains.
Advantages of CSR
1. Minimal Server Burden
The server just needs to serve static files (HTML, JS, CSS, images) with nginx or Apache. If you host on a CDN, the server barely does anything.
# nginx.conf
server {
listen 80;
root /var/www/html;
location / {
try_files $uri /index.html; # All requests to index.html
}
}
Since users' browsers handle rendering, even with 10,000 concurrent users, the server just transmits files.
2. Very Fast Page Transitions
// Navigate from /posts to /posts/123
<Link to="/posts/123">
<h2>{post.title}</h2>
</Link>
// On click:
// - No server request
// - No HTML re-fetch
// - Just swap components with JavaScript
// - Screen transition within 50ms
This is SPA's biggest appeal: app-like smooth UX.
3. Complete Server-Client Separation
Frontend: React App (S3 + CloudFront)
Backend: REST API (AWS Lambda)
You can completely separate frontend as static hosting and backend as API server. Each can be deployed and scaled independently.
Disadvantages of CSR
1. Slow Initial Loading (Especially Mobile)
Receive HTML (1KB) → Screen stays empty
↓
Download JS (2.5MB, 3 seconds on 3G)
↓
Parse/compile JS (1 second on mobile CPU)
↓
React executes → Creates DOM
↓
Finally see screen (5 seconds total)
Users see a white screen for 5 seconds. This is called the "White Screen of Death."
2. Critically Weak SEO
HTML that Google bot crawls:
<!-- HTML Google bot receives -->
<html>
<head>
<title>My Blog</title>
</head>
<body>
<div id="root"></div> <!-- Empty! -->
<script src="/static/js/bundle.js"></script>
</body>
</html>
Google bot thinks "this page has no content" and leaves. While Google bot does execute JavaScript nowadays:
- Lower execution priority (to save server resources)
- Doesn't execute all JS
- Doesn't follow dynamic loading well
Result: Lower search rankings.
3. Social Media Sharing Doesn't Work
<!-- HTML Facebook/Twitter scrapes -->
<html>
<head>
<meta property="og:title" content="???" />
<meta property="og:image" content="???" />
</head>
<body>
<div id="root"></div>
</body>
</html>
OG (Open Graph) tags are empty, so sharing links on KakaoTalk/Slack shows no thumbnail.
Deep Dive into SSR (Server Side Rendering)
How SSR Works
SSR is the concept of "server executes React to create HTML."
1. Browser: "GET / HTTP/1.1"
2. Server: Runs React in Node.js
- ReactDOMServer.renderToString(<App />)
- Result: HTML string
3. Server: "Here's the complete HTML"
<!-- HTML the server sends -->
<html>
<head>
<title>My Blog</title>
</head>
<body>
<div id="root">
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main>
<h1>Welcome to My Blog</h1>
<p>This content is server-rendered!</p>
</main>
</div>
<script src="/static/js/bundle.js"></script>
</body>
</html>
4. Browser: Receives HTML and immediately renders → Screen visible! (0.3s)
5. Downloads bundle.js (background)
6. Hydration: Attaches event listeners
7. Becomes interactive
Key point: HTML already has full content. Browser just needs to render it immediately.
React's Server-Side Rendering Mechanism
I thought React was browser-only, so how can it run on the server?
ReactDOMServer.renderToString
// server.js (Node.js)
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import App from './App';
const app = express();
app.get('*', (req, res) => {
// Convert React component to HTML string
const html = ReactDOMServer.renderToString(<App />);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>SSR App</title>
</head>
<body>
<div id="root">${html}</div>
<script src="/bundle.js"></script>
</body>
</html>
`);
});
app.listen(3000);
What renderToString does:
- Executes
<App />component - Creates Virtual DOM
- Converts Virtual DOM to HTML string
- Doesn't attach event listeners (just markup)
This works because React was designed "platform-independent." React Core doesn't need to know about DOM; ReactDOM handles DOM. On servers, ReactDOMServer just creates HTML strings.
Streaming SSR (React 18's renderToPipeableStream)
The traditional renderToString problem was having to build the entire HTML before sending a response.
// Before: renderToString (blocking)
const html = ReactDOMServer.renderToString(<App />);
// Wait until all components render (e.g., 2 seconds)
// Only then send response
res.send(html); // Send all at once after 2 seconds
React 18's renderToPipeableStream supports streaming.
// After: renderToPipeableStream (streaming)
import { renderToPipeableStream } from 'react-dom/server';
app.get('*', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
onShellReady() {
// Start sending as soon as shell is ready
res.setHeader('Content-Type', 'text/html');
pipe(res);
}
});
});
// Stream HTML in chunks
// User sees first screen in 0.3s,
// Rest fills in as it loads
This significantly improved SSR performance.
Advantages of SSR
1. Fast Initial Loading (Time To View)
Receive HTML (50KB) → Immediately render screen (0.3s)
↓
Download JS (background)
↓
Hydration → Interactive
Users see "something meaningful" in 0.3 seconds. Huge difference compared to CSR's 5 seconds.
TTV (Time To View): When users see meaningful content. SSR is overwhelmingly faster.
2. SEO Champion
<!-- HTML Google bot receives (SSR) -->
<html>
<head>
<title>Clean Code Principles Summary | Codemapo</title>
<meta name="description" content="Variable naming, function writing..." />
<meta property="og:image" content="/images/clean-code.png" />
</head>
<body>
<h1>Clean Code Principles Summary</h1>
<p>Clean Code means readable code...</p>
<p>First principle is meaningful variable names...</p>
</body>
</html>
With full content:
- Google understands page content → Indexes keywords → Search exposure
- Social media reads OG tags → Generates thumbnails
- Higher search rankings
SSR is well-known to be beneficial for SEO. There are many reported cases of significant improvements in search visibility after switching from CSR to SSR.
3. Fast Even on Low-End Devices
CSR requires browser CPU to execute JavaScript, which is slow on low-end phones. SSR has the server pre-build HTML, so browsers just render (rendering is well-optimized in browsers).
Disadvantages of SSR
1. Heavy Server Burden
// If 100 concurrent requests come in
// Node.js server runs React 100 times
// CPU 100% → Response delay
// Solution: Caching
import NodeCache from 'node-cache';
const cache = new NodeCache({ stdTTL: 60 });
app.get('/posts/:id', (req, res) => {
const cached = cache.get(req.params.id);
if (cached) {
return res.send(cached);
}
const html = renderToString(<Post id={req.params.id} />);
cache.set(req.params.id, html);
res.send(html);
});
Caching helps somewhat, but server load is still higher than CSR.
2. Slow TTFB (Time To First Byte)
CSR:
Browser request → nginx reads and sends index.html
TTFB: 50ms (just file reading)
SSR:
Browser request → Node.js runs React → Generates HTML → Sends
TTFB: 300ms (React execution time)
TTFB is slower, but TTV is faster. Trade-off.
3. Server vs Browser Environment Differences
// This code errors on server
function MyComponent() {
const width = window.innerWidth; // ReferenceError: window is not defined
return <div>Width: {width}</div>;
}
// Solution: Run only in browser
function MyComponent() {
const [width, setWidth] = useState(0);
useEffect(() => {
setWidth(window.innerWidth); // Runs only in browser
}, []);
return <div>Width: {width}</div>;
}
Servers don't have window, document, localStorage, etc., so you must be careful.
Understanding Hydration Deeply
Hydration is a core SSR concept that was initially hard to understand.
What Is Hydration?
Hydration: The process of "breathing life" into static HTML created by the server. Specifically, attaching event listeners and starting state management.
<!-- HTML sent by server (static) -->
<button>Click me</button>
<!-- This button doesn't do anything when clicked yet -->
// Client-side Hydration
import { hydrateRoot } from 'react-dom/client';
function App() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Click me</button>;
}
// Run Hydration
hydrateRoot(document.getElementById('root'), <App />);
// Now button click increments count!
What hydrateRoot does:
- Preserves HTML created by server as-is
- React creates Virtual DOM
- Matches existing HTML with Virtual DOM
- Attaches event listeners (
onClick, etc.) - Starts React state management
Hydration Mismatch Error
A common SSR error is "Hydration mismatch."
// Server rendering
function ServerTime() {
const now = new Date().toISOString();
return <div>Server time: {now}</div>;
}
// Server: "2025-06-21T10:30:00Z"
// Client Hydration: "2025-06-21T10:30:05Z" (5 second difference)
// → Hydration mismatch! Warning triggered
This happens when HTML generated by server and client differ.
Solution:
function ServerTime() {
const [now, setNow] = useState(null);
useEffect(() => {
setNow(new Date().toISOString()); // Runs only on client
}, []);
if (!now) {
return <div>Loading...</div>; // Server and client first render match
}
return <div>Client time: {now}</div>;
}
Selective Hydration (React 18)
React 18's "Selective Hydration" significantly improved performance.
// Before: Hydrate entire tree at once (blocking)
hydrateRoot(document, <App />);
// Nothing interactive until all components hydrated
// After: Prioritize with Suspense
function App() {
return (
<div>
<Header /> {/* Hydrate immediately */}
<Suspense fallback={<Spinner />}>
<Comments /> {/* Hydrate later */}
</Suspense>
</div>
);
}
A "smart" approach that prioritizes hydrating parts users interact with first.
SSG (Static Site Generation): The Fastest Method
Between SSR and CSR, there's another approach. SSG (Static Site Generation): Pre-building HTML at build time.
How SSG Works
# Build time
npm run build
→ Next.js pre-renders all pages
→ /out/index.html (complete HTML)
→ /out/posts/1.html (complete HTML)
→ /out/posts/2.html (complete HTML)
# Runtime
User request → nginx serves static HTML
→ Zero server burden, maximum speed
Using SSG in Next.js
// pages/posts/[id].js
export async function getStaticProps({ params }) {
// Runs once at build time
const post = await fetchPost(params.id);
return {
props: { post },
revalidate: 60, // ISR: Regenerate every 60 seconds
};
}
export async function getStaticPaths() {
// Specify which pages to pre-build
const posts = await fetchAllPosts();
return {
paths: posts.map(p => ({ params: { id: p.id } })),
fallback: 'blocking', // Generate missing pages on request
};
}
function Post({ post }) {
return <article>{post.content}</article>;
}
ISR (Incremental Static Regeneration)
ISR solves SSG's disadvantage (no updates after build).
export async function getStaticProps() {
const data = await fetch('https://api.example.com/data');
return {
props: { data },
revalidate: 10, // Regenerate in background every 10 seconds
};
}
How it works:
- User request → Instantly serve cached page (fast!)
- If 10 seconds passed, regenerate in background
- Next user gets new page
Captures both "static page speed + dynamic data freshness."
React Server Components (RSC): New Paradigm
React Server Components, released in 2023, advanced SSR one step further.
What Are RSC?
React Server Components: Components that run only on server and don't send JavaScript bundles to client.
// app/page.js (Server Component - default)
async function HomePage() {
// Runs only on server (direct database access possible)
const posts = await db.query('SELECT * FROM posts');
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
// This component's JavaScript is NOT sent to client!
// Bundle size: 0KB
Server Component vs Client Component
// app/PostList.server.js (Server Component)
async function PostList() {
const posts = await db.posts.findMany(); // Direct DB access
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
// Not included in client bundle
// app/LikeButton.client.js (Client Component)
'use client'; // Explicitly declare client component
export function LikeButton() {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
);
}
// Included in client bundle (needs to be interactive)
Key point: Non-interactive parts as Server Components, only necessary parts as Client Components.
Advantages of RSC
// Before: Traditional SSR
// - Server generates HTML
// - Client downloads all component code (2MB)
// - Hydration
// After: RSC
// - Server generates HTML
// - Client downloads only Client Components (200KB)
// - Hydrate only Client Components
Bundle size reduced by 90%! That's RSC's core value.
Next.js: App Router vs Pages Router
Next.js provides two routers.
Pages Router (Traditional Approach)
pages/
index.js → / (choose SSR/SSG/CSR)
about.js → /about
posts/[id].js → /posts/:id
// pages/posts/[id].js
export async function getServerSideProps({ params }) {
// SSR: Runs on every request
const post = await fetchPost(params.id);
return { props: { post } };
}
// Or
export async function getStaticProps({ params }) {
// SSG: Runs at build time
const post = await fetchPost(params.id);
return { props: { post } };
}
function PostPage({ post }) {
return <article>{post.content}</article>;
}
App Router (Next.js 13+, RSC Support)
app/
page.js → / (default Server Component)
about/page.js → /about
posts/[id]/page.js → /posts/:id
// app/posts/[id]/page.js (Server Component)
async function PostPage({ params }) {
// Async components possible! (runs only on server)
const post = await fetchPost(params.id);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton /> {/* Client Component */}
</article>
);
}
// app/LikeButton.js (Client Component)
'use client';
export function LikeButton() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Like {count}</button>;
}
Difference:
- Pages Router: Data fetching with
getServerSideProps - App Router: Components themselves are async, fetch directly on server
App Router is more intuitive, and smaller bundle size thanks to RSC.
Performance Metrics: Impact of SSR/CSR
Let me summarize key web performance metrics and SSR/CSR's impact.
Core Web Vitals
Google's 3 critical metrics:
1. LCP (Largest Contentful Paint)
"Time when largest content renders on screen"
CSR: 3.5s (after JS loads and renders)
SSR: 0.8s (already in HTML)
SSG: 0.3s (cached HTML)
Goal: Within 2.5 seconds
SSR/SSG overwhelmingly better.
2. FID (First Input Delay) → INP (Interaction to Next Paint)
"Delay from user input to response"
CSR: 300ms (after Hydration completes)
SSR: 200ms (faster Hydration)
SSG: 150ms
Goal: Within 100ms
3. CLS (Cumulative Layout Shift)
"How much layout shifts during page load"
CSR: 0.15 (content suddenly appears after JS loads → layout shift)
SSR: 0.05 (already in HTML, less shift)
Goal: Below 0.1
Other Important Metrics
FCP (First Contentful Paint)
"Time when first content renders on screen"
CSR: 2.5s (after JS loads)
SSR: 0.5s (HTML immediately)
TTI (Time To Interactive)
"Time when page becomes fully interactive"
CSR: 3.8s (JS load + execution)
SSR: 2.5s (Hydration)
SSG: 2.0s
Real Deployment Environment Comparison
Comparing actual deployment methods and costs for each rendering approach.
1. CSR Deployment: S3 + CloudFront
# Build
npm run build # → Creates static files in build/ folder
# Upload to S3
aws s3 sync build/ s3://my-bucket --delete
# Deploy with CloudFront CDN
# → Served quickly from worldwide edges
Cost:
- S3: $0.023/GB (storage) + $0.09/GB (transfer)
- CloudFront: $0.085/GB
- 10K monthly visitors: ~$5
Pros:
- Very cheap
- Infinitely scalable (CDN handles traffic spikes)
- Simple deployment
Cons:
- Weak SEO
- Slow initial load
2. SSR Deployment: Vercel
# Deploy Next.js project to Vercel
vercel deploy
# Vercel automatically:
# - Runs Node.js server
# - Distributes to edge network
# - Auto-scales
Cost:
- Hobby plan: Free (personal projects)
- Pro plan: $20/month (commercial)
Pros:
- Supports SSR/SSG/ISR all
- Super simple deployment
- Auto-scaling
Cons:
- More expensive than CSR
- Vendor lock-in to Vercel
3. SSG Deployment: Cloudflare Pages
# Build Next.js SSG
next build && next export
# Deploy to Cloudflare Pages
wrangler pages publish out/
Cost:
- Free (500 builds/month, unlimited traffic!)
Pros:
- Almost free
- Fast CDN speed
- More stable than SSR (because static)
Cons:
- Dynamic data must be fetched on client
4. Self-Hosted SSR: AWS ECS
# Dockerfile
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build
CMD ["npm", "start"]
# Build Docker image and deploy to ECS
docker build -t my-app .
aws ecr push my-app
aws ecs update-service --service my-app
Cost:
- ECS Fargate: $0.04/vCPU/hour + $0.004/GB/hour
- ALB: $16/month + $0.008/LCU-hour
- 10K monthly visitors: ~$50
Pros:
- Full control
- No vendor lock-in
Cons:
- Complex
- Expensive
Real Code Examples
Next.js Pages Router: SSR vs SSG
// pages/posts/[id].js
// Method 1: SSR (server render on every request)
export async function getServerSideProps({ params, req, res }) {
// Runs on every request
console.log('Running SSR...');
// Optional caching headers
res.setHeader(
'Cache-Control',
'public, s-maxage=10, stale-while-revalidate=59'
);
const post = await fetch(`https://api.example.com/posts/${params.id}`)
.then(res => res.json());
if (!post) {
return { notFound: true };
}
return {
props: {
post,
renderedAt: new Date().toISOString(),
},
};
}
// Method 2: SSG (once at build time)
export async function getStaticProps({ params }) {
// Runs once at build time
console.log('Running SSG...');
const post = await fetch(`https://api.example.com/posts/${params.id}`)
.then(res => res.json());
return {
props: { post },
revalidate: 60, // ISR: Regenerate every 60 seconds
};
}
export async function getStaticPaths() {
// Specify which paths to pre-generate
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json());
return {
paths: posts.map(p => ({ params: { id: String(p.id) } })),
fallback: 'blocking', // Generate missing paths on request
};
}
// Component
function PostPage({ post, renderedAt }) {
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{renderedAt && <small>Rendered at: {renderedAt}</small>}
</article>
);
}
export default PostPage;
Next.js App Router: Server Component + Client Component
// app/posts/[id]/page.js (Server Component)
import { Suspense } from 'react';
import { LikeButton } from './LikeButton';
import { Comments } from './Comments';
// Server components can be async!
async function PostPage({ params }) {
// Direct database access (runs only on server)
const post = await db.posts.findUnique({
where: { id: params.id },
});
if (!post) {
notFound(); // Go to 404 page
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* Client Component: Interactive */}
<LikeButton postId={post.id} />
{/* Suspense for loading */}
<Suspense fallback={<div>Loading comments...</div>}>
<Comments postId={post.id} />
</Suspense>
</article>
);
}
export default PostPage;
// app/posts/[id]/LikeButton.js (Client Component)
'use client'; // Explicitly client component
import { useState } from 'react';
export function LikeButton({ postId }) {
const [liked, setLiked] = useState(false);
const [count, setCount] = useState(0);
const handleLike = async () => {
setLiked(!liked);
// API call
const res = await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
});
const data = await res.json();
setCount(data.likeCount);
};
return (
<button onClick={handleLike}>
{liked ? '❤️' : '🤍'} {count}
</button>
);
}
// app/posts/[id]/Comments.js (Server Component)
async function Comments({ postId }) {
// Data fetching on server
const comments = await db.comments.findMany({
where: { postId },
});
return (
<div>
<h3>Comments</h3>
{comments.map(comment => (
<div key={comment.id}>
<strong>{comment.author}</strong>
<p>{comment.content}</p>
</div>
))}
</div>
);
}
Hybrid: Different Approaches per Page
// Next.js project structure
pages/
index.js // SSG (main page, rarely changes)
blog/[slug].js // SSG + ISR (blog posts)
dashboard/index.js // CSR (requires login, no SEO needed)
products/[id].js // SSR (real-time stock info)
// pages/index.js (SSG)
export async function getStaticProps() {
return {
props: {
posts: await fetchLatestPosts(),
},
revalidate: 3600, // Regenerate every hour
};
}
// pages/blog/[slug].js (SSG + ISR)
export async function getStaticProps({ params }) {
return {
props: {
post: await fetchPost(params.slug),
},
revalidate: 60, // Regenerate every minute
};
}
// pages/dashboard/index.js (CSR)
function Dashboard() {
const [data, setData] = useState(null);
useEffect(() => {
// Fetch data only on client
fetch('/api/user/dashboard')
.then(res => res.json())
.then(setData);
}, []);
if (!data) return <div>Loading...</div>;
return <div>{data.username}'s Dashboard</div>;
}
// No getServerSideProps = CSR
// pages/products/[id].js (SSR)
export async function getServerSideProps({ params }) {
// Latest stock info on every request
return {
props: {
product: await fetchProduct(params.id),
},
};
}
My Real Experience: Blog Migration
Let me summarize my experience migrating my blog from CSR (Create React App) → SSR (Next.js).
Before: Blog Built with CRA
Tech stack:
- Create React App
- React Router
- Markdown parsing (client-side)
- S3 + CloudFront deployment
Problems:
1. Not showing in Google search (critical)
2. Initial load 3.5s (5s on mobile)
3. No thumbnail when sharing links
4. Lighthouse SEO score: 45
After: Migrated to Next.js
Tech stack:
- Next.js 14 (App Router)
- Server Components
- MDX (server-side parsing)
- Vercel deployment
Improvements:
1. Google search traffic 0 → 100 daily visitors (within 2 weeks)
2. Initial load 0.8s (4x faster)
3. OG images working properly
4. Lighthouse SEO score: 100
5. All Core Web Vitals passing
What I Learned During Migration
1. Not Every Page Needs SSR
// Blog posts: SSG + ISR (fastest)
export const revalidate = 3600;
// Search page: CSR (no SEO needed)
'use client';
// Real-time comments: Server Component (SEO needed)
async function Comments() { }
2. Image Optimization Matters
// Before
<img src="/images/cover.jpg" /> // 2MB original
// After
import Image from 'next/image';
<Image
src="/images/cover.jpg"
width={800}
height={400}
alt="Cover"
loading="lazy"
/>
// Next.js auto converts to WebP, resizes, lazy loads
3. Reduce Bundle Size
// Before: All libraries in main bundle
import { format } from 'date-fns'; // Import entire library
// After: Only what's needed
import format from 'date-fns/format'; // Tree-shaking
// Result: Bundle size 2.5MB → 800KB
When to Use What?
The conclusion I reached was "it depends on the situation."
Use CSR When
1. SEO Not Needed
Examples:
- Admin dashboard
- Internal tools
- Pages behind login
Reason: If Google exposure isn't needed anyway, CSR is simplest and cheapest.
2. Real-Time Interaction Is Critical
Examples:
- Chat apps
- Real-time collaboration tools (Figma, Notion)
- Games
Reason: SSR only speeds up initial load; subsequent interactions happen on client anyway. Starting with CSR is simpler.
3. Want to Minimize Server Costs
For 100K monthly visitors:
- CSR (S3 + CloudFront): $10
- SSR (Vercel Pro): $20
- SSR (self-hosted): $100
Use SSR When
1. SEO Is Essential
Examples:
- Blogs
- News sites
- E-commerce (product pages)
- Corporate websites
Reason: If Google search traffic is critical, SSR is essential.
2. Social Media Sharing Matters
<!-- OG tags must be server-generated -->
<meta property="og:title" content="Actual title" />
<meta property="og:image" content="Actual image" />
Need thumbnails when sharing on KakaoTalk/Slack? Use SSR.
3. Initial Load Speed Is Very Important
Examples:
- News sites (so readers don't leave immediately)
- Landing pages (directly affects conversion)
- Mobile web (low-end devices)
Use SSG When
1. Content Rarely Changes
Examples:
- Documentation sites
- Blogs (posts edited infrequently)
- Portfolios
- Marketing pages
Reason: Fastest, cheapest, most stable.
2. Same Content for All Users
If no personalization needed, SSG is best.
3. Capture "Static + Dynamic" with ISR
export const revalidate = 60; // Regenerate every minute
// First user: Cached page (fast)
// User after 1 min: Background regeneration → Fresh data
Can capture both "static page speed + dynamic data freshness."
Summary
Let me summarize the core lessons I learned while writing this.
1. CSR vs SSR Is a Trade-off
SSR isn't unconditionally better. Each has clear pros and cons.
CSR:
✅ Low server burden, simple deployment, cheap
❌ Weak SEO, slow initial load
SSR:
✅ Strong SEO, fast initial load
❌ Heavy server burden, complex deployment, expensive
SSG:
✅ Fastest, cheap, stable
❌ Limited dynamic data
2. Hybrid Is the Realistic Answer
Next.js is popular because "you can use both."
// Different per page
pages/
index.js → SSG (main page)
blog/[slug].js → SSG + ISR (blog)
dashboard.js → CSR (dashboard)
products/[id].js → SSR (real-time stock)
Mixing approaches based on use case in one project was the answer.
3. Measure Performance with Numbers
"Fast/slow" is subjective. Lighthouse measurement is objective.
Metrics:
- LCP: Within 2.5s
- FID: Within 100ms
- CLS: Below 0.1
- Lighthouse Performance: Above 90
4. First Ask If SEO Is Needed
When starting a project:
Q1. Need Google search exposure?
→ YES: Consider SSR/SSG
→ NO: CSR is enough
Q2. Is social media sharing important?
→ YES: SSR/SSG
→ NO: CSR
Q3. Is initial load very important?
→ YES: SSR/SSG
→ NO: CSR is fine
Answering these questions makes the choice clear.
5. Future Is Moving Toward RSC
I understood React Server Components as a game changer.
Before: All components in client bundle
After: Only interactive ones in client bundle
Result: 90% bundle size reduction possible
Major frameworks like Next.js App Router and Remix adopted RSC. Studying in this direction felt right.
6. No Perfect Solution
Ultimately "there's no right answer" was the right answer.
- Low traffic? Start with CSR, switch to SSR when needed
- Blog uses SSG, search feature uses CSR
- Product list uses SSG, product details use SSR
Flexibly choosing based on project situation is key.