- Published on
React Server Components & Next.js App Router — RSC Protocol, Server Actions, PPR, Streaming (2025)
- Authors

- Name
- Youngju Kim
- @fjvbn20031
"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