
Next.js 렌더링 완전정복: CSR, SSR, SSG, ISR, 그리고 RSC
싱글 페이지 애플리케이션(SPA)의 SEO 문제를 해결하기 위해 등장한 SSR. 그리고 정적 생성(SSG)과 점진적 재생성(ISR)의 진화. 이제는 서버 컴포넌트(RSC) 시대.

싱글 페이지 애플리케이션(SPA)의 SEO 문제를 해결하기 위해 등장한 SSR. 그리고 정적 생성(SSG)과 점진적 재생성(ISR)의 진화. 이제는 서버 컴포넌트(RSC) 시대.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

제가 처음 React로 회사 홈페이지를 만들었을 때입니다. 사장님이 물었습니다. "김 대리, 구글에 우리 회사 검색해도 왜 안 나와?" "네? 그럴 리가요..."
소스 보기를 해보고 충격받았습니다.
<body>
<div id="root"></div>
<script src="bundle.js"></script>
</body>
내용이 하나도 없었습니다. 구글 봇은 빈 껍데기만 보고 떠난 겁니다. 이게 바로 CSR(Client Side Rendering)의 SEO 문제입니다.
당시 저는 Create React App(CRA)으로 홈페이지를 만들었습니다. React는 SPA(Single Page Application)니까 당연히 모던하고 빠를 거라고 생각했거든요. 근데 현실은 달랐습니다. 구글 검색 결과에 우리 홈페이지가 안 뜨는 건 둘째치고, 카카오톡에 링크를 공유했는데 미리보기 이미지도 안 떴습니다. Open Graph 메타 태그를 아무리 넣어도 소용없었습니다. 왜냐면 그 메타 태그도 JavaScript가 실행되고 나서야 DOM에 주입되거든요.
이 문제를 해결하러 Next.js가 등장했습니다. 그리고 저는 지난 1년간 CRA 프로젝트를 Next.js로 마이그레이션하면서 렌더링 전략의 중요성을 뼈저리게 이해했습니다.
처음 Next.js를 배울 때 가장 헷갈렸던 게 용어였습니다. SSR, SSG, ISR, CSR, 그리고 최근엔 RSC까지. 알파벳 수프를 먹는 기분이었습니다.
"Next.js는 SSR 프레임워크다"라는 말을 듣고 공부를 시작했는데, 공식 문서를 보니 getStaticProps라는 함수가 나왔습니다. "어? 이건 SSG 아닌가? 그럼 Next.js는 SSG 프레임워크인가?" 또 다른 페이지에선 getServerSideProps를 쓰라고 하고. 심지어 어떤 블로그는 "Next.js로 CSR도 할 수 있다"고 하더군요.
결국 이거였습니다. Next.js는 하이브리드 프레임워크입니다. 페이지마다, 아니 컴포넌트마다 다른 렌더링 전략을 선택할 수 있습니다. 이 유연함이 Next.js의 핵심이었는데, 처음엔 이게 혼란의 원인이었습니다.
그래서 저는 이렇게 받아들였습니다. "Next.js는 렌더링 전략의 자판기다. 상황에 맞는 음료(전략)를 고르면 된다."
이 모든 혼란이 정리된 순간은 이 질문에 답했을 때였습니다.
"서버는 언제 HTML을 만드는가?"이 시간 축을 머릿속에 그리니까 모든 게 명확해졌습니다. 그리고 각각의 트레이드오프도 이해했습니다.
CSR (Create React App)
빌드: HTML 껍데기 생성 → 배포 → 사용자 접속 → JS 다운로드 → React 실행 → DOM 생성
서버 부하: ☆☆☆☆☆ (거의 없음, S3만 있으면 됨)
첫 화면: ★☆☆☆☆ (느림, JS 실행까지 흰 화면)
SEO: ★☆☆☆☆ (검색봇은 빈 페이지만 봄)
SSG (getStaticProps)
빌드: DB 조회 → HTML 생성 → 배포 → 사용자 접속 → HTML 바로 표시
서버 부하: ☆☆☆☆☆ (빌드 타임에만 부하, 이후엔 CDN)
첫 화면: ★★★★★ (가장 빠름)
SEO: ★★★★★ (완벽)
단점: 데이터 변경 시 재배포 필요
SSR (getServerSideProps)
사용자 접속 → 서버 DB 조회 → HTML 생성 → 전송 → 화면 표시
서버 부하: ★★★★☆ (요청마다 DB 조회)
첫 화면: ★★★☆☆ (DB 레이턴시만큼 대기)
SEO: ★★★★★ (완벽)
장점: 항상 최신 데이터
ISR (getStaticProps + revalidate)
빌드: HTML 생성 → 사용자 접속 → 캐시된 HTML 표시 → 백그라운드 재생성
서버 부하: ★★☆☆☆ (주기적으로만)
첫 화면: ★★★★★ (SSG처럼 빠름)
SEO: ★★★★★ (완벽)
장점: 속도 + 신선도 균형
이 도표를 그리고 나서야 "아, 그래서 블로그는 SSG를 쓰는구나"라고 무릎을 쳤습니다. 글 내용이 바뀔 일이 거의 없으니까요. 반면 쇼핑몰 상품 페이지는 ISR이 적합합니다. 가격이나 재고는 변하지만, 매 요청마다 DB 조회할 필요는 없으니까요.
제가 처음 React를 배웠을 때 느낀 마법 같은 경험이 있습니다. 페이지가 깜빡이지 않았습니다. PHP나 JSP로 만든 사이트는 링크를 클릭하면 전체 페이지가 새로고침되면서 하얀 화면이 잠깐 나타났거든요. 그런데 React는 부드럽게 넘어갔습니다.
이게 바로 CSR의 매력입니다. 서버는 그냥 빈 HTML을 던져주고, JavaScript가 브라우저에서 모든 UI를 그립니다.
// React (CSR)의 동작 원리
// 1. 서버가 보내는 HTML (거의 비어있음)
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
</head>
<body>
<div id="root"></div>
<script src="/static/js/bundle.js"></script>
</body>
</html>
// 2. bundle.js가 실행되면
ReactDOM.render(<App />, document.getElementById('root'));
// 3. 이제야 DOM이 생성됨
<div id="root">
<header>Welcome to my site</header>
<main>Content here...</main>
</div>
구글봇(크롤러)은 JavaScript 실행 능력이 제한적입니다. 2023년 기준으론 많이 개선됐다지만, 여전히 느리고 불완전합니다. 특히 페이스북, 트위터 같은 소셜 미디어 크롤러는 JS를 거의 실행하지 못합니다.
제가 만든 홈페이지를 카카오톡에 공유했을 때 이런 미리보기가 나왔습니다.
Title: React App
Description: (없음)
Image: (없음)
왜냐면 Open Graph 메타 태그를 React 컴포넌트 안에서 react-helmet으로 넣었거든요. 카카오톡 크롤러는 그걸 못 읽었습니다.
제 회사 홈페이지 번들 크기가 1.2MB였습니다. 압축(gzip)해도 400KB. 3G 환경에서 다운로드하는 데만 3초 걸렸습니다. 게다가 다운로드 후에도 JavaScript를 파싱하고 실행해야 하니까 사용자는 5초 이상 흰 화면을 봤습니다.
Google의 Core Web Vitals 기준으로 LCP(Largest Contentful Paint)는 2.5초 이내여야 하는데, 저는 5초를 넘겼습니다. 검색 순위에서 불리할 수밖에 없었습니다.
"그럼 서버에서 미리 HTML을 만들어서 보내면 되잖아?" 이게 Pre-rendering의 핵심입니다. Next.js가 이걸 쉽게 해줍니다.
블로그 글 같은 정적 콘텐츠에 최적화된 방식입니다. 빌드할 때 모든 HTML을 미리 생성합니다.
// pages/posts/[id].js
export async function getStaticPaths() {
// 어떤 페이지들을 미리 만들지 정의
const posts = await fetchAllPostIds(); // [1, 2, 3, ..., 100]
return {
paths: posts.map(id => ({ params: { id: String(id) } })),
fallback: false // 정의 안 된 id는 404
};
}
export async function getStaticProps({ params }) {
// 빌드 타임에 실행됨 (사용자 요청 시 X)
const post = await fetchPost(params.id);
return {
props: { post }
};
}
export default function Post({ post }) {
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
빌드 로그 예시:
$ npm run build
> Building...
Page Size First Load JS
┌ ● /posts/1 2.1 kB 85 kB
├ ● /posts/2 2.0 kB 85 kB
├ ● /posts/3 2.2 kB 85 kB
...
└ ● /posts/100 2.1 kB 85 kB
● (SSG) automatically generated as static HTML
100개의 HTML 파일이 미리 생성됩니다. 사용자가 /posts/1에 접속하면 서버는 이미 만들어진 posts/1.html을 그냥 던져주기만 하면 됩니다. 초고속입니다.
요청이 들어올 때마다 서버가 HTML을 생성합니다.
// pages/dashboard.js
export async function getServerSideProps(context) {
// 매 요청마다 실행됨!
const session = await getSession(context);
if (!session) {
return {
redirect: {
destination: '/login',
permanent: false,
}
};
}
// DB 조회 (항상 최신 데이터)
const userData = await db.user.findUnique({
where: { id: session.user.id },
include: { orders: true }
});
return {
props: { userData }
};
}
export default function Dashboard({ userData }) {
return (
<div>
<h1>안녕하세요, {userData.name}님</h1>
<ul>
{userData.orders.map(order => (
<li key={order.id}>{order.product}</li>
))}
</ul>
</div>
);
}
실행 흐름:
사용자 접속 (10:23:15) → getServerSideProps 실행 → DB 조회 (50ms) → HTML 생성 → 응답
사용자 접속 (10:23:20) → getServerSideProps 실행 → DB 조회 (50ms) → HTML 생성 → 응답
사용자 접속 (10:23:25) → getServerSideProps 실행 → DB 조회 (50ms) → HTML 생성 → 응답
매번 DB를 조회하니까 서버 부하가 큽니다. 그래서 처음에 "모든 페이지를 SSR로 해야 하나?"라는 생각이 들지만, 그러면 서버비가 폭탄처럼 나옵니다. AWS Lambda로 배포하면 트래픽이 많아질수록 비용이 급격하게 뛰는 구조입니다.
제가 SSR을 쓰는 경우:SSG와 SSR의 혁명적 타협점입니다. Next.js 9.5에서 도입됐을 때 저는 "이거다!" 싶었습니다.
// pages/products/[id].js
export async function getStaticProps({ params }) {
const product = await fetchProduct(params.id);
return {
props: { product },
revalidate: 60 // 60초마다 재생성
};
}
export async function getStaticPaths() {
// 인기 상품 100개만 빌드 타임에 생성
const topProducts = await fetchTopProducts(100);
return {
paths: topProducts.map(id => ({ params: { id: String(id) } })),
fallback: 'blocking' // 없는 상품은 요청 시 생성
};
}
동작 방식:
시간 0초: 빌드 완료. /products/1 ~ /products/100 HTML 생성됨.
시간 10초: 사용자 A가 /products/1 접속
→ 캐시된 HTML 즉시 응답 (빠름!)
시간 70초: 사용자 B가 /products/1 접속
→ 60초 경과 → 백그라운드에서 새 HTML 생성 시작
→ 사용자 B는 여전히 **기존 캐시** 받음 (느려지지 않음!)
시간 75초: 새 HTML 생성 완료 → 캐시 교체
시간 80초: 사용자 C가 /products/1 접속
→ 업데이트된 HTML 응답
이 방식이 와닿았던 이유는 사용자는 절대 느려지지 않는다는 점입니다. SSR처럼 매번 DB 조회를 기다릴 필요가 없고, SSG처럼 배포를 다시 할 필요도 없습니다.
제가 ISR을 쓰는 경우:ISR을 쓸 때 가장 헷갈렸던 게 fallback 옵션입니다.
// fallback: false
// 정의 안 된 경로는 404
paths: ['/posts/1', '/posts/2']
→ /posts/3 접속 시 → 404
// fallback: true
// 정의 안 된 경로는 빈 페이지 보여주고, 백그라운드에서 생성
→ /posts/3 접속 시 → Loading... 표시 → 생성 완료 → 내용 표시
사용자가 기다려야 함
// fallback: 'blocking' (추천!)
// 정의 안 된 경로는 서버에서 생성 완료될 때까지 대기
→ /posts/3 접속 시 → 서버가 HTML 생성 (SSR처럼) → 완성된 페이지 응답
이후 요청은 캐시된 HTML 사용 (SSG처럼)
저는 blocking을 주로 씁니다. 사용자 경험이 더 좋거든요. true는 로딩 상태를 직접 처리해야 해서 번거롭습니다.
Next.js를 처음 쓸 때 "Hydration"이라는 단어가 계속 나왔습니다. 직역하면 "수분 보충"인데, 웹에선 무슨 의미일까요?
// 서버가 생성한 HTML (Next.js SSR/SSG)
<button id="btn">클릭하세요</button>
// 브라우저가 이 HTML을 받으면
1. 화면에 버튼이 보임 (빠름!)
2. 하지만 클릭해도 아무 일도 안 일어남 (이벤트 리스너가 없음)
3. JavaScript 다운로드 → React 실행
4. React가 DOM을 다시 순회하며 이벤트 리스너 부착
5. 이제 버튼이 작동함
4번 과정이 "Hydration"입니다.
비유하자면, 서버는 뼈대(HTML)만 보내고, 브라우저가 영혼(JavaScript)을 불어넣는 겁니다.
Hydration은 공짜가 아닙니다. React가 전체 DOM 트리를 다시 순회하면서 Virtual DOM과 매칭해야 하거든요. 번들 크기가 크면 Hydration 시간도 길어집니다.
제가 실제로 겪었던 문제:
// _app.js
import 'bootstrap/dist/css/bootstrap.min.css'; // 200KB
import 'antd/dist/antd.css'; // 500KB
import Lodash from 'lodash'; // 70KB (tree-shaking 안 됨)
import Moment from 'moment'; // 200KB (전체 locale 포함)
// 결과: 번들 크기 1.5MB
// Hydration 시간: 3초
// 사용자는 3초간 버튼을 클릭해도 반응 없음 (Frozen UI)
이걸 개선하려고 저는 다음을 했습니다.
// 1. Tree-shaking이 되는 라이브러리로 교체
import { debounce } from 'lodash-es'; // 5KB
import dayjs from 'dayjs'; // 2KB
// 2. CSS는 필요한 컴포넌트만 import
import Button from 'antd/lib/button';
import 'antd/lib/button/style/css';
// 3. Dynamic Import로 코드 분할
const HeavyChart = dynamic(() => import('../components/Chart'), {
ssr: false // 서버에선 렌더링 안 함
});
// 결과: 번들 크기 300KB로 감소
// Hydration 시간: 0.5초
Next.js를 쓰면서 가장 많이 본 에러입니다.
Warning: Text content did not match. Server: "..." Client: "..."
원인: 서버에서 렌더링한 HTML과 클라이언트에서 렌더링한 HTML이 다를 때 발생합니다.
제가 실수했던 케이스:
// ❌ 잘못된 코드
function CurrentTime() {
return <div>현재 시간: {new Date().toLocaleString()}</div>;
}
// 서버에서 렌더링: "현재 시간: 2025-01-15 14:23:10"
// 클라이언트에서 Hydration: "현재 시간: 2025-01-15 14:23:13"
// → Mismatch!
해결:
// ✅ 올바른 코드
function CurrentTime() {
const [time, setTime] = useState(null);
useEffect(() => {
setTime(new Date().toLocaleString());
}, []);
// 서버와 클라이언트 첫 렌더링은 동일 (null)
return <div>현재 시간: {time || '로딩 중...'}</div>;
}
useEffect는 클라이언트에서만 실행되니까 Hydration mismatch가 안 생깁니다.
Next.js 개발 모드에서 console.log를 찍으면 두 번 출력됩니다.
useEffect(() => {
console.log('API 호출!');
fetchData();
}, []);
// 콘솔:
// API 호출!
// API 호출!
이거 때문에 저는 "버그인가?"라고 생각했는데, React 18의 Strict Mode 때문입니다. 개발 모드에서 컴포넌트를 의도적으로 두 번 마운트해서 부작용(side-effects)이 안전한지 검증합니다.
프로덕션 빌드에선 한 번만 실행됩니다. 하지만 개발 중에 API가 두 번 호출되는 게 신경 쓰이면:
// next.config.js
module.exports = {
reactStrictMode: false // Strict Mode 끄기 (비추천)
};
하지만 저는 그냥 받아들였습니다. 두 번 호출돼도 문제없게 코드를 짜는 게 맞다고 생각하거든요 (멱등성).
Next.js 13에서 App Router가 도입되면서 React Server Components(RSC)가 나왔습니다. 이건 정말 패러다임 전환이었습니다.
기존 Next.js SSR은 이렇게 작동합니다.
1. 서버: React 컴포넌트를 실행 → HTML 생성 → 브라우저에 전송
2. 브라우저: HTML 표시 (빠름!)
3. 브라우저: JavaScript 번들 다운로드 (200KB~2MB)
4. 브라우저: React 재실행 → Hydration
5. 이제 상호작용 가능
문제는 3번입니다. 서버에서 이미 React를 실행했는데, 브라우저에서 똑같은 컴포넌트를 다시 실행합니다. 중복입니다.
예를 들어 블로그 글 페이지:
// pages/posts/[id].js (기존 Pages Router)
export async function getServerSideProps({ params }) {
const post = await db.post.findUnique({ where: { id: params.id } });
return { props: { post } };
}
export default function Post({ post }) {
return (
<article>
<h1>{post.title}</h1>
<MarkdownRenderer content={post.content} />
<CommentSection postId={post.id} />
</article>
);
}
이 코드에서 MarkdownRenderer는 순수하게 마크다운을 HTML로 변환하기만 합니다. 상호작용이 전혀 없습니다. 근데 이것도 JavaScript 번들에 포함됩니다. markdown-it 라이브러리(50KB)가 번들에 들어갑니다.
"서버에서만 실행되는 컴포넌트를 만들자."
// app/posts/[id]/page.js (App Router with RSC)
import { db } from '@/lib/db';
import MarkdownRenderer from '@/components/MarkdownRenderer'; // Server Component
import CommentSection from '@/components/CommentSection'; // Client Component
export default async function Post({ params }) {
// 서버에서 직접 DB 조회! (getServerSideProps 필요 없음)
const post = await db.post.findUnique({ where: { id: params.id } });
return (
<article>
<h1>{post.title}</h1>
{/* 서버에서 실행됨. 브라우저엔 결과 HTML만 전송 */}
<MarkdownRenderer content={post.content} />
{/* 클라이언트에서 실행됨. 상호작용 가능 */}
<CommentSection postId={post.id} />
</article>
);
}
// components/MarkdownRenderer.js (Server Component)
import markdownIt from 'markdown-it'; // 이 라이브러리는 번들에 안 들어감!
export default function MarkdownRenderer({ content }) {
const md = markdownIt();
const html = md.render(content);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// components/CommentSection.js (Client Component)
'use client'; // 이 지시어가 있으면 Client Component
import { useState } from 'react';
export default function CommentSection({ postId }) {
const [comments, setComments] = useState([]);
return (
<div>
<button onClick={() => loadComments(postId)}>
댓글 보기
</button>
{comments.map(c => <div key={c.id}>{c.text}</div>)}
</div>
);
}
결과:
markdown-it 라이브러리는 서버에만 있고, 번들에 안 들어감.MarkdownRenderer는 Hydration 대상이 아님 (이미 HTML이니까).기존엔 API 라우트를 만들어야 했습니다.
// pages/api/posts/[id].js (API 라우트)
export default async function handler(req, res) {
const post = await db.post.findUnique({ where: { id: req.query.id } });
res.json(post);
}
// pages/posts/[id].js (프론트엔드)
export async function getServerSideProps({ params }) {
const res = await fetch(`http://localhost:3000/api/posts/${params.id}`);
const post = await res.json();
return { props: { post } };
}
이건 비효율적입니다. 서버 안에서 또 HTTP 요청을 하고 있잖아요?
RSC는 이걸 단순화합니다.
// app/posts/[id]/page.js
import { db } from '@/lib/db';
export default async function Post({ params }) {
const post = await db.post.findUnique({ where: { id: params.id } });
return <div>{post.title}</div>;
}
API 라우트가 필요 없습니다. 컴포넌트에서 직접 DB를 조회합니다. 이게 가능한 이유는 이 컴포넌트가 서버에서만 실행되니까요.
| 항목 | 기존 SSR (Pages Router) | RSC (App Router) |
|---|---|---|
| 서버 실행 | getServerSideProps에서만 | 컴포넌트 자체가 async 가능 |
| 번들 크기 | 모든 컴포넌트 포함 | Server Component는 제외 |
| Hydration | 전체 트리 Hydration | Client Component만 Hydration |
| DB 접근 | API 라우트 or getServerSideProps | 컴포넌트에서 직접 가능 |
| Waterfall 문제 | 있음 (직렬 fetch) | Streaming으로 해결 가능 |
RSC와 함께 도입된 Streaming SSR은 진짜 혁명입니다.
기존 SSR은 전체 페이지가 준비될 때까지 기다렸습니다.
사용자 접속 → 서버에서 DB 조회 (100ms) → 추천 상품 조회 (300ms) → 댓글 조회 (200ms)
→ 총 600ms 대기 → HTML 전송
Streaming SSR은 준비된 부분부터 먼저 보냅니다.
// app/posts/[id]/page.js
import { Suspense } from 'react';
import Post from '@/components/Post';
import RecommendedProducts from '@/components/RecommendedProducts';
import Comments from '@/components/Comments';
export default function Page({ params }) {
return (
<div>
{/* 즉시 렌더링 */}
<Suspense fallback={<PostSkeleton />}>
<Post id={params.id} />
</Suspense>
{/* 느린 쿼리는 나중에 */}
<Suspense fallback={<div>추천 상품 로딩 중...</div>}>
<RecommendedProducts />
</Suspense>
<Suspense fallback={<div>댓글 로딩 중...</div>}>
<Comments postId={params.id} />
</Suspense>
</div>
);
}
동작:
0ms: 사용자 접속 → HTML shell 즉시 전송 (Skeleton UI)
100ms: Post 데이터 준비 → 스트리밍으로 전송 → Post 부분만 교체
400ms: RecommendedProducts 준비 → 전송 → 교체
600ms: Comments 준비 → 전송 → 교체
사용자는 100ms만 기다리면 글을 읽기 시작할 수 있습니다. 나머지는 백그라운드에서 채워집니다.
제가 이 기능을 적용했을 때 TTFB(Time To First Byte)가 600ms에서 100ms로 줄었습니다. 체감 속도가 확 빨라졌습니다.
제 회사 서비스는 처음에 Create React App(CRA)으로 만들어졌습니다. 그런데 SEO 문제, 초기 로딩 속도 문제가 심각해지면서 Next.js로 마이그레이션했습니다.
먼저 기존 React 코드를 최대한 유지하면서 Next.js Pages Router로 옮겼습니다.
// 기존 CRA: src/pages/ProductDetail.js
function ProductDetail() {
const { id } = useParams();
const [product, setProduct] = useState(null);
useEffect(() => {
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(setProduct);
}, [id]);
if (!product) return <div>Loading...</div>;
return <div>{product.name}</div>;
}
// Next.js로 전환: pages/products/[id].js
export async function getServerSideProps({ params }) {
const product = await fetchProduct(params.id);
return { props: { product } };
}
export default function ProductDetail({ product }) {
return <div>{product.name}</div>;
}
이것만으로도 SEO가 해결됐습니다. 구글 검색 결과에 상품이 뜨기 시작했습니다.
2단계: 렌더링 전략 최적화모든 페이지를 SSR로 하니까 서버비가 폭탄처럼 나왔습니다. 그래서 페이지별로 전략을 분리했습니다.
// 홈페이지 → ISR (1시간마다 갱신)
export async function getStaticProps() {
const featured = await fetchFeaturedProducts();
return {
props: { featured },
revalidate: 3600
};
}
// 상품 상세 → ISR (10분마다 갱신, fallback: blocking)
export async function getStaticProps({ params }) {
const product = await fetchProduct(params.id);
return {
props: { product },
revalidate: 600
};
}
export async function getStaticPaths() {
const top100 = await fetchTopProducts(100);
return {
paths: top100.map(id => ({ params: { id } })),
fallback: 'blocking'
};
}
// 마이페이지 → SSR (항상 최신 데이터)
export async function getServerSideProps({ req }) {
const session = await getSession({ req });
const user = await fetchUser(session.userId);
return { props: { user } };
}
// 장바구니 → CSR (SEO 불필요, 상호작용 많음)
// getServerSideProps 없음 → 자동으로 CSR
결과:
현재 점진적으로 App Router로 마이그레이션 중입니다. 특히 상품 상세 페이지부터 시작했습니다.
// app/products/[id]/page.js
import { db } from '@/lib/db';
import ProductImage from './ProductImage'; // Server Component
import AddToCartButton from './AddToCartButton'; // Client Component
import RecommendedProducts from './RecommendedProducts'; // Server Component
export default async function ProductPage({ params }) {
const product = await db.product.findUnique({
where: { id: params.id },
include: { reviews: true }
});
return (
<div>
<ProductImage src={product.image} />
<h1>{product.name}</h1>
<p>{product.price}원</p>
{/* Client Component: 상호작용 */}
<AddToCartButton productId={product.id} />
{/* Server Component: DB 조회 */}
<Suspense fallback={<RecommendedSkeleton />}>
<RecommendedProducts category={product.category} />
</Suspense>
<ReviewList reviews={product.reviews} />
</div>
);
}
번들 크기가 150KB → 90KB로 줄었습니다. ProductImage, RecommendedProducts, ReviewList가 모두 Server Component라 번들에서 제외됐거든요.
렌더링 전략에 따라 인프라 비용이 천차만별입니다. 아래는 일반적인 규모의 서비스를 기준으로 한 비용 비교입니다.
호스팅: Vercel 무료 플랜 (또는 S3 + CloudFront)
- Vercel: $0 (100GB 대역폭 무료)
- S3: $1 (스토리지)
- CloudFront: $5 (대역폭)
총 비용: $0~6
장점: 엄청 싸고 빠름 단점: 빌드 타임이 오래 걸림 (페이지 1만 개면 빌드 1시간)
호스팅: Vercel Pro 또는 Netlify
- Vercel Pro: $20/월
- 또는 Lambda@Edge + S3: $15/월
총 비용: $15~20
장점: SSG의 속도 + SSR의 신선도 단점: 캐시 전략 설정이 까다로움
호스팅: AWS Lambda + API Gateway (또는 EC2)
- Lambda: $50/월 (매 요청마다 실행)
- RDS (DB): $30/월
- API Gateway: $10/월
총 비용: $90
만약 트래픽이 10배로 늘면?
이런 구조 때문에 대부분의 페이지는 ISR로 운영하고, 정말 필요한 페이지만 SSR을 쓰는 방식이 권장됩니다.
문제: 프로덕션 배포 후 사용자들이 "화면이 깜빡인다"고 제보했습니다.
원인:function UserGreeting() {
const user = useAuthStore(state => state.user);
return <div>안녕하세요, {user?.name || '손님'}님</div>;
}
서버에서는 인증 정보가 없으니까 "안녕하세요, 손님님"으로 렌더링. 브라우저에서 Hydration 후 "안녕하세요, 홍길동님"으로 변경. → Mismatch 발생 → React가 전체 트리를 다시 렌더링 → 깜빡임
해결:function UserGreeting() {
const [mounted, setMounted] = useState(false);
const user = useAuthStore(state => state.user);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div>안녕하세요, 손님님</div>;
}
return <div>안녕하세요, {user?.name || '손님'}님</div>;
}
서버와 클라이언트 첫 렌더링을 동일하게 맞췄습니다.
export async function getServerSideProps() {
const token = cookies().get('auth_token'); // undefined!
// ...
}
원인:
브라우저 쿠키는 HTTP 요청 헤더로 전달됩니다. context.req.headers.cookie에서 파싱해야 합니다.
import { parse } from 'cookie';
export async function getServerSideProps({ req }) {
const cookies = parse(req.headers.cookie || '');
const token = cookies.auth_token;
// ...
}
App Router에서는 더 간단합니다.
import { cookies } from 'next/headers';
export default async function Page() {
const token = cookies().get('auth_token');
// ...
}
앞서 설명했듯이 Strict Mode 때문입니다. 저는 이렇게 해결했습니다.
useEffect(() => {
let cancelled = false;
fetchData().then(data => {
if (!cancelled) {
setData(data);
}
});
return () => {
cancelled = true; // Cleanup 시 취소
};
}, []);
또는 React Query 같은 라이브러리를 쓰면 자동으로 중복 요청을 방지합니다.
const Chart = dynamic(() => import('react-chartjs-2'), {
ssr: false
});
// 에러: "Chart is not a constructor"
원인: 일부 라이브러리는 default export가 아닌 named export를 씁니다.
해결:const Chart = dynamic(() => import('react-chartjs-2').then(mod => mod.Chart), {
ssr: false
});
Next.js 14에서 Partial Prerendering(PPR)이라는 실험적 기능이 나왔습니다. 저는 아직 프로덕션에 적용 안 했지만, 테스트해본 결과 정말 인상적이었습니다.
"페이지의 정적인 부분은 SSG로, 동적인 부분은 SSR로 자동 분리."
// app/dashboard/page.js
export const experimental_ppr = true;
export default function Dashboard() {
return (
<div>
{/* 정적 부분: 빌드 타임에 생성 */}
<header>
<Logo />
<Navigation />
</header>
{/* 동적 부분: 요청 타임에 생성 */}
<Suspense fallback={<UserSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RealtimeChart />
</Suspense>
</div>
);
}
Next.js가 자동으로 분석해서:
<header> 부분은 빌드 타임에 HTML로 생성<UserProfile>, <RealtimeChart>는 요청 타임에 서버에서 생성getStaticProps와 getServerSideProps를 고민 안 해도 됨Next.js를 1년 넘게 쓰면서 결국 이거였습니다.
"렌더링 전략은 비즈니스 요구사항과 비용의 균형이다."그리고 이 선택은 페이지별로, 심지어 컴포넌트별로 다를 수 있습니다. Next.js의 진짜 강점은 이 유연함입니다.
처음엔 "Next.js는 SSR 프레임워크"라는 단순한 이해로 시작했지만, 지금은 "Next.js는 웹 렌더링의 스위스 아미 나이프"라고 받아들였습니다. 상황에 맞는 도구를 고르면 됩니다.
마지막으로, RSC는 진짜 게임 체인저입니다. 번들 크기 감소, Hydration 최적화, 백엔드 통합의 단순함. 아직 안정화 단계지만, 앞으로 웹 개발의 표준이 될 거라고 확신합니다.
제가 이 과정에서 가장 와닿았던 깨달음은 "기술은 트레이드오프다"라는 점입니다. 은탄환은 없습니다. 상황에 맞게 선택하고, 측정하고, 개선하는 것. 그게 개발자의 일이라고 이해했습니다.
When I first built our website with React, the CEO asked me: "Why doesn't our site show up on Google?" "What? That can't be right..."
I opened the page source and was shocked.
<body>
<div id="root"></div>
<script src="bundle.js"></script>
</body>
There was nothing. Google's crawler saw an empty shell and left. This is the core SEO problem with Client-Side Rendering (CSR).
At the time, I had used Create React App (CRA) to build the site. I thought React was modern and fast because it's a Single Page Application (SPA). But reality hit hard. Not only did our site not appear in Google search results, but when I shared the link on KakaoTalk, there was no preview image. I tried adding Open Graph meta tags, but they didn't work. Why? Because those tags were injected into the DOM only after JavaScript executed.
This is why Next.js was created. Over the past year, I migrated our CRA project to Next.js, and I learned the importance of rendering strategies the hard way.
When I first started learning Next.js, the terminology was the most confusing part. SSR, SSG, ISR, CSR, and now RSC. It felt like eating alphabet soup.
I heard "Next.js is an SSR framework" and started studying, but then I saw getStaticProps in the docs. "Wait, isn't that SSG? So is Next.js an SSG framework?" Another page told me to use getServerSideProps. Some blog posts even said "you can do CSR with Next.js too."
Eventually, I got it. Next.js is a hybrid framework. You can choose different rendering strategies for each page, or even for each component. This flexibility is Next.js's core strength, but initially, it was the source of my confusion.
I came to think of it this way: "Next.js is a vending machine for rendering strategies. You pick the drink (strategy) that fits your situation."
Everything clicked when I answered this question:
"When does the server generate HTML?"npm run build)Once I visualized this timeline in my head, everything became clear. I also understood the tradeoffs for each approach.
CSR (Create React App)
Build: Generate HTML shell → Deploy → User visits → Download JS → Execute React → Create DOM
Server load: ☆☆☆☆☆ (Almost none, just needs S3)
First paint: ★☆☆☆☆ (Slow, white screen until JS executes)
SEO: ★☆☆☆☆ (Crawler sees empty page)
SSG (getStaticProps)
Build: Query DB → Generate HTML → Deploy → User visits → Display HTML immediately
Server load: ☆☆☆☆☆ (Load only at build time, then CDN)
First paint: ★★★★★ (Fastest)
SEO: ★★★★★ (Perfect)
Downside: Need to redeploy when data changes
SSR (getServerSideProps)
User visits → Server queries DB → Generate HTML → Send → Display
Server load: ★★★★☆ (DB query per request)
First paint: ★★★☆☆ (Wait for DB latency)
SEO: ★★★★★ (Perfect)
Benefit: Always fresh data
ISR (getStaticProps + revalidate)
Build: Generate HTML → User visits → Serve cached HTML → Background regeneration
Server load: ★★☆☆☆ (Only periodically)
First paint: ★★★★★ (Fast like SSG)
SEO: ★★★★★ (Perfect)
Benefit: Balance between speed and freshness
After drawing this diagram, I finally understood "Oh, that's why blogs use SSG." The content rarely changes. On the other hand, e-commerce product pages are perfect for ISR. Prices and inventory change, but you don't need to query the database on every request.
When I first learned React, I had a magical experience. Pages didn't flicker. Websites built with PHP or JSP would reload the entire page when you clicked a link, showing a brief white screen. But React transitioned smoothly.
That's CSR's magic. The server just throws an empty HTML shell, and JavaScript draws the entire UI in the browser.
// How React (CSR) works
// 1. HTML sent by server (almost empty)
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
</head>
<body>
<div id="root"></div>
<script src="/static/js/bundle.js"></script>
</body>
</html>
// 2. When bundle.js executes
ReactDOM.render(<App />, document.getElementById('root'));
// 3. Now the DOM is created
<div id="root">
<header>Welcome to my site</header>
<main>Content here...</main>
</div>
Google's crawler has limited JavaScript execution capabilities. As of 2023, it has improved significantly, but it's still slow and incomplete. Social media crawlers like Facebook and Twitter barely execute JavaScript at all.
When I shared our homepage link on KakaoTok, this is what showed up:
Title: React App
Description: (none)
Image: (none)
Why? Because I added Open Graph meta tags inside React components using react-helmet. KakaoTalk's crawler couldn't read them.
Our homepage bundle was 1.2MB. Even with gzip compression, it was 400KB. On a 3G connection, just downloading it took 3 seconds. After downloading, JavaScript still needed to be parsed and executed, so users saw a white screen for over 5 seconds.
Google's Core Web Vitals standard requires LCP (Largest Contentful Paint) under 2.5 seconds, but we were hitting 5 seconds. We were definitely penalized in search rankings.
"What if we generate HTML on the server and send it?" That's the core of pre-rendering. Next.js makes this easy.
Optimized for static content like blog posts. All HTML is generated at build time.
// pages/posts/[id].js
export async function getStaticPaths() {
// Define which pages to pre-build
const posts = await fetchAllPostIds(); // [1, 2, 3, ..., 100]
return {
paths: posts.map(id => ({ params: { id: String(id) } })),
fallback: false // Return 404 for undefined ids
};
}
export async function getStaticProps({ params }) {
// Runs at build time (NOT on user request)
const post = await fetchPost(params.id);
return {
props: { post }
};
}
export default function Post({ post }) {
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
Build log example:
$ npm run build
> Building...
Page Size First Load JS
┌ ● /posts/1 2.1 kB 85 kB
├ ● /posts/2 2.0 kB 85 kB
├ ● /posts/3 2.2 kB 85 kB
...
└ ● /posts/100 2.1 kB 85 kB
● (SSG) automatically generated as static HTML
100 HTML files are pre-generated. When a user visits /posts/1, the server just serves the already-built posts/1.html. Lightning fast.
The server generates HTML on every request.
// pages/dashboard.js
export async function getServerSideProps(context) {
// Runs on every request!
const session = await getSession(context);
if (!session) {
return {
redirect: {
destination: '/login',
permanent: false,
}
};
}
// DB query (always fresh data)
const userData = await db.user.findUnique({
where: { id: session.user.id },
include: { orders: true }
});
return {
props: { userData }
};
}
export default function Dashboard({ userData }) {
return (
<div>
<h1>Welcome, {userData.name}</h1>
<ul>
{userData.orders.map(order => (
<li key={order.id}>{order.product}</li>
))}
</ul>
</div>
);
}
Execution flow:
User visits (10:23:15) → Run getServerSideProps → Query DB (50ms) → Generate HTML → Respond
User visits (10:23:20) → Run getServerSideProps → Query DB (50ms) → Generate HTML → Respond
User visits (10:23:25) → Run getServerSideProps → Query DB (50ms) → Generate HTML → Respond
DB is queried every time, so server load is high. The temptation is to use SSR for all pages, but that results in explosive server costs. When deployed on AWS Lambda, costs scale proportionally with traffic — and that can get expensive fast.
When I use SSR:A revolutionary compromise between SSG and SSR. When Next.js 9.5 introduced this, I thought "This is it!"
// pages/products/[id].js
export async function getStaticProps({ params }) {
const product = await fetchProduct(params.id);
return {
props: { product },
revalidate: 60 // Regenerate every 60 seconds
};
}
export async function getStaticPaths() {
// Only build top 100 products at build time
const topProducts = await fetchTopProducts(100);
return {
paths: topProducts.map(id => ({ params: { id: String(id) } })),
fallback: 'blocking' // Generate missing products on request
};
}
How it works:
Time 0s: Build complete. HTML for /products/1 ~ /products/100 generated.
Time 10s: User A visits /products/1
→ Cached HTML served immediately (fast!)
Time 70s: User B visits /products/1
→ 60s elapsed → Start generating new HTML in background
→ User B still gets **old cache** (doesn't slow down!)
Time 75s: New HTML generation complete → Cache replaced
Time 80s: User C visits /products/1
→ Updated HTML served
What resonated with me is that users never experience slowdowns. No waiting for DB queries like SSR, and no need for redeployment like SSG.
When I use ISR:The fallback option was the most confusing part of ISR for me.
// fallback: false
// Undefined paths return 404
paths: ['/posts/1', '/posts/2']
→ Visit /posts/3 → 404
// fallback: true
// Undefined paths show empty page, generate in background
→ Visit /posts/3 → Show "Loading..." → Generate → Display content
User has to wait
// fallback: 'blocking' (recommended!)
// Undefined paths wait for server generation
→ Visit /posts/3 → Server generates HTML (like SSR) → Serve complete page
Subsequent requests use cached HTML (like SSG)
I mostly use blocking. Better user experience. true requires manual loading state handling, which is cumbersome.
When I first used Next.js, the word "hydration" kept appearing. Literally it means "moisture replenishment," but what does it mean in web development?
// HTML generated by server (Next.js SSR/SSG)
<button id="btn">Click me</button>
// When the browser receives this HTML
1. Button appears on screen (fast!)
2. But clicking does nothing (no event listeners)
3. JavaScript downloads → React executes
4. React traverses DOM again and attaches event listeners
5. Now the button works
Step 4 is "Hydration"
As a metaphor, the server sends the skeleton (HTML), and the browser breathes life into it (JavaScript).
Hydration isn't free. React has to traverse the entire DOM tree again and match it with the Virtual DOM. If the bundle is large, hydration takes longer.
A problem I actually encountered:
// _app.js
import 'bootstrap/dist/css/bootstrap.min.css'; // 200KB
import 'antd/dist/antd.css'; // 500KB
import Lodash from 'lodash'; // 70KB (no tree-shaking)
import Moment from 'moment'; // 200KB (includes all locales)
// Result: Bundle size 1.5MB
// Hydration time: 3 seconds
// Users can't click buttons for 3 seconds (Frozen UI)
To improve this, I did the following:
// 1. Switch to libraries with tree-shaking
import { debounce } from 'lodash-es'; // 5KB
import dayjs from 'dayjs'; // 2KB
// 2. Import only needed CSS components
import Button from 'antd/lib/button';
import 'antd/lib/button/style/css';
// 3. Code split with dynamic imports
const HeavyChart = dynamic(() => import('../components/Chart'), {
ssr: false // Don't render on server
});
// Result: Bundle size reduced to 300KB
// Hydration time: 0.5 seconds
The error I saw most often with Next.js:
Warning: Text content did not match. Server: "..." Client: "..."
Cause: Happens when server-rendered HTML differs from client-rendered HTML.
A case where I made a mistake:
// ❌ Wrong code
function CurrentTime() {
return <div>Current time: {new Date().toLocaleString()}</div>;
}
// Server renders: "Current time: 2025-01-15 14:23:10"
// Client hydrates: "Current time: 2025-01-15 14:23:13"
// → Mismatch!
Solution:
// ✅ Correct code
function CurrentTime() {
const [time, setTime] = useState(null);
useEffect(() => {
setTime(new Date().toLocaleString());
}, []);
// Server and client first render are identical (null)
return <div>Current time: {time || 'Loading...'}</div>;
}
useEffect only runs on the client, so no hydration mismatch occurs.
In Next.js development mode, console.log outputs twice.
useEffect(() => {
console.log('API call!');
fetchData();
}, []);
// Console:
// API call!
// API call!
I thought "Is this a bug?" but it's due to React 18's Strict Mode. In development mode, components are intentionally mounted twice to verify that side effects are safe.
In production builds, it runs only once. But if the double API call bothers you during development:
// next.config.js
module.exports = {
reactStrictMode: false // Turn off Strict Mode (not recommended)
};
But I just accepted it. Code should be written to handle being called twice (idempotency).
When Next.js 13 introduced the App Router, React Server Components (RSC) arrived. This was a true paradigm shift.
Traditional Next.js SSR works like this:
1. Server: Execute React components → Generate HTML → Send to browser
2. Browser: Display HTML (fast!)
3. Browser: Download JavaScript bundle (200KB~2MB)
4. Browser: Re-execute React → Hydration
5. Now interactive
The problem is step 3. The server already executed React, but the browser re-executes the same components. It's redundant.
For example, a blog post page:
// pages/posts/[id].js (old Pages Router)
export async function getServerSideProps({ params }) {
const post = await db.post.findUnique({ where: { id: params.id } });
return { props: { post } };
}
export default function Post({ post }) {
return (
<article>
<h1>{post.title}</h1>
<MarkdownRenderer content={post.content} />
<CommentSection postId={post.id} />
</article>
);
}
In this code, MarkdownRenderer purely converts markdown to HTML. There's no interactivity. But it's still included in the JavaScript bundle. The markdown-it library (50KB) goes into the bundle.
"Let's create components that only run on the server."
// app/posts/[id]/page.js (App Router with RSC)
import { db } from '@/lib/db';
import MarkdownRenderer from '@/components/MarkdownRenderer'; // Server Component
import CommentSection from '@/components/CommentSection'; // Client Component
export default async function Post({ params }) {
// Query DB directly on server! (no getServerSideProps needed)
const post = await db.post.findUnique({ where: { id: params.id } });
return (
<article>
<h1>{post.title}</h1>
{/* Runs on server. Only result HTML sent to browser */}
<MarkdownRenderer content={post.content} />
{/* Runs on client. Interactive */}
<CommentSection postId={post.id} />
</article>
);
}
// components/MarkdownRenderer.js (Server Component)
import markdownIt from 'markdown-it'; // This library doesn't go into bundle!
export default function MarkdownRenderer({ content }) {
const md = markdownIt();
const html = md.render(content);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// components/CommentSection.js (Client Component)
'use client'; // This directive makes it a Client Component
import { useState } from 'react';
export default function CommentSection({ postId }) {
const [comments, setComments] = useState([]);
return (
<div>
<button onClick={() => loadComments(postId)}>
Show comments
</button>
{comments.map(c => <div key={c.id}>{c.text}</div>)}
</div>
);
}
Result:
markdown-it library stays on server, doesn't go into bundleMarkdownRenderer isn't subject to hydration (it's already HTML)Previously, we had to create API routes.
// pages/api/posts/[id].js (API route)
export default async function handler(req, res) {
const post = await db.post.findUnique({ where: { id: req.query.id } });
res.json(post);
}
// pages/posts/[id].js (frontend)
export async function getServerSideProps({ params }) {
const res = await fetch(`http://localhost:3000/api/posts/${params.id}`);
const post = await res.json();
return { props: { post } };
}
This is inefficient. We're making an HTTP request inside the server!
RSC simplifies this.
// app/posts/[id]/page.js
import { db } from '@/lib/db';
export default async function Post({ params }) {
const post = await db.post.findUnique({ where: { id: params.id } });
return <div>{post.title}</div>;
}
No API route needed. Query the database directly from the component. This is possible because this component only runs on the server.
| Aspect | Traditional SSR (Pages Router) | RSC (App Router) |
|---|---|---|
| Server execution | Only in getServerSideProps | Component itself can be async |
| Bundle size | Includes all components | Excludes Server Components |
| Hydration | Entire tree hydrated | Only Client Components hydrated |
| DB access | Via API route or getServerSideProps | Direct from component |
| Waterfall problem | Yes (serial fetches) | Can solve with Streaming |
Streaming SSR, introduced with RSC, is a real revolution.
Traditional SSR waited until the entire page was ready.
User visits → Server queries DB (100ms) → Query recommended products (300ms) → Query comments (200ms)
→ Total 600ms wait → Send HTML
Streaming SSR sends ready parts first.
// app/posts/[id]/page.js
import { Suspense } from 'react';
import Post from '@/components/Post';
import RecommendedProducts from '@/components/RecommendedProducts';
import Comments from '@/components/Comments';
export default function Page({ params }) {
return (
<div>
{/* Render immediately */}
<Suspense fallback={<PostSkeleton />}>
<Post id={params.id} />
</Suspense>
{/* Slow query comes later */}
<Suspense fallback={<div>Loading recommended products...</div>}>
<RecommendedProducts />
</Suspense>
<Suspense fallback={<div>Loading comments...</div>}>
<Comments postId={params.id} />
</Suspense>
</div>
);
}
Behavior:
0ms: User visits → Send HTML shell immediately (Skeleton UI)
100ms: Post data ready → Stream to client → Replace Post section
400ms: RecommendedProducts ready → Send → Replace
600ms: Comments ready → Send → Replace
Users only wait 100ms before they can start reading the post. The rest fills in the background.
When I applied this feature, TTFB (Time To First Byte) dropped from 600ms to 100ms. Perceived speed improved dramatically.
Our service was initially built with Create React App (CRA). But as SEO issues and slow initial load became serious, we migrated to Next.js.
First, I moved existing React code to Next.js Pages Router while keeping the code as intact as possible.
// Old CRA: src/pages/ProductDetail.js
function ProductDetail() {
const { id } = useParams();
const [product, setProduct] = useState(null);
useEffect(() => {
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(setProduct);
}, [id]);
if (!product) return <div>Loading...</div>;
return <div>{product.name}</div>;
}
// Migrated to Next.js: pages/products/[id].js
export async function getServerSideProps({ params }) {
const product = await fetchProduct(params.id);
return { props: { product } };
}
export default function ProductDetail({ product }) {
return <div>{product.name}</div>;
}
This alone solved SEO. Products started appearing in Google search results.
Phase 2: Optimize Rendering StrategiesMaking all pages SSR resulted in explosive server costs. So I split strategies by page.
// Homepage → ISR (refresh every hour)
export async function getStaticProps() {
const featured = await fetchFeaturedProducts();
return {
props: { featured },
revalidate: 3600
};
}
// Product detail → ISR (refresh every 10 minutes, fallback: blocking)
export async function getStaticProps({ params }) {
const product = await fetchProduct(params.id);
return {
props: { product },
revalidate: 600
};
}
export async function getStaticPaths() {
const top100 = await fetchTopProducts(100);
return {
paths: top100.map(id => ({ params: { id } })),
fallback: 'blocking'
};
}
// User profile → SSR (always fresh data)
export async function getServerSideProps({ req }) {
const session = await getSession({ req });
const user = await fetchUser(session.userId);
return { props: { user } };
}
// Shopping cart → CSR (no SEO needed, lots of interaction)
// No getServerSideProps → automatically CSR
Results:
Currently gradually migrating to App Router. Started with product detail pages.
// app/products/[id]/page.js
import { db } from '@/lib/db';
import ProductImage from './ProductImage'; // Server Component
import AddToCartButton from './AddToCartButton'; // Client Component
import RecommendedProducts from './RecommendedProducts'; // Server Component
export default async function ProductPage({ params }) {
const product = await db.product.findUnique({
where: { id: params.id },
include: { reviews: true }
});
return (
<div>
<ProductImage src={product.image} />
<h1>{product.name}</h1>
<p>{product.price} KRW</p>
{/* Client Component: interactive */}
<AddToCartButton productId={product.id} />
{/* Server Component: DB query */}
<Suspense fallback={<RecommendedSkeleton />}>
<RecommendedProducts category={product.category} />
</Suspense>
<ReviewList reviews={product.reviews} />
</div>
);
}
Bundle size reduced from 150KB → 90KB. ProductImage, RecommendedProducts, and ReviewList are all Server Components, so they're excluded from the bundle.
Infrastructure costs vary drastically based on rendering strategy. Here's a rough cost comparison for a typical scale service.
Hosting: Vercel free tier (or S3 + CloudFront)
- Vercel: $0 (100GB bandwidth free)
- S3: $1 (storage)
- CloudFront: $5 (bandwidth)
Total cost: $0~6
Pros: Super cheap and fast Cons: Long build times (1 hour for 10k pages)
Hosting: Vercel Pro or Netlify
- Vercel Pro: $20/month
- Or Lambda@Edge + S3: $15/month
Total cost: $15~20
Pros: Speed of SSG + freshness of SSR Cons: Cache strategy setup is tricky
Hosting: AWS Lambda + API Gateway (or EC2)
- Lambda: $50/month (executes on every request)
- RDS (DB): $30/month
- API Gateway: $10/month
Total cost: $90
If traffic grows 10x?
That's why most pages should be ISR, with SSR reserved only for pages that truly require it.
Problem: After production deployment, users reported "the screen flickers."
Cause:function UserGreeting() {
const user = useAuthStore(state => state.user);
return <div>Hello, {user?.name || 'Guest'}</div>;
}
Server has no auth info, so it renders "Hello, Guest". After browser hydration: "Hello, John Doe". → Mismatch occurs → React re-renders entire tree → Flicker
Solution:function UserGreeting() {
const [mounted, setMounted] = useState(false);
const user = useAuthStore(state => state.user);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div>Hello, Guest</div>;
}
return <div>Hello, {user?.name || 'Guest'}</div>;
}
I made the server and client first render identical.
export async function getServerSideProps() {
const token = cookies().get('auth_token'); // undefined!
// ...
}
Cause:
Browser cookies are sent as HTTP request headers. You need to parse context.req.headers.cookie.
import { parse } from 'cookie';
export async function getServerSideProps({ req }) {
const cookies = parse(req.headers.cookie || '');
const token = cookies.auth_token;
// ...
}
In App Router, it's simpler:
import { cookies } from 'next/headers';
export default async function Page() {
const token = cookies().get('auth_token');
// ...
}
As I explained earlier, it's due to Strict Mode. I solved it like this:
useEffect(() => {
let cancelled = false;
fetchData().then(data => {
if (!cancelled) {
setData(data);
}
});
return () => {
cancelled = true; // Cancel on cleanup
};
}, []);
Or using a library like React Query automatically prevents duplicate requests.
const Chart = dynamic(() => import('react-chartjs-2'), {
ssr: false
});
// Error: "Chart is not a constructor"
Cause: Some libraries use named exports instead of default exports.
Solution:const Chart = dynamic(() => import('react-chartjs-2').then(mod => mod.Chart), {
ssr: false
});
Next.js 14 introduced an experimental feature called Partial Prerendering (PPR). I haven't applied it to production yet, but testing it was genuinely impressive.
"Automatically split static parts as SSG and dynamic parts as SSR in a page."
// app/dashboard/page.js
export const experimental_ppr = true;
export default function Dashboard() {
return (
<div>
{/* Static part: generated at build time */}
<header>
<Logo />
<Navigation />
</header>
{/* Dynamic part: generated at request time */}
<Suspense fallback={<UserSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RealtimeChart />
</Suspense>
</div>
);
}
Next.js automatically analyzes and:
<header> as HTML at build time<UserProfile> and <RealtimeChart> on server at request timegetStaticProps vs getServerSideProps