The web has come a long way since the early days of Multi-Page Applications
(MPAs). What started as simple server-rendered HTML has evolved through several
architectural paradigms, each solving problems introduced by the previous
approach. Today, React Server Components (RSCs) represent what might be the most
significant leap forward yet.
Let me take you through this complete journey and show you why each step
mattered.
The Early Web: Multi-Page Applications
In the beginning, there were Multi-Page Applications. Each user interaction
resulted in a full page reload, with the server generating complete HTML
documents.
Here's what a simple counter app looked like in those days:
app.get('/', async (c) => {
const count = await db.getCount()
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MPA Counter</title>
<form action="/update-count" method="POST">
<button type="submit" name="change" value="-1">Decrement</button>
<button type="submit" name="change" value="1">Increment</button>
app.post('/update-count', async (c) => {
const formData = await c.req.formData()
const change = Number(formData.get('change'))
await db.changeCount(change)
The Good:
- Simple and straightforward
- Progressive enhancement worked out of the box
- No JavaScript required
- Great for SEO and accessibility
The Problems:
- Poor user experience with full page reloads
- High server load generating entire pages
- Difficult to maintain complex state
- No way to provide immediate feedback
The POST → Redirect → GET pattern was a common solution to prevent duplicate
form submissions, but it still required full page reloads.
The React Revolution: Single-Page Applications
React introduced a paradigm shift. Instead of server-rendered HTML, we moved to
client-side JavaScript that managed the entire application state.
Here's how our counter app evolved:
export function Counter() {
const [state, dispatch] = useReducer(countReducer, {
async function fetchCount() {
dispatch({ type: 'FETCH_START' })
const response = await fetch('/count')
if (!response.ok) throw new Error('Failed to fetch count')
const data = await response.json()
dispatch({ type: 'FETCH_SUCCESS', payload: data.count })
dispatch({ type: 'FETCH_ERROR', payload: err.message })
async function updateCount(change) {
dispatch({ type: 'UPDATE_START' })
const response = await fetch('/update-count', {
body: JSON.stringify({ change }),
headers: { 'Content-Type': 'application/json' },
if (!response.ok) throw new Error('Failed to update count')
const { count } = await response.json()
dispatch({ type: 'UPDATE_SUCCESS', payload: count })
dispatch({ type: 'UPDATE_ERROR', payload: err.message })
if (state.loading && state.count === null) return <div>Loading...</div>
if (state.error) return <div>Error: {state.error}</div>
<h1>Count: {state.count}</h1>
<div style={{ opacity: state.loading ? 0.6 : 1 }}>
<button onClick={() => updateCount(-1)}>Decrement</button>
<button onClick={() => updateCount(1)}>Increment</button>
The Good:
- Smooth, responsive user experience
- No full page reloads
- Rich interactive interfaces
- Better state management
The Problems:
- Lost progressive enhancement
- Complex network state management
- Large JavaScript bundles
- Poor initial page load performance
- SEO challenges
Notice how much more code we now need just to handle network requests! We have
to manage loading states, error states, and manually wire up all the data
fetching logic.
The Framework Solution: Progressive Enhancement SPAs
Frameworks like Remix emerged to solve these problems by bringing back
progressive enhancement while maintaining the benefits of SPAs. They introduced
concepts like loaders and actions to handle data fetching and mutations.
export async function loader() {
const db = await import('../db.js')
return { count: await db.getCount() }
export async function action({ request }) {
const db = await import('../db.js')
const formData = await request.formData()
const change = Number(formData.get('change'))
await db.changeCount(change)
export function Counter() {
const data = useLoaderData()
const fetcher = useFetcher()
<fetcher.Form method="POST" action="/counter">
<h1>Count: {data.count}</h1>
<div style={{ opacity: fetcher.isPending ? 0.6 : 1 }}>
<button type="submit" name="change" value="-1">
<button type="submit" name="change" value="1">
The Good:
- Progressive enhancement restored
- Much less network management code
- Better performance with server-side rendering
- Forms work without JavaScript
The Problems:
- Route-based composition (mixing routes and components)
- Still need to manually wire up data fetching
- Framework-specific patterns to learn
- Limited flexibility in data composition
This approach significantly reduced the network management code, but it
introduced a new problem: the composition boundary was now the route, not the
component.
The Modern Solution: React Server Components
React Server Components represent a fundamental shift back to component-based
composition while eliminating the need for custom network management code
entirely.
import * as db from '../db.js'
import { PendingDiv } from './pending-form.js'
export async function updateCount(formData) {
const change = Number(formData.get('change'))
await db.changeCount(change)
return { status: 'success', message: 'Success!' }
return { status: 'error', message: error?.message || String(error) }
export async function Counter() {
const count = await db.getCount()
<form action={updateCount}>
<button type="submit" name="change" value={-1}>
<button type="submit" name="change" value={1}>
And the client component for pending state:
import { useFormStatus } from 'react-dom'
export function PendingDiv(props) {
const { pending } = useFormStatus()
return <div style={{ opacity: pending ? 0.6 : 1 }} {...props} />
The Good:
- Component-based composition restored
- No custom network management code needed
- Progressive enhancement by default
- Server and client code clearly separated
- Optimal performance with server-side rendering
- Forms work without JavaScript
- Pending states handled automatically
The Problems:
- New mental model to learn
- Different boundaries between server and client
- Some ecosystem tools still catching up
Why This Journey Matters
Each step in this evolution solved real problems while introducing new
challenges that the next step would address:
- MPAs were simple but provided poor user experience
- SPAs improved UX but lost progressive enhancement and required complex
network management
- PESPAs restored progressive enhancement but mixed compositional paradigms
- RSCs bring back component-based composition while eliminating network
boilerplate
The key insight is that React Server Components aren't just another framework
feature—they're a fundamental rethinking of how we compose applications. By
moving the composition boundary back to the component level and eliminating the
need for custom network management code, RSCs give us the best of all previous
approaches:
- The simplicity of MPAs
- The interactivity of SPAs
- The progressive enhancement of PESPAs
- The composition model of React
The Network Vanishes
The most remarkable thing about React Server Components is how they make network
management code disappear. In the early React days, we had to manually manage:
- Loading states
- Error states
- Data fetching logic
- Form submission handling
- Optimistic updates
- Cache invalidation
With RSCs, the framework handles all of this automatically. Server components
can fetch data directly, and server actions handle mutations with built-in
pending states and error handling.
This isn't just about reducing boilerplate—it's about eliminating entire
categories of bugs and complexity that developers have been wrestling with for
years.
Looking Forward
React Server Components represent what might be the most significant
architectural advancement in web development since React itself. They solve the
fundamental tension between server-side rendering and client-side interactivity
by making the distinction explicit and manageable.
As the ecosystem continues to mature around RSCs, we're seeing a new generation
of applications that combine the best aspects of all previous approaches. The
network management code that once dominated our applications is finally
vanishing, replaced by a simpler, more powerful model.
The journey from MPAs to RSCs shows us that web development is constantly
evolving, with each step building on the lessons learned from previous
approaches. React Server Components aren't just the latest trend—they're the
natural evolution of web architecture, bringing us closer to the ideal of
simple, performant, and maintainable web applications.
This article is based on the
"React and the Vanishing Network" workshop,
which provides hands-on experience with each of these architectural approaches.
You can explore the complete code examples and see the evolution in action. You
can also watch a talk version of this workshop
from my talk at React Paris 2025