Split View: React Server Components와 Next.js App Router 완전 정복 — RSC 프로토콜, Server Actions, PPR, Streaming (2025)
React Server Components와 Next.js App Router 완전 정복 — RSC 프로토콜, Server Actions, PPR, Streaming (2025)
"The future of React is not about rendering faster. It's about rendering less." — Dan Abramov (RSC introduction, 2020 Dec)
2020년 12월 21일, Dan Abramov와 Lauren Tan이 "React Server Components" 발표 영상을 올렸다. 당시 대부분의 개발자는 "또 새로운 걸 배워야 하나?"라고 반응했다. 2025년이 된 지금, RSC는 Next.js 14/15의 기본값이고, Remix, TanStack Start, RedwoodJS가 모두 RSC를 품으려 한다. 이것은 React 역사상 가장 큰 패러다임 변화다.
그러나 RSC는 복잡하다. "왜 'use server'랑 'use client'가 필요한가?" "이게 SSR과 뭐가 다른가?" "언제 어디서 뭐가 렌더링되는가?" 이 글은 그 모든 질문에 답하는 지도다.
1. React 렌더링의 역사 — 4단계
1단계 — CSR (2013-2015)
create-react-app 시절의 모델:
- 서버가 빈 HTML + JS 번들 전송
- 브라우저가 JS 실행 → 컴포넌트 렌더링 → DOM 생성
단점:
- First Paint 느림 — JS 로드 + 실행
- SEO 부족 — 초기 HTML에 콘텐츠 없음
- 네트워크 워터폴 — 데이터 페치 → 렌더 → 자식 페치
2단계 — SSR (2017+)
Next.js 등이 ReactDOMServer.renderToString으로 서버에서 HTML 생성:
- 서버가 렌더링된 HTML 전송 (FCP 빠름)
- 브라우저가 hydrate — 이벤트 리스너 붙이기
단점:
- 서버 렌더는 번들 크기에 영향 없음 (클라이언트 JS 여전)
- Hydration TTI가 오히려 더 느림 (HTML + JS 둘 다)
- getServerSideProps 워터폴
3단계 — SSG / ISR (2019+)
빌드 시 또는 주기적으로 HTML 생성:
- 정적 사이트의 속도 + React의 상호작용
- Vercel, Netlify의 전성기
그러나 동적 콘텐츠에는 부족.
4단계 — RSC (2020 발표, 2022 상용화)
서버에서만 렌더링되는 컴포넌트. 클라이언트에 JS가 없다.
// 완전히 서버에서만 실행됨, 번들에 안 들어감
async function ProductList() {
const products = await db.query('...') // DB 직접 접근!
return (
<ul>
{products.map(p => <ProductCard key={p.id} product={p} />)}
</ul>
)
}
혁명의 본질: 서버 로직(DB 질의, 파일 IO)이 React 컴포넌트 안에 자연스럽게 들어간다. API 레이어 없이.
2. Server Component vs Client Component
세 종류의 컴포넌트
| 타입 | 실행 위치 | 번들 포함 | 사용법 |
|---|---|---|---|
| Server Component | 서버에서만 | X | 'use server' 불필요, 기본값 (App Router) |
| Client Component | 서버+클라이언트 | O | 'use client' 명시 |
| Shared Component | 양쪽 | 조건부 | 지시어 없음 |
"use client" 지시어
'use client'
import { useState } from 'react'
export default function Counter() {
const [n, setN] = useState(0)
return <button onClick={() => setN(n+1)}>{n}</button>
}
이 파일과 여기서 import하는 모든 것이 클라이언트 번들에 들어간다. "Client Boundary"를 만드는 마커.
"use server" 지시어
// actions.ts
'use server'
export async function createPost(formData: FormData) {
await db.posts.insert({ ... })
}
// Component.tsx
<form action={createPost}>...</form>
Server Action — 클라이언트에서 호출하지만 서버에서 실행되는 함수. 내부적으로는 RPC 엔드포인트로 변환.
경계(boundary)의 규칙
- Server → Server: 자유 (그냥 함수 호출)
- Server → Client:
childrenprop 또는 serializable props로 전달 - Client → Client: 일반 React
- Client → Server: Server Action으로만
흔한 오해 — "서버 컴포넌트는 SSR이다"
아니다. SSR은 "서버에서도 한 번 렌더링"하는 것. RSC는 "서버에서만 렌더링"하는 것. 클라이언트 번들에 아예 없다.
- SSR Client Component: 서버에서 HTML 생성 + 클라이언트에서 hydrate
- Server Component: 서버에서만 실행, JS는 클라이언트에 보내지지 않음
3. RSC Flight Protocol — 직렬화의 기술
왜 새 프로토콜이 필요한가
RSC가 생성하는 건 HTML이 아니다. 컴포넌트 트리의 직렬화된 표현이다.
이유:
- 클라이언트가 이후 네비게이션 시 새 서버 컴포넌트를 받아 기존 트리와 병합해야
- "이 위치에 이 Client Component + props를 붙여라"를 표현해야
Flight Format — 스트리밍 JSON
1:"$Sreact.suspense"
2:{"children":["$","h1",null,{"children":"Hello"}]}
3:I[{"id":"Counter","chunks":["..."]}]
0:["$","$1",null,{"children":["$","div",null,{"children":["$","$L3",null,{"initial":0}]}]}]
$접두어로 특수 값(컴포넌트, Suspense, 참조) 표현$L3는 "Client Component 3번을 lazy load" 지시- 스트리밍으로 전송 — 나중에 resolve되는 부분은 늦게
장점
- 증분 렌더링 — 각 청크가 준비되는 대로 전송
- 번들 분할 — Client Component 참조만 있으므로 필요한 청크만 로드
- hydration 최적화 — 이미 렌더된 부분은 re-render 안 함
벤치마크
같은 페이지 SSR vs RSC:
- TTFB: 비슷 (둘 다 서버 렌더)
- JS 번들: RSC가 30-70% 작음 (서버 코드 제외)
- TTI: RSC가 빠름 (hydrate할 컴포넌트 적음)
4. Next.js App Router — 컨벤션의 언어
파일 시스템 기반 라우팅
app/
layout.tsx # 루트 레이아웃
page.tsx # 홈
blog/
layout.tsx # 블로그 레이아웃
page.tsx # 블로그 목록
[slug]/
page.tsx # 개별 글
loading.tsx # 로딩 UI
error.tsx # 에러 UI
@sidebar/ # 병렬 라우팅
default.tsx
page.tsx
특수 파일 규약
| 파일 | 역할 |
|---|---|
page.tsx | URL에 매칭되는 컴포넌트 |
layout.tsx | 공유 레이아웃 (리렌더 안됨) |
template.tsx | layout과 유사하지만 매번 리렌더 |
loading.tsx | Suspense fallback 자동 래핑 |
error.tsx | Error Boundary 자동 래핑 |
not-found.tsx | 404 페이지 |
default.tsx | 병렬 라우팅의 기본값 |
route.ts | API 엔드포인트 |
Layout Nesting — 진짜 장점
app/layout.tsx (웹사이트 프레임)
└─ app/blog/layout.tsx (블로그 프레임)
└─ app/blog/[slug]/page.tsx (글 본문)
페이지 간 이동 시 layout은 리렌더 안됨. 사이드바 state가 유지됨. Pages Router에서는 불가능했던 일.
병렬 라우팅 — @slot
app/
layout.tsx
@team/
page.tsx
@analytics/
page.tsx
export default function Layout({ children, team, analytics }) {
return (
<>
{children}
{team}
{analytics}
</>
)
}
한 화면에 여러 독립 라우트. 대시보드에 이상적.
Intercepting Routes — (..)
사진 클릭 시 모달로 열되, URL은 /photo/123으로. 새로고침하면 전체 페이지로. Instagram 같은 UX.
5. 데이터 페칭의 혁명
전통 방식 (Pages Router)
// 페이지별로만 데이터 페칭 가능
export async function getServerSideProps() {
const data = await fetch('...')
return { props: { data } }
}
컴포넌트 깊은 곳에서 데이터 필요하면? Prop drilling 또는 전역 상태.
App Router — 어디서든 async 가능
// 깊이 중첩된 서버 컴포넌트
async function UserProfile({ userId }) {
const user = await db.users.find(userId)
return <div>{user.name}</div>
}
컴포넌트 트리 어디서든 await. React가 병렬로 실행.
자동 요청 중복 제거 (Dedup)
같은 렌더에서 같은 fetch 여러 번 → 실제로는 한 번만.
// 두 컴포넌트가 같은 URL fetch
async function A() {
const user = await fetch('/api/user/1').then(r => r.json())
return ...
}
async function B() {
const user = await fetch('/api/user/1').then(r => r.json()) // 캐시됨
return ...
}
React 18의 cache() API로도 가능.
네트워크 워터폴 방지
// 병렬 페치
async function Page() {
const userPromise = getUser()
const postsPromise = getPosts()
const [user, posts] = await Promise.all([userPromise, postsPromise])
return <UI user={user} posts={posts} />
}
순차는 금물. 독립 데이터는 항상 병렬.
6. Server Actions — fetch의 종말?
기본 사용
// actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.posts.insert({ title })
revalidatePath('/blog')
}
// page.tsx
import { createPost } from './actions'
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
)
}
JavaScript가 비활성화되어도 동작 (form이 서버로 POST). Progressive Enhancement!
useActionState — 에러와 pending 처리
'use client'
const [state, action, isPending] = useActionState(createPost, null)
<form action={action}>
{state?.error && <p>{state.error}</p>}
<button disabled={isPending}>Submit</button>
</form>
useOptimistic — 즉각 피드백
'use client'
const [optimisticMsgs, addOptimistic] = useOptimistic(messages,
(state, newMsg) => [...state, { ...newMsg, sending: true }]
)
async function send(formData) {
addOptimistic({ text: formData.get('text') })
await sendMessage(formData)
}
사용자 관점에서는 즉시 반영, 실제 저장은 뒤에서. Twitter/WhatsApp UX.
Server Actions vs API Routes
- API Route: REST 엔드포인트, 외부에서도 접근
- Server Action: 내부 컴포넌트에서만, 자동 보안 토큰, CSRF 방어
내부 뮤테이션은 Server Action, 외부 API는 Route Handler.
7. 캐싱의 4계층 — 가장 헷갈리는 부분
Next.js의 캐시 모델은 유명할 정도로 복잡하다.
1. Request Memoization (요청 중복 제거)
- 같은 렌더 내에서 같은
fetch= 1번 - React 제공, Next.js 확장
2. Data Cache
fetch()가 기본으로 영속 캐시 (Vercel 환경){ cache: 'no-store' }또는{ next: { revalidate: 60 } }로 제어- 2024년 Next.js 15부터 기본이
no-store로 변경 — 혼란 회피
3. Full Route Cache
- 정적 라우트의 HTML + RSC payload 전체 캐시
- 빌드 시 또는 ISR로 생성
4. Router Cache (클라이언트)
- 브라우저에서 방문한 라우트의 RSC payload 메모리 캐시
- 뒤로가기/페이지 전환 즉시
- 수동 무효화:
router.refresh()
무효화
revalidatePath('/blog')— 특정 경로revalidateTag('posts')— 태그 단위- 재방문해도 stale-while-revalidate 동작
2024-2025 간소화 시도
Next.js 15의 "stable cache semantics":
fetch기본 캐시 → opt-in으로 변경 (export const fetchCache = 'default-cache')dynamicIOflag — 데이터 페치를 명시적으로 정적/동적 표시'use cache'지시어 (실험적) — 함수 단위 캐시 선언
8. Streaming과 Suspense
스트리밍의 개념
서버가 HTML/RSC를 완성된 후 한 번에 보내는 게 아니라, 준비되는 대로 청크로 보냄.
자동 Suspense
// page.tsx
export default function Page() {
return (
<>
<Header />
<SlowComponent /> // DB 느림
<Footer />
</>
)
}
loading.tsx가 있으면 자동으로:
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
→ Header는 즉시 렌더, SlowComponent 기다리는 동안 fallback 표시, 준비되면 교체.
수동 Suspense 경계
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<ProductSkeleton />}>
<SlowProducts />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
<Footer />
</>
)
}
두 개가 독립적으로 로드. 빨리 끝나는 게 먼저 나옴.
스트리밍 HTML의 세부
<html><head>...먼저 전송- 각 Suspense boundary가 resolved되면
<script>로 해당 HTML 주입 - hydration도 순차적(Selective Hydration)
TTFB vs FMP 트레이드오프
- 전통 SSR: 모두 기다린 후 전송 (TTFB 느림, FMP도 늦음)
- 스트리밍: 즉시 시작 (TTFB 빠름, FMP 빠름, 일부는 나중)
9. PPR — Partial Prerendering (Next.js 15)
문제
- 전부 정적: 빠름, but 동적 데이터 못 씀
- 전부 동적: 느림, 캐시 못 씀
- 현실: 일부는 정적, 일부는 동적
해결 — 하나의 페이지에 혼합
export const experimental_ppr = true
export default function Page() {
return (
<>
<Header /> {/* 정적 */}
<Hero /> {/* 정적 */}
<Suspense fallback={<Skeleton />}>
<DynamicCart /> {/* 동적, 매 요청마다 */}
</Suspense>
</>
)
}
작동 원리
- 빌드 시 정적 부분을 미리 렌더 → shell
- 요청 시 동적 부분만 렌더 → streaming으로 결합
- 결과: 정적 속도 + 동적 유연성
현재 (2025)
- Next.js 15에서 experimental
- Vercel에서 production 사용 중
- 2026년 안정화 예상
10. React Server Components vs SolidStart vs Qwik
SolidStart
- Solid.js 기반 (React보다 빠름, reactive 원시 타입)
- "islands" 모델 + RSC-like 서버 함수
- Vite 기반, 덜 복잡
Qwik City
- Resumability — hydration 없음
- HTML에 상태를 직렬화, 인터랙션 시 해당 코드만 다운로드
- 초기 JS: 1KB 미만 가능
- "instant app" 철학
TanStack Start
- Tanner Linsley(React Query) 주도
- Vite 기반, TypeScript-first
- 2025년 beta, RSC 지원 계획
Remix (이제 React Router v7)
- "Use the Platform" — 웹 표준 사랑
- 2024년 말 React Router v7로 통합
- RSC 지원 2025년 도입
선택 기준
| 조건 | 추천 |
|---|---|
| 팀이 React 익숙, 대규모 | Next.js (RSC) |
| 성능 극한, 작은 앱 | Qwik |
| Solid.js 선호 | SolidStart |
| 타입 안전성 최우선 | TanStack Start |
| Use the Platform 철학 | React Router v7 |
11. 실제 마이그레이션 — Pages → App Router
점진적 이행
같은 Next.js 프로젝트에 pages/와 app/을 공존 가능.
app/ # 새 라우트 App Router
about/page.tsx
pages/ # 기존 라우트 유지
index.tsx
api/...
이행 순서
- 새 기능부터
app/에 - 기존 API routes는 그대로 (
route.ts로 옮기든 말든) - 페이지별로 점진 이행 (큰 페이지부터 or 간단한 것부터)
_app.tsx→app/layout.tsx로 통합
함정
- CSS 충돌 — Pages Router의
styles/globals.css이중 로드 - middleware — 공유되지만 일부 API 차이
- Image 컴포넌트 —
next/image는 동일 - useRouter —
next/navigation으로 변경 필수
12. 흔한 실수 TOP 10
- 모든 곳에
'use client'— RSC 이점 사라짐 - Server Component에 useState — 안 됨, TypeScript가 막아줌
- Client Component에서 DB 직접 접근 — 보안 사고, 자동 차단
window를 Server Component에서 — 존재 안 함- Large fetch를 매 요청마다 — 캐시 전략 필수
- 중첩 Suspense 없음 — 전체가 같이 로딩
- 네트워크 워터폴 —
await순차 - Server Action에서 큰 객체 전달 — 직렬화 비용
- revalidatePath 누락 — mutation 후 UI 안 바뀜
- 클라이언트 상태를 Server Component에 의존 — 불가능
13. App Router 체크리스트
- 기본은 Server Component —
'use client'는 필요할 때만 - layout의 무리한 재사용 — 리렌더 안 됨 확인
- Suspense boundary — 느린 부분 독립 격리
- loading.tsx — 페이지별 로딩 UI
- error.tsx — 페이지별 에러 경계
- 병렬 데이터 페칭 —
Promise.all활용 - Server Action 검증 — zod로 입력 검증
- revalidate 전략 — path/tag 기반
- Dynamic/Static 명시 —
export const dynamic = 'force-static' - TypeScript strict — 타입 유지
- Turbopack dev —
next dev --turbo - bundle analysis —
@next/bundle-analyzer
마치며 — "서버는 다시 중요해졌다"
RSC는 단순한 최적화가 아니다. "프론트엔드와 백엔드의 경계를 컴포넌트 레벨까지 끌어내린" 패러다임 변화다. 10년 전 "SPA가 미래"라고 외쳤던 업계가, 이제는 "서버가 다시 중요하다"고 말한다. 그러나 이것은 과거로의 회귀가 아니라 새로운 합성이다 — 서버의 DB 접근 편의성 + 클라이언트의 상호작용성.
React 팀이 말한 "Use the Platform" 철학은 웹 표준(form, HTML 스트리밍, progressive enhancement)을 React에 녹여넣은 것이다. 2030년의 React는 지금과 많이 다를 것이다. 하지만 그 방향은 이미 보인다: 작은 클라이언트 번들, 빠른 첫 화면, 서버에서 해결할 수 있는 것은 서버에서, 복잡성은 프레임워크로.
다음 글 예고 — TypeScript 타입 시스템의 깊이 — 2026년의 표준
RSC가 런타임의 혁명이었다면, TypeScript는 지난 10년간의 언어 혁명이다. 다음 글에서는:
- TypeScript의 철학 — Gradual typing의 성공 공식
- 구조적 타이핑 — nominal과의 대비
- Generic의 깊이 — Conditional, Mapped, Template Literal Types
satisfiesvsas— 2022년의 작은 혁명- 타입 수준 프로그래밍 — Type Challenges의 세계
- 모듈 해석의 복잡성 — NodeNext, ESM/CJS interop
- tsc의 한계와 대안 — SWC, esbuild, Bun
- TypeScript Go 포팅 (2025) — Anders Hejlsberg의 10배 빠른 컴파일러
- Zod, ArkType, Valibot — 런타임 검증의 진화
- Effect-TS — 함수형 타입 시스템의 프런티어
- tRPC와 end-to-end 타입 안전성
개발자 도구의 가장 중요한 혁명을 정리하는 여정.
"With Server Components, you don't have to choose between 'it's a rich app' and 'it's fast'. You get both." — Sebastian Markbåge (React core, RSC architect)
React Server Components & Next.js App Router — RSC Protocol, Server Actions, PPR, Streaming (2025)
"The future of React is not about rendering faster. It's about rendering less." — Dan Abramov (RSC introduction, Dec 2020)
In December 2020, Dan Abramov and Lauren Tan published the "React Server Components" talk. Most developers reacted with "do I have to learn another thing?" Now, in 2025, RSC is the default in Next.js 14/15, and Remix, TanStack Start, and RedwoodJS are all moving to embrace it. This is the biggest paradigm shift in React's history.
But RSC is complex. Why 'use server' and 'use client'? How does it differ from SSR? When and where does anything render? This post is the map.
1. Four eras of React rendering
Era 1 — CSR (2013–2015)
The create-react-app model: server sends an empty HTML shell + JS bundle, browser executes JS and builds the DOM. Slow first paint, weak SEO, network waterfalls.
Era 2 — SSR (2017+)
Next.js and friends use ReactDOMServer.renderToString to produce HTML on the server, then the browser hydrates. FCP is fast — but the client bundle is unchanged, TTI often gets worse, and getServerSideProps waterfalls plagued the ecosystem.
Era 3 — SSG / ISR (2019+)
Pre-rendered HTML at build time or at intervals. Vercel and Netlify's glory days. Weak on dynamic content.
Era 4 — RSC (announced 2020, shipped 2022)
Components that render only on the server. No JS ships to the client.
// Runs only on the server, never in the bundle
async function ProductList() {
const products = await db.query('...') // direct DB access!
return (
<ul>
{products.map(p => <ProductCard key={p.id} product={p} />)}
</ul>
)
}
The revolution: server logic (DB queries, file IO) lives inside React components — no API layer in between.
2. Server vs Client Components
| Kind | Runs | In bundle | Marker |
|---|---|---|---|
| Server Component | Server only | No | Default (App Router) |
| Client Component | Server + Client | Yes | 'use client' |
| Shared | Either | Conditional | No directive |
"use client"
'use client'
import { useState } from 'react'
export default function Counter() {
const [n, setN] = useState(0)
return <button onClick={() => setN(n+1)}>{n}</button>
}
This file and everything it imports enters the client bundle. It draws the "Client Boundary."
"use server"
// actions.ts
'use server'
export async function createPost(formData: FormData) {
await db.posts.insert({ ... })
}
// <form action={createPost}>
Server Action — invoked from the client, executed on the server. Internally compiled to an RPC endpoint.
Boundary rules
- Server → Server: free function call.
- Server → Client: via
childrenor serializable props. - Client → Client: normal React.
- Client → Server: only via Server Action.
Common misconception — "Server Components are SSR"
No. SSR means "rendered once on the server as HTML and hydrated." RSC means "rendered only on the server; no JS is sent." Two different mechanisms — they can combine.
3. The RSC Flight Protocol
RSC produces not HTML but a serialized representation of a component tree — so the client can later fetch a new subtree on navigation and merge.
Flight Format — streaming JSON
1:"$Sreact.suspense"
2:{"children":["$","h1",null,{"children":"Hello"}]}
3:I[{"id":"Counter","chunks":["..."]}]
0:["$","$1",null,{"children":["$","div",null,{"children":["$","$L3",null,{"initial":0}]}]}]
$prefix denotes special values (components, Suspense, refs).$L3means "lazy-load Client Component 3."- Streamed incrementally — later-resolving chunks arrive later.
Benefits
- Incremental rendering — chunks ship as they're ready.
- Bundle splitting — only Client Component chunks that are actually used.
- Hydration optimization — already-rendered parts don't re-render.
Benchmarks
Same page SSR vs RSC: TTFB similar, JS bundle 30–70% smaller, TTI faster (fewer components to hydrate).
4. App Router — the convention language
File-system routing
app/
layout.tsx
page.tsx
blog/
layout.tsx
page.tsx
[slug]/
page.tsx
loading.tsx
error.tsx
@sidebar/
default.tsx
page.tsx
Special files
page.tsx— URL-matched component.layout.tsx— shared, does not re-render across nested navigation.template.tsx— re-renders every time.loading.tsx— auto-wraps with Suspense.error.tsx— auto-wraps with Error Boundary.not-found.tsx— 404.default.tsx— default for parallel routes.route.ts— API endpoint.
Layout nesting — the real win
Navigating between pages does not re-render the layout — sidebar state persists. Impossible with the Pages Router.
Parallel routes (@slot)
Multiple independent routes in one screen — ideal for dashboards.
Intercepting routes ((..))
Click a photo → opens as modal, URL becomes /photo/123, reload shows full page. Instagram-style UX.
5. The data-fetching revolution
Old way (Pages Router)
getServerSideProps only at the page level → prop drilling or global state for deep components.
App Router — async anywhere
async function UserProfile({ userId }) {
const user = await db.users.find(userId)
return <div>{user.name}</div>
}
await anywhere in the tree; React runs them in parallel.
Automatic dedup
The same fetch URL invoked twice in one render hits the source once. React 18 cache() too.
Avoid waterfalls
async function Page() {
const [user, posts] = await Promise.all([getUser(), getPosts()])
return <UI user={user} posts={posts} />
}
Independent data → always parallel.
6. Server Actions — the end of fetch?
Basic use
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
await db.posts.insert({ title: formData.get('title') })
revalidatePath('/blog')
}
// <form action={createPost}><input name="title" /><button>Create</button></form>
Works even with JavaScript disabled — the form just POSTs to the server. Progressive enhancement.
useActionState — error + pending
'use client'
const [state, action, isPending] = useActionState(createPost, null)
<form action={action}>
{state?.error && <p>{state.error}</p>}
<button disabled={isPending}>Submit</button>
</form>
useOptimistic — instant feedback
const [optimisticMsgs, addOptimistic] = useOptimistic(messages,
(state, newMsg) => [...state, { ...newMsg, sending: true }])
Twitter/WhatsApp-style UX: reflect immediately, save in the background.
Server Actions vs API Routes
- API Route: REST endpoint, externally callable.
- Server Action: internal only, automatic CSRF/token protection.
Internal mutations → Server Action. External APIs → Route Handler.
7. Four cache layers — the confusing part
- Request Memoization — same
fetchin one render = one call. React-level. - Data Cache —
fetch()is cached on disk/edge. Control via{ cache: 'no-store' }or{ next: { revalidate: 60 } }. Next.js 15 changed the default to no-store. - Full Route Cache — entire HTML + RSC payload for static routes. Built at build or via ISR.
- Router Cache (client) — in-memory RSC payload for visited routes. Back button is instant. Invalidate with
router.refresh().
Invalidation
revalidatePath('/blog')— path-level.revalidateTag('posts')— tag-level.- Stale-while-revalidate under the hood.
2024–2025 simplification
Next.js 15 "stable cache semantics": fetch cache is opt-in, dynamicIO flag for explicit static/dynamic, experimental 'use cache' directive for function-level caching.
8. Streaming and Suspense
Servers don't wait to finish everything — they stream chunks as they become ready.
Automatic Suspense via loading.tsx
export default function Page() {
return (
<>
<Header />
<SlowComponent />
<Footer />
</>
)
}
With loading.tsx, this is auto-wrapped in <Suspense fallback={<Loading />}>.
Manual Suspense boundaries
<Suspense fallback={<ProductSkeleton />}><SlowProducts /></Suspense>
<Suspense fallback={<ReviewsSkeleton />}><Reviews /></Suspense>
Independent boundaries resolve independently — the faster one arrives first.
How streaming HTML works
<html><head>...ships immediately.- Each Suspense boundary injects its HTML via
<script>on resolution. - Hydration is selective.
TTFB vs FMP
Traditional SSR: wait for everything, slow TTFB and FMP. Streaming: immediate TTFB, fast FMP, some parts later.
9. PPR — Partial Prerendering (Next.js 15)
The problem
Fully static is fast but rigid. Fully dynamic is flexible but slow. Real pages are some static, some dynamic.
Solution — mix in one page
export const experimental_ppr = true
export default function Page() {
return (
<>
<Header /> {/* static */}
<Hero /> {/* static */}
<Suspense fallback={<Skeleton />}>
<DynamicCart /> {/* dynamic per request */}
</Suspense>
</>
)
}
How it works
- Build-time: pre-render the static shell.
- Request-time: render only dynamic parts, stream them in.
- Result: static speed + dynamic flexibility.
Status
Experimental in Next.js 15, running in Vercel production, expected to stabilize in 2026.
10. RSC vs SolidStart vs Qwik vs friends
SolidStart
Solid.js-based (faster than React, reactive primitives). Islands + RSC-like server functions. Vite-based, simpler.
Qwik City
Resumability — no hydration. State is serialized into HTML; code downloads only on interaction. Initial JS can be under 1KB.
TanStack Start
Tanner Linsley (React Query). Vite, TypeScript-first. Beta in 2025, RSC planned.
Remix (now React Router v7)
"Use the Platform." Merged into React Router v7 in late 2024. RSC support arriving in 2025.
Choice matrix
| Team / goal | Pick |
|---|---|
| React-native team, large app | Next.js (RSC) |
| Extreme performance, small app | Qwik |
| Prefer Solid.js | SolidStart |
| Type-safety-first | TanStack Start |
| "Use the Platform" | React Router v7 |
11. Migrating Pages → App Router
Coexist
Same Next.js project can have both app/ and pages/.
Order
- New features in
app/. - Keep API routes until it's convenient.
- Migrate page-by-page.
- Fold
_app.tsxintoapp/layout.tsx.
Pitfalls
- CSS —
styles/globals.cssloaded twice. - Middleware — shared but some API drift.
<Image>— identical.useRouter→next/navigation(breaking).
12. Ten common mistakes
'use client'everywhere — RSC benefits evaporate.useStatein a Server Component — TypeScript catches it.- DB access in a Client Component — security incident; framework blocks it.
windowin a Server Component — doesn't exist.- Huge fetch on every request — use cache strategy.
- No nested Suspense — the whole page waits.
- Sequential
await— network waterfall. - Large objects through Server Actions — serialization cost.
- Missing
revalidatePath— UI stale after mutation. - Client state expected inside Server Component — impossible.
13. App Router checklist
- Default to Server Component —
'use client'only when needed. - Layouts don't re-render — verify shared state.
- Suspense boundaries — isolate slow parts.
- loading.tsx per route.
- error.tsx per route.
- Parallel fetching —
Promise.all. - Server Action validation — zod.
- Revalidation strategy — path/tag.
- Static/dynamic explicit —
export const dynamic = 'force-static'. - TypeScript strict.
- Turbopack dev —
next dev --turbo. - Bundle analysis —
@next/bundle-analyzer.
Closing — "the server matters again"
RSC isn't an optimization. It is a paradigm shift that pushes the frontend/backend boundary down to the component level. Ten years ago the industry shouted "SPAs are the future." Now we say "the server matters again." This is not nostalgia — it's a new synthesis: server's DB access convenience + client's interactivity.
React's "Use the Platform" mantra is about folding web standards (forms, HTML streaming, progressive enhancement) back into React. The 2030 React will look different — but the direction is already visible: small client bundles, fast first paint, solve server problems on the server, push complexity into the framework.
"With Server Components, you don't have to choose between 'it's a rich app' and 'it's fast'. You get both." — Sebastian Markbåge