RSC in 2026: The End of the SPA Era and What Comes Next
TL;DR: React Server Components are the default in Next.js 15+ and React 19. They can slash JavaScript bundles by 40–75% and cut LCP by up to 67% — but only if you architect them correctly. 67% of developers who use RSC report negative sentiment. Not because the technology is bad. Because the mental model hasn't clicked yet. This post fixes that.
📅 Published: May 2026 | ⏱ Reading Time: 18 min | 🎯 Level: Intermediate–Advanced React Developers
Your component tree now spans two environments. Only one of them ships JavaScript.
The Number That Should Alarm Every React Developer
The State of React 2025 survey (3,760 developers, Nov 2025–Jan 2026) dropped this bombshell:
45% of new React projects use Server Components. Of those developers, 67% report negative sentiment.
The survey authors called this "troubling for a set of new APIs that was supposed to pave the way towards React's next big evolution."
But here's the interpretation the headlines missed: this is an education crisis, not a technology crisis.
Shopify's Hydrogen 2 cut per-page JavaScript from 340KB to 89KB with RSC. Meta reported 78% bundle reduction for data-display components. DoorDash saw 65% LCP improvement. These aren't cherry-picked demos — they're production migrations at scale.
The 67% dissatisfaction comes from developers who tried RSC without the right mental model, got confused by Context incompatibility, accidentally marked a root layout 'use client', and watched their 2.1MB bundle stay at 2.1MB.
This post gives you the mental model. The real numbers. The code that works. And the honest assessment of when RSC is the wrong answer.
A Decade of SPA: What We Built and What It Cost Us
The SPA era gave us extraordinary things: instant navigation, rich interactivity, component-driven development. But it came with a structural tax that kept compounding:
The median React app in 2024 shipped 500KB+ of gzipped JavaScript. Users on mid-range devices in India, Southeast Asia, or anywhere with slower connections experienced multi-second blank screens — for content that was mostly static.
The irony: we were shipping the code for rendering product names and blog posts to every device in the world, then waiting for that code to execute before showing the content.
RSC asks the obvious question: why does a product listing component need to run in the browser at all?
What RSC Actually Is (The Distinction That Changes Everything)
Let's kill the most dangerous misconception first:
RSC is NOT the same as Server-Side Rendering (SSR).
This confusion is responsible for most of the frustration in the survey data.
The critical difference: with SSR, your component code still ships to the client for hydration. React renders on the server, sends HTML, then re-runs the same code in the browser to attach event handlers.
With RSC, server components never reach the client. They render on the server, produce an RSC Payload (a compact binary representation of the component tree), and that's it. No hydration. No JavaScript shipped for those components. Ever.
This is why RSC can eliminate 40–75% of your JavaScript bundle — but only if you're intentional about what gets the 'use client' label.
The Three-Layer Architecture of 2026
This three-layer model is what Partial Pre-rendering (PPR) — stable in Next.js 15, default in Next.js 16 — formalizes. A single route can contain all three layers simultaneously, each optimized independently.
The Real Numbers: Production Case Studies
Real migrations. Not benchmarks.
Bundle Size Reductions
| Company / Project | Before | After | Reduction |
|---|---|---|---|
| Shopify Hydrogen 2 | 340KB | 89KB | 74% |
| Meta (Facebook) data-display tree | — | — | 78% |
| Vercel Commerce Template | 210KB | 67KB | 68% |
| OpenMyPro (healthcare, 150K users) | 180KB | 45KB | 75% |
| E-commerce (50K DAU, 4G mobile) | 800KB | 320KB | 60% |
| Ecosire.com (~1,500 pages, Next.js 16) | 340KB | 210KB | 38% |
| HTTP Archive 2025 (App vs Pages Router) | 390KB | 180KB | 54% |
Core Web Vitals
| Metric | CSR Baseline | RSC + Streaming | RSC + PPR |
|---|---|---|---|
| TTFB | 450ms | ~50ms | ~30ms |
| LCP | 4.1s | 1.28s | ~1.1s |
| TTI | 5.8s | 2.1s | 1.8s |
| FID/INP | 180ms | 45ms | 45ms |
Business Impact (Real Data)
DoorDash: 65% LCP reduction on home and store pages; Poor URLs (LCP > 4s) dropped 95%
Preply: INP 250ms → 175ms, estimated $200K/year in additional conversions
OpenMyPro: Lighthouse 78 → 94, contributed to 150K+ users and six-figure ARR
E-commerce case study: Bounce rate -23%, conversion rate +8%, session duration +12%
Meta Quest Store (React Compiler 1.0): Initial loads 12% faster, interactions 2.5× faster
💡 Critical caveat: These results came from architectural rewrites, not surface-level migrations. The common thread: restructure data fetching to be server-side AND keep client components minimal. RSC alone, without restructured data fetching, shows negligible improvement.
The Mental Model That Makes RSC Click
One rule unlocks everything:
"Server Component by default. Add
'use client'only when you need the browser."
You need 'use client' for exactly three things:
State —
useState,useReducerEffects —
useEffect,useLayoutEffect,useRefBrowser APIs —
onClick,onChange,window,localStorage
Everything else — data fetching, layout, static content, heavy libraries — belongs on the server.
The target architecture looks like this:
App (Server)
├── Layout (Server) ← static structure, zero JS
├── Header (Server) ← reads DB for nav items
│ └── MobileMenuButton (Client) ← needs onClick
├── ProductPage (Server) ← async, queries DB directly
│ ├── ProductImages (Client ← needs state for zoom/gallery
│ ├── ProductDetails (Server) ← static markup, zero JS
│ └── AddToCartButton (Client) ← needs onClick + state
└── Footer (Server) ← static markup, zero JS
Result: Only 3 components ship JavaScript. Everything else = zero bytes.
Code: The Patterns That Work
❌ The Wrong Pattern — Everything as a Client Component
// ❌ BAD: Entire blog post as a client component
'use client'
import { useState, useEffect } from 'react'
import { marked } from 'marked' // 45KB — now in your bundle
import { Prism as SyntaxHighlighter } from // 120KB — now in your bundle
'react-syntax-highlighter'
export default function BlogPost({ id }: { id: string }) {
const [post, setPost] = useState(null)
const [loading, setLoading] = useState(true)
// Waterfall: JS loads → mounts → fetch fires → data arrives → renders
useEffect(() => {
fetch(`/api/posts/${id}`)
.then(r => r.json())
.then(data => { setPost(data); setLoading(false) })
}, [id])
if (loading) return <div>Loading...</div>
return (
<article>
<h1>{post.title}</h1>
{/* marked + SyntaxHighlighter both shipped to every user */}
<div dangerouslySetInnerHTML={{ __html: marked(post.content) }} />
</article>
)
}
Cost of this approach: 165KB+ of libraries shipped to every user. Full round-trip waterfall before any content shows. No streaming.
✅ The Right Pattern — Server Component + Minimal Client Island
// ✅ GOOD: BlogPost is a Server Component (no 'use client')
// app/blog/[id]/page.tsx
import { marked } from 'marked' // stays on server — zero bytes shipped
import { Prism as SyntaxHighlighter } from // stays on server — zero bytes shipped
'react-syntax-highlighter'
import { LikeButton } from './LikeButton' // only interactive part is client
import { db } from '@/lib/database' // direct DB access — no API route
export default async function BlogPost({
params
}: {
params: { id: string }
}) {
// Runs on the server — no useEffect, no loading state, no waterfall
const post = await db.posts.findUnique({ where: { id: params.id } })
if (!post) notFound()
// marked runs on the server — zero bytes shipped to client
const htmlContent = marked(post.content)
return (
<article className="prose max-w-3xl mx-auto">
<h1>{post.title}</h1>
<p className="text-gray-500">
By {post.author} · {new Date(post.date).toLocaleDateString()}
</p>
{/* Pure HTML — no markdown library in browser bundle */}
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
{/* Only the interactive button needs 'use client' */}
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
)
}
// app/blog/[id]/LikeButton.tsx — the ONLY client component on this page
'use client'
import { useState } from 'react'
export function LikeButton({
postId,
initialLikes
}: {
postId: string
initialLikes: number
}) {
const [likes, setLikes] = useState(initialLikes)
const [liked, setLiked] = useState(false)
const handleLike = async () => {
if (liked) return
setLiked(true)
setLikes(prev => prev + 1)
await fetch(`/api/posts/${postId}/like`, { method: 'POST' })
}
return (
<button
onClick={handleLike}
disabled={liked}
className={`flex items-center gap-2 px-4 py-2 rounded-full border transition-colors
${liked ? 'text-red-500 border-red-200 bg-red-50' : 'text-gray-500 border-gray-200'}`}
>
❤️ {likes.toLocaleString()}
</button>
)
}
What changed: marked + SyntaxHighlighter (165KB+) never reach the browser. No fetch waterfall. Only LikeButton (~2KB) ships JavaScript.
Parallel Data Fetching: Eliminating the Waterfall
According to SitePoint's 2026 report, most teams only unlock ~30% of RSC's performance potential because of untreated data waterfalls. Here's how to fix it:
// ❌ BAD: Sequential awaits — each blocks the next
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id) // 200ms
const reviews = await getReviews(params.id) // 150ms (waits for product)
const related = await getRelatedProducts(params.id) // 180ms (waits for reviews)
// Total: 530ms before ANY content renders
}
// ✅ GOOD: Parallel with Promise.all
export default async function ProductPage({ params }: { params: { id: string } }) {
const [product, reviews, related] = await Promise.all([
getProduct(params.id), // 200ms
getReviews(params.id), // 150ms ← all run simultaneously
getRelatedProducts(params.id) // 180ms
])
// Total: 200ms (the slowest only)
}
// ✅ EVEN BETTER: Independent Suspense boundaries — each streams as it resolves
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
<Breadcrumb /> {/* renders immediately — no data needed */}
<Suspense fallback={<ProductSkeleton />}>
<ProductDetail id={params.id} /> {/* streams at ~200ms */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews id={params.id} /> {/* streams at ~150ms — independently */}
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts id={params.id} /> {/* streams at ~180ms — independently */}
</Suspense>
</div>
)
}
Partial Pre-rendering (PPR): The Best of Both Worlds
PPR, stable in Next.js 15 and default in Next.js 16, is the culmination of the RSC vision. A single route delivers:
Static shell → prerendered at build time, served from CDN in ~30ms
Dynamic sections → streamed at request time via Suspense boundaries
PPR Request Flow:
───────────────────────────────────────
t=0ms CDN serves static shell (Header + Nav + Sidebar) instantly
t=~30ms Server starts streaming LiveMetrics (parallel)
t=~50ms Server starts streaming PersonalizedFeed (parallel)
t=~80ms Server starts streaming UserNotifications (parallel)
t=~200ms All dynamic content fully rendered
vs. traditional SSR: t=0 → ~450ms before ANY content
───────────────────────────────────────
// app/dashboard/page.tsx — a PPR page
import { Suspense } from 'react'
import { cacheLife, cacheTag } from 'next/cache'
// Static content — prerendered at build time, served from CDN
function DashboardHeader() {
return <header><h1>Dashboard</h1></header>
}
// Cached dynamic content — included in static shell
async function CachedStats() {
'use cache'
cacheLife('hours')
cacheTag('dashboard-stats')
const stats = await db.analytics.getStats()
return <StatsGrid stats={stats} />
}
// Runtime dynamic — streams at request time (user-specific)
async function PersonalizedFeed() {
const user = await getCurrentUser()
const feed = await getFeedForUser(user.id)
return <FeedList items={feed} />
}
export default function DashboardPage() {
return (
<div className="dashboard-layout">
{/* ✅ Static shell — served from CDN instantly */}
<DashboardHeader />
{/* ✅ Cached — included in static shell */}
<CachedStats />
{/* ✅ Dynamic — streams at request time */}
<Suspense fallback={<FeedSkeleton />}>
<PersonalizedFeed />
</Suspense>
</div>
)
}
// next.config.js — enable PPR
const nextConfig = {
experimental: {
ppr: 'incremental', // Route-by-route adoption (Next.js 15)
// ppr: true // Global (Next.js 16 default)
},
}
⚠️ PPR Gotcha #1: Calling
cookies()orgetServerSession()outside a Suspense boundary forces the entire route to dynamic rendering, silently defeating PPR. Push auth into a Suspense boundary.
⚠️ PPR Gotcha #2: Next.js 15 changed
fetch()to uncached by default. Audit everyfetchin your static shell — add explicit{ cache: 'force-cache' }where you want caching.
The 'use cache' Directive: Next.js 16's Caching Revolution
Stable in Next.js 16 (experimental in 15), 'use cache' replaces unstable_cache and caches any async function — not just fetch().
// Cache a database query with granular control
import { cacheLife, cacheTag } from 'next/cache'
async function getProducts(categoryId: string) {
'use cache'
cacheLife('hours') // 5min fresh, 1hr revalidate, 1 day expire
cacheTag('products', `category-${categoryId}`) // tags for on-demand invalidation
return db.products.findMany({
where: { categoryId, active: true },
orderBy: { updatedAt: 'desc' }
})
}
// Invalidate on mutation (Server Action)
'use server'
import { revalidateTag } from 'next/cache'
export async function updateProduct(id: string, data: ProductData) {
await db.products.update({ where: { id }, data })
revalidateTag(`product-${id}`, 'max') // Stale + background regen (SWR pattern)
revalidateTag('products', 'max')
}
// Custom cache profiles in next.config.ts
const nextConfig = {
cacheComponents: true,
cacheLife: {
'product-catalog': { stale: 300, revalidate: 900, expire: 3600 },
'blog-posts': { stale: 3600, revalidate: 86400, expire: 604800 },
'live-data': { stale: 0, revalidate: 30, expire: 120 },
},
}
Server Actions: Mutations Without API Routes
Server Actions replace fetch('/api/...') for mutations. They run on the server, can be called directly from Client Components, and work with useActionState for progressive enhancement.
// app/actions/cart.ts
'use server'
import { z } from 'zod'
import { revalidateTag } from 'next/cache'
// ✅ Always validate — Server Actions are public API endpoints
const AddToCartSchema = z.object({
productId: z.string().cuid(),
quantity: z.number().int().min(1).max(99),
})
export async function addToCart(formData: FormData) {
const result = AddToCartSchema.safeParse({
productId: formData.get('productId'),
quantity: Number(formData.get('quantity')),
})
if (!result.success) {
return { error: 'Invalid input', details: result.error.flatten() }
}
// Verify auth — always check on the server
const session = await getServerSession()
if (!session?.user) {
return { error: 'Authentication required' }
}
await db.cartItems.upsert({
where: { userId_productId: { userId: session.user.id, productId: result.data.productId } },
update: { quantity: { increment: result.data.quantity } },
create: { userId: session.user.id, ...result.data },
})
revalidateTag(`cart-${session.user.id}`, 'max')
return { success: true }
}
// app/components/AddToCartButton.tsx
'use client'
import { useActionState } from 'react' // React 19 — replaces useFormState
import { addToCart } from '@/app/actions/cart'
export function AddToCartButton({ productId }: { productId: string }) {
const [state, formAction, isPending] = useActionState(addToCart, null)
return (
<form action={formAction}>
<input type="hidden" name="productId" value={productId} />
<input type="hidden" name="quantity" value="1" />
<button type="submit" disabled={isPending} className="btn-primary">
{isPending ? 'Adding...' : 'Add to Cart'}
</button>
{state?.error && <p className="text-red-500 text-sm mt-1">{state.error}</p>}
</form>
)
}
⚠️ Security Alert: CVE-2025-55182 — CVSS 10.0 Critical
In December 2025, a critical remote code execution vulnerability was discovered in React Server Components.
Severity: CVSS 10.0 (maximum possible score) Type: Pre-authentication Remote Code Execution Affected: React 19.0.0–19.2.0, Next.js 14.3.0-canary.77 through 15.x/16.x (pre-patch) Fixed in: React 19.0.1+, Next.js 15.0.5+, 16.0.7+
Root cause: Unsafe deserialization of HTTP request payloads to Server Function endpoints. The requireModule function performed prototype chain lookup instead of checking own properties, allowing attackers to shadow hasOwnProperty with a malicious reference — giving access to constructor, __proto__, and RCE gadgets like child_process.execSync.
Attack vector: A single maliciously crafted HTTP POST request to any Server Function endpoint. No credentials required. No user interaction needed.
Real-world impact: ~145 in-the-wild proof-of-concept exploits identified. Listed in CISA KEV database. EPSS score: 84% probability of exploitation.
// ❌ VULNERABLE: Passing unsanitized input to server operations
'use server'
export async function searchProducts(query: string) {
// If query contains injection payload, this is exploitable
const results = await db.raw(`SELECT * FROM products WHERE name LIKE '%${query}%'`)
return results
}
// ✅ SAFE: Always validate and sanitize with Zod + parameterized queries
'use server'
import { z } from 'zod'
const SearchSchema = z.object({
query: z.string().max(100).regex(/^[\w\s]+$/)
})
export async function searchProducts(formData: FormData) {
const { query } = SearchSchema.parse({ query: formData.get('query') })
// Parameterized query — never string interpolation
return db.products.findMany({
where: { name: { contains: query } }
})
}
🔐 The rule: Treat every Server Action like a public API endpoint. Validate all inputs with Zod. Use parameterized queries. Never trust data from the client — even in Server Actions. Upgrade to React 19.0.1+ and Next.js 15.0.5+ immediately if you haven't.
The 'use client' Propagation Trap (The 2.1MB Bug)
This is the single most common RSC mistake. It's responsible for teams seeing zero improvement after "migrating to RSC":
// ❌ CATASTROPHIC: 'use client' on the root layout
// app/layout.tsx
'use client' // ← Added because ThemeProvider uses useState
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html><body><ThemeProvider>{children}</ThemeProvider></body></html>
)
}
What happens: 'use client' propagates downward through the entire component tree. Every page, every component, every import becomes a Client Component. The RSC runtime is active but never used. Bundle stays at 2.1MB.
The fix — isolate the client-only piece:
// app/providers/ThemeProvider.tsx — isolated client wrapper
'use client'
import { createContext, useState } from 'react'
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
// app/layout.tsx — NO 'use client' here
import { ThemeProvider } from './providers/ThemeProvider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{/* ThemeProvider is 'use client', but layout itself is NOT */}
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
Result: Bundle drops from 2.1MB to 680KB. LCP improves from 3.2s to 1.4s. Same functionality.
'use client' vs 'use server' — Complete Reference
React Compiler + RSC: A Synergistic Combination
The React Compiler (v1.0, October 2025) and RSC work together, not in competition:
RSC reduces the volume of code running on the client — entire component subtrees eliminated from the bundle
React Compiler reduces the frequency of re-renders within the Client Components that remain — automatic memoization without
useMemo/useCallback
Per React team benchmarks from React Conf 2024: enabling the Compiler on a production app that was already well-memoized by hand reduced re-renders by an additional 22%. Manual memoization, even by experienced engineers, leaves significant optimization unrealized.
A well-structured RSC app: 60–70% Server Components (zero re-render cost) + 30–40% Client Components (Compiler-optimized).
You can stop writing useMemo and useCallback. You can stop writing data-fetching useEffect. RSC + Compiler eliminates two of the biggest sources of React complexity simultaneously.
TanStack Start vs Next.js: The RSC Landscape in 2026
The RSC ecosystem is no longer a Next.js monopoly.
| Aspect | Next.js 16 | TanStack Start |
|---|---|---|
| RSC support | ✅ Full production | 🔄 Experimental (April 2026) |
| Build tool | Turbopack | Vite |
| Dev startup | 10–12s | 2–3s |
| HMR speed | ~836ms | ~335ms |
| Bundle (hello world) | 80–95KB | 45–60KB |
| Deployment | Optimized for Vercel | Any host (Nitro runtime) |
| Type safety | Partial | End-to-end (TanStack Router) |
| Ecosystem | Massive | Growing |
| Memory behavior | Linear growth in K8s | Standard Node.js |
Next.js remains the default for content-heavy apps, large teams, and Vercel deployments. TanStack Start is gaining ground for teams that value type safety, deployment flexibility, and faster development iteration.
🔥 Hot Take from Tanner Linsley: "RSC is a protocol, not an architecture. Frameworks are using RSC primitives in the way that has been revealed to them thus far — which is fine if that model covers your use cases. Ours needed more." TanStack Start treats RSC as one tool in the pipeline, not the entire pipeline.
When NOT to Use RSC
RSC is not a silver bullet. Here's the honest assessment:
| Scenario | RSC Value | Recommendation |
|---|---|---|
| Content-heavy pages (blogs, docs, listings) | 🟢 Very High | Use RSC aggressively |
| Marketing / landing pages | 🟢 Very High | RSC + PPR |
| E-commerce product pages | 🟢 High | RSC with client islands |
| Data dashboards (mostly read-only) | 🟡 Medium | RSC for data, client for charts |
| Admin panels with complex filters | 🟡 Low | Mostly client, RSC for shell |
| Real-time features (chat, live feeds) | 🔴 None | Client + WebSocket |
| Drag-and-drop UIs | 🔴 None | Client Components |
| Offline-first apps | 🔴 None | Client Components |
| Authenticated SPAs (no SEO needed) | 🟡 Low | Consider plain SPA + API |
🔥 Hot Take: If your app is 80%+ interactive — think Figma, Google Docs, or a complex real-time dashboard — RSC adds architectural complexity without proportional performance gains. A plain SPA with a separate API is sometimes the right answer. The 67% dissatisfaction rate in the survey is largely from developers applying RSC to the wrong problem.
The Ecosystem Reality Check
State of React 2025 — The Honest Numbers
───────────────────────────────────────
React 19 daily usage: 48.4% of respondents
SPA still dominant: 84.5% of projects
RSC adoption: 45% of new projects
RSC positive sentiment: 33%
RSC negative sentiment: 67%
Top RSC pain points:
├── Context API incompatibility(59 mentions — #1 complaint)
├── Testing complexity(24 mentions)
├── Debugging difficulty
├── Too many directives
└── Excessive complexity
Framework sentiment:
├── TanStack Query: 68% usage, 42% positive, 1% negative
├── Next.js: 80% usage, 27% positive, 17% negative
└── React Compiler: 62% excited (top upcoming feature)
────────────────────────────────────────
The community friction is real. Context API incompatibility is the #1 pain point — many state managers, UI libraries, and theme providers use React Context internally, requiring 'use client' wrapper files. Testing RSC components requires different tooling. Error messages are still improving.
But the direction is clear: Next.js 16 makes App Router the only recommended path. TanStack Start is adding RSC support. Remix/React Router v7 is adding it. The ecosystem is converging.
Migration Checklist: Pages Router → App Router
Phase 1: Low-Risk Wins (Week 1–2)
☐ Pick one content-heavy route (blog, product listing, landing page)
☐ Move it to App Router as a Server Component
☐ Replace useEffect data fetching with async Server Component
☐ Add Suspense boundaries around slow data sections
☐ Measure: bundle size + LCP before/after with @next/bundle-analyzer
☐ If no improvement → check for 'use client' leaking up the tree
Phase 2: Architectural Changes (Week 3–4)
☐ Identify true 'use client' candidates (state/effects/events only)
☐ Push 'use client' to leaf components (buttons, inputs, modals)
☐ Replace Context providers with isolated 'use client' wrapper files
☐ Replace API routes with Server Actions for mutations
☐ Add Zod validation to every Server Action
☐ Upgrade to React 19.0.1+ and Next.js 15.0.5+ (CVE-2025-55182 patch)
Phase 3: Advanced Optimization (Month 2)
☐ Add 'use cache' directives for expensive DB queries
☐ Configure cacheLife profiles for your domain
☐ Add cacheTag for on-demand invalidation after mutations
☐ Enable PPR for high-traffic routes
☐ Audit Suspense boundary granularity (3–5 per route max)
☐ Set up bundle size CI gates with bundlesize
The 5 RSC Rules to Memorize
Further Reading
State of React 2025 Survey Results — The data behind this post
Next.js PPR Documentation — Official PPR guide
Next.js 'use cache' Directive — Official reference
CVE-2025-55182 Security Advisory — React team
RSC Performance Deep Dive — Nadia Makarevich
React 19 RSC Migration Guide — Ecosire
RSC as a Protocol, Not an Architecture — Tanner Linsley / TanStack
React 19 Server Components Cut Bundle 60% — Production case study
OpenMyPro Healthcare RSC Case Study — Pablo Diaz