React Server Components

May 14, 2025

React Server Components (RSC) is this new cool react concept that is really powerful and simple to get started with, but has some gotchas that you need to be aware of, in-order to have a smooth ride with it.

Just for fun here are bunch of questions try answering them as per your current understanding of RSC. And then the goal of the rest of the text is to justify what the answers are and why.

Quiz

Quiz section goes here

1/ 11

RSC and SSR are the same thing.

True
False

Server Side Rendering

One important thing to realize is that React Server Components (RSC) and Server Side Rendering (SSR) are two different things that happen to work together really nicely. Just from the timeline point of view, SSR has existed since the start of the world wide web (early 1990's).

While RSC was first introduced in late 2020 and have become stable in Dec 2024 with React version 19.

SSR Vs RSC Timeline

The general idea with SSR is that when a client requests a page from the server, server processes the request and spits out the HTML file back to the client.

Now SSR is a broad term and can have many different variations (just like Ravi Ashwin 😀):

  1. On-Demand SSR: A new HTML page is generated and send for each request.

  2. Static Side Generation (SSG): The HTML pages are generated at the build time once and that same page is severed for each request.

  3. Incremental Static Regeneration (ISR): It is a mix of both on demand SSR and SSG. Initially the page is cached for a certain configurable amount of time, and till that time the page is served from the cache (similar to SSG), after that time has passed for the next request the page will be generated again on the server (similar to on demand SSR), and will be added to the cache.

Now in-depth explanation of all these variations and there implementation is beyond the scope of this post. But the takeaway remains the same: When a client requests a page from the server, server processes the request and spits out the HTML file back to the client.

Now before understanding what RSC is, let's discuss the available rendering options without RSC and then we'll see where RSC fits in.

Rendering Till Now

There are multiple rendering strategies but here we will cover the rendering strategies for:

  • Multi Page Applications (MPA)
  • Single Page Application (SPA)
  • Server Rendered Single Page Applications (SSR'd SPA)

MPA

For a traditional MPA, the rendering is pretty straight forward.

  • Client visits acme.com, server send the pre-rendered HTML file to the client.
  • Client click on the profile page, the server will query the database for the users info, generate the profile page and send it back to the client.

The Good

  • Fast initial content display (quick FCP)
  • Works without JavaScript
  • Excellent SEO by default
  • Low client-side resource requirements
  • Simple mental model for developers
  • Highly reliable across all browsers and devices
  • Predictable page life cycle

This is great right ? What's the problem here ?

The problem occurs when the user starts interacting with the website.
As they click through different pages, the entire flow repeats again, i.e., a full page reload will happen. The server will query the database and then send over the HTML file back. And till then the client waits there with no visual feedback except for the browser's tab loading spinner, they will only see any change once the full page data of the requested page is send over by the server.

It is called Multi Page for a reason since each page request on client needs a new page from the server.

So what exactly is bad here ?

The Bad

  • Context Switching: User loses visual context between pages
  • Repetitive Loading: Same header/footer/navigation reloads on every page
  • Discontinuous Experience: Feels disconnected between pages
  • No Visual Feedback on Navigation: The users stays at the same page, until the new page loads
  • Slower Perceived Performance: Even if actual load time is fast, the visual reset feels slower
  • Repeated Asset Downloads: Common elements must be re-downloaded (though browser caching helps)
  • Building Rich Internet Applications: Building RIA's like Real-time dashboard, Collaborative editing tools, or Interactive map applications are a nightmare with MPA's.

TL;DR for MPA

First Page Load: 🤩
Subsequent Page Interactions: 💩

SPA

Let's now consider a simple React application and look at the page rendering:

On first page load

In a Single Page Application (SPA), the server sends minimal HTML and extensive JavaScript.
This JavaScript executes client-side, building the page's HTML structure and inserting it into the DOM.

The generated HTML is visible in the browser's DOM Inspector, but absent from the initial page source. Why?

Since page source shows the minimal HTML send over by the server and DOM inspector contains the final DOM that was constructed on the client.

Here's the flow:

  • Browser requests the URL (acme.com)

  • Server returns a minimal HTML shell with JavaScript references
    Usually looks like:

    <!doctype html>
    <html lang="en">
    <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Acme</title>
    </head>
    <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
    </body>
    </html>

    This is the same HTML that you get for all the pages, hence the name single page application

  • Browser downloads React and application JavaScript bundles

  • React initializes and mounts the virtual DOM

  • Client makes API requests to server

  • Server queries the database and returns JSON data

  • React renders components as data becomes available

And all this while, till the components render the user have to stare at a empty white screen. And since we need all the JS bundles to be downloaded for the components to render, If the user disables there JavaScript from the browser then the page will never be loaded.

On subsequent page navigation

  • React unmounts previous components and mounts new ones
  • API requests fetch new data if needed
  • React updates only the changed portions of the DOM

The Good

  • Continuous Experience: No jarring white screen between views
  • Maintained Context: Global elements remain stable during navigation
  • Faster Perceived Navigation: After initial load, page transitions feel instant
  • Preserved State: Form inputs, scroll positions can be maintained
  • Rich Interactions: Complex UI behaviors are easier to implement
  • Background Processing: Can perform operations without blocking the UI
  • Reduced Data Transfer: Only necessary data transferred after initial load

The Bad

  • Slow Initial Load: First page load takes longer as entire application must initialize
  • JavaScript Dependency: Requires JavaScript to function at all
  • Loading States: Users see placeholders rather than content initially
  • Memory Usage: Can consume significant client-side resources over time
  • SEO Challenges: Content not present in initial HTML (without special handling)

TL;DR for SPA

First Page Load: 💩
Subsequent Page Interactions : 🤩

SRR'd SPA

Now to improve on the first load experience of SPA, people though instead of sending the bare minimum HTML, let's send the fully formed HTML to the client.

And to do that we need to run React on the server. To demonstrate this let's use client components in next.js.

Consider the following component:

client/page.tsx
'use client'
const Client = () => {
return (
<div>
<h1>This is a client component</h1>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus,
dignissimos.
</p>
</div>
)
}
export default Client

Here's how the flow goes:

On Server

  • The client component is rendered on the server using React's API

    This is what is send over to the client

<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="stylesheet"
href="/_next/static/css/app/layout.css?v=1742584290653"
data-precedence="next_static/css/app/layout.css"
/>
<link
rel="preload"
as="script"
fetchPriority="low"
href="/_next/static/chunks/webpack.js?v=1742584290653"
/>
<script
src="/_next/static/chunks/main-app.js?v=1742584290653"
async=""
></script>
<script src="/_next/static/chunks/app-pages-internals.js" async=""></script>
<script src="/_next/static/chunks/app/client/page.js" async=""></script>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16" />
<script src="/_next/static/chunks/polyfills.js" nomodule=""></script>
</head>
<body class="__className_d65c78">
<div>
<h1>This is a client component</h1>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus,
dignissimos.
</p>
</div>
<script
src="/_next/static/chunks/webpack.js?v=1742584290653"
async=""
></script>
<script>
;(self.__next_f = self.__next_f || []).push([0])
self.__next_f.push([2, null])
</script>
<script>
self.__next_f.push([
1,
'1:HL["/_next/static/media/a34f9d1faa5f3315-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]\n2:HL["/_next/static/css/app/layout.css?v=1742584290653","style"]\n0:D{"name":"r1","env":"Server"}\n',
])
</script>
<script>
self.__next_f.push([
1,
'3:I["(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n5:I["(app-pages-browser)/./node_modules/next/dist/client/components/client-page.js",["app-pages-internals","static/chunks/app-pages-internals.js"],"ClientPageRoot"]\n6:I["(app-pages-browser)/./app/client/page.tsx",["app/client/page","static/chunks/app/client/page.js"],"default"]\n7:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n8:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\nd:I["(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n4:D{"name":"","env":"Server"}\n9:D{"name":"RootLayout","env":"Server"}\na:D{"name":"NotFound","env":"Server"}\na:[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\\"Segoe UI\\",Roboto,Helvetica,Arial,sans-serif,\\"Apple Color Emoji\\",\\"Segoe UI Emoji\\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children",
])
</script>
<script>
self.__next_f.push([
1,
'en":"This page could not be found."}]}]]}]}]]\n9:["$","html",null,{"lang":"en","children":["$","body",null,{"className":"__className_d65c78","children":["$","$L7",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L8",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$a","notFoundStyles":[],"styles":null}]}]}]\nb:D{"name":"rQ","env":"Server"}\nb:null\nc:D{"name":"","env":"Server"}\ne:[]\n0:[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/app/layout.css?v=1742584290653","precedence":"next_static/css/app/layout.css","crossOrigin":"$undefined"}]],["$","$L3",null,{"buildId":"development","assetPrefix":"","initialCanonicalUrl":"/client","initialTree":["",{"children":["client",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],"initialSeedData":["",{"children":["client",{"children":["__PAGE__",{},[["$L4",["$","$L5",null,{"props":{"params":{},"searchParams":{}},"Component":"$6"}]],null],null]},["$","$L7",null,{"parallelRouterKey":"children","segmentPath":["children","client","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L8",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","styles":null}],null]},["$9",null],null],"couldBeIntercepted":false,"initialHead":["$b","$Lc"],"globalErrorComponent":"$d","missingSlots":"$We"}]]\n',
])
</script>
<script>
self.__next_f.push([
1,
'c:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Create Next App"}],["$","meta","3",{"name":"description","content":"Generated by create next app"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]]\n4:null\n',
])
</script>
</body>
</html>

Yeah this is a lot of gibberish that I just threw at you. But here are the important parts:

  1. All the content is pre-rendered:
<div>
<h1>This is a client component</h1>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus,
dignissimos.
</p>
</div>

  1. One of the script tag has reference to the page where the client component is located client/page.js
<script>
self.__next_f.push([
1,
'3:I["(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n5:I["(app-pages-browser)/./node_modules/next/dist/client/components/client-page.js",["app-pages-internals","static/chunks/app-pages-internals.js"],"ClientPageRoot"]\n6:I["(app-pages-browser)/./app/client/page.tsx",["app/client/page","static/chunks/app/client/page.js"],"default"]\n7:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n8:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\nd:I["(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n4:D{"name":"","env":"Server"}\n9:D{"name":"RootLayout","env":"Server"}\na:D{"name":"NotFound","env":"Server"}\na:[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\\"Segoe UI\\",Roboto,Helvetica,Arial,sans-serif,\\"Apple Color Emoji\\",\\"Segoe UI Emoji\\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children",
])
</script>

If you scroll a bit you would see a script with \n6:I["(app-pages-browser)/./app/client/page.tsx", which is the reference to the page containing the client component.

But why do we need this? Haven't the server already send over the HTML content?

Let's see the what happens when this page reaches the client (browser) to answer that.

On Client

  • A non-interactive preview HTML from server is shown and the initial DOM tree is created. After the JS bundle is loaded, hydration begins:

    • React performs reconciliation by walking through the existing DOM and constructing a matching Virtual DOM representation by executing the client components on the browser (Remember the reference in the RSC payload?)

    • React is performing reconciliation during hydration in a special way, instead of generating new DOM nodes, it's "adopting" the existing server-rendered DOM while building its internal Virtual DOM representation to match

    • React adds event listeners and sets up component state

    • This reconciliation process during hydration creates an internal map of your components and their DOM elements, which React uses to efficiently update only specific parts of the page when state changes later

  • Now the application is fully interactive

On subsequent load:

Since the JavaScript bundle is loaded. Client components will renderer entirely on the client and so no SSR'd HTML this time.

But in these example we assumed that the page that the user is trying to access is a static page. What if the page requires some personalized data to render completely (e.g. user's profile page) ?

  • User visits acme.com/username/profile, server sends the pre-rendered HTML to the client
  • Browser download JS bundle in background
  • Now we realize that we need more data to render the component, browser sends another request to the server
  • Server returns JSON blob, the components gets rendered.

Now in this case the server render HTML, acts as a skeleton while the JS loads and api call is done. Which is an improvement from the user seeing a white screen, but still is not a massive improvement.

What if we can also fetch the data required by the page while server rendering the page and send a truly fully formed HTML to the client.

And this makes sense since we are on the server land, we can access the database here and send the HTML will all the data required, instead of making another api request while rendering the page on the client.

And this is problem React Server Components (RSC) solves. And that is the reason SRR + RSC is such a neat combination.

React Server Components (RSC)

Now the big promise that RSC's make is that it can run on the server and can access backend related things (like databases, file system ,private environment variable, etc) securely.

So now something like this is possible:

import db from './database';
async function Note({id}) {
const note = await db.notes.get(id);
return (
<div>
<Author id={note.authorId} />
<p>{note}</p>
</div>
);
}
async function Author({id}) {
const author = await db.authors.get(id);
return <span>By: {author.name}</span>;
}

We can now access database directly from a react component, without the need of writing any API endpoint, without ever crossing the client - server boundary (Which can bring in considerable amount of latency).

But all if this is not for free. Since we are now able to access these resources that only a backend server should have access to, hence for security and implementation we can't run this on the client.

Hence the rule: Server Components only run on the server and are never re-rendered.

Now what does this mean from the interactivity point of view?
Since server components can't re-render, we can't use a lot of React's API that we are used to.

And it makes sense:
We can't use the useState hook, since it makes the component re-render when the state changes, but server components can't re-render. Hence theres not point having a useState hook.

Similarly we can't use the useEffect hook since it only runs on the client after render, and since server components are never rendered on the client. We can't use useEffect.

Now this might sound really limiting, that we can't use the usual interactivity in server components, that we love react for. So are we going back to the MPA days with server components?

No, since we can weave together client and server components as we like and use server components to predominately fetch data closer to the database, and then use that data in the client components if we want interactivity.

Now here is the important point about client and server component:

Since we are server rendering the application, both client and server components run on the server. But server components never render on the client.

RSC does not change the exiting mental model, but add this new server layer. And props always pass from the server to client.

Client Server

Image credit: github/reactwg

Server components rendering

Let's consider a simple react server component:

server/page.tsx
const Server = () => {
return (
<div>
<h1>This is a server component</h1>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus,
dignissimos.
</p>
</div>
)
}
export default Server

For rendering server components they are split into chunks, based on suspense boundary or route segments. Let's see the rendering flow for the above react server component.

On Server:

  • Server Component code is rendered as RSC Payload using React's API

  • Now this RSC payload and the client JS instruction is used to generate HTML on the server.

    Here's what it would look like:

<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="stylesheet"
href="/_next/static/css/app/layout.css?v=1742580302168"
data-precedence="next_static/css/app/layout.css"
/>
<link
rel="preload"
as="script"
fetchPriority="low"
href="/_next/static/chunks/webpack.js?v=1742580302168"
/>
<script
src="/_next/static/chunks/main-app.js?v=1742580302168"
async=""
></script>
<script src="/_next/static/chunks/app-pages-internals.js" async=""></script>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16" />
<meta name="next-size-adjust" />
<script src="/_next/static/chunks/polyfills.js" nomodule=""></script>
</head>
<body class="__className_d65c78">
<div>
<h1>This is a server component</h1>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus,
dignissimos.
</p>
</div>
<script
src="/_next/static/chunks/webpack.js?v=1742580302168"
async=""
></script>
<script>
;(self.__next_f = self.__next_f || []).push([0])
self.__next_f.push([2, null])
</script>
<script>
self.__next_f.push([
1,
'1:HL["/_next/static/media/a34f9d1faa5f3315-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]\n2:HL["/_next/static/css/app/layout.css?v=1742580302168","style"]\n0:D{"name":"r1","env":"Server"}\n',
])
</script>
<script>
self.__next_f.push([
1,
'3:I["(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n6:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n7:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\nc:I["(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n4:D{"name":"","env":"Server"}\n5:D{"name":"Server","env":"Server"}\n5:["$","div",null,{"children":[["$","h1",null,{"children":"This is a server component"}],["$","p",null,{"children":"Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus, dignissimos."}]]}]\n8:D{"name":"RootLayout","env":"Server"}\n9:D{"name":"NotFound","env":"Server"}\n9:[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\\"Segoe UI\\",Roboto,Helvetica,Arial,sans-serif,\\"Apple Color Emoji\\",\\"Segoe UI Emoji\\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]',
])
</script>
<script>
self.__next_f.push([
1,
']\n8:["$","html",null,{"lang":"en","children":["$","body",null,{"className":"__className_d65c78","children":["$","$L6",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$9","notFoundStyles":[],"styles":null}]}]}]\na:D{"name":"rQ","env":"Server"}\na:null\nb:D{"name":"","env":"Server"}\nd:[]\n0:[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/app/layout.css?v=1742580302168","precedence":"next_static/css/app/layout.css","crossOrigin":"$undefined"}]],["$","$L3",null,{"buildId":"development","assetPrefix":"","initialCanonicalUrl":"/server","initialTree":["",{"children":["server",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],"initialSeedData":["",{"children":["server",{"children":["__PAGE__",{},[["$L4","$5"],null],null]},["$","$L6",null,{"parallelRouterKey":"children","segmentPath":["children","server","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","styles":null}],null]},["$8",null],null],"couldBeIntercepted":false,"initialHead":["$a","$Lb"],"globalErrorComponent":"$c","missingSlots":"$Wd"}]]\n',
])
</script>
<script>
self.__next_f.push([
1,
'b:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Create Next App"}],["$","meta","3",{"name":"description","content":"Generated by create next app"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]]\n4:null\n',
])
</script>
</body>
</html>

Again! theres a lot of things here, but here are the things that interests us:

  1. All the HTML for the server rendered HTML:
<div>
<h1>This is a server component</h1>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus,
dignissimos.
</p>
</div>
  1. Script containing all the contents as well:
<script>
self.__next_f.push([
1,
'3:I["(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n6:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n7:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\nc:I["(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n4:D{"name":"","env":"Server"}\n5:D{"name":"Server","env":"Server"}\n5:["$","div",null,{"children":[["$","h1",null,{"children":"This is a server component"}],["$","p",null,{"children":"Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus, dignissimos."}]]}]\n8:D{"name":"RootLayout","env":"Server"}\n9:D{"name":"NotFound","env":"Server"}\n9:[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\\"Segoe UI\\",Roboto,Helvetica,Arial,sans-serif,\\"Apple Color Emoji\\",\\"Segoe UI Emoji\\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]',
])
</script>

But why are we sending HTML content twice ?

The first HTML content is used to render a non-interactive page on load instantly. And since we have the rule that server components can't run on the client, so we need to send DOM information of the server rendered HTML for react to function on the client. Let's see how client uses all these information 👇

On Client:

  • The non-interactive preview HTML from the server is shown and creates an initial DOM tree.
  • After the JS bundle loads - DOM Adoption (no hydration): React "adopts" the existing DOM nodes rather than recreating them. It doesn't need to add event handlers because server components don't have interactive elements.

TL;DR for RSC

First Page Load: 🤩
Subsequent Page Interactions : 🤩

Ok that was the rendering cycle for a really simple server component.

We know that a react component re-renders when its prop changes.

When considering server components, have you wondered what will happen if one of its props change, how will server components handle this case.

Consider this server component:

function Count({count}) {
return (
<div>Count: {count}</div>
)
}

Just think about, what will happen if the count prop changes.
Since Count is a server component, it can't re-render. But wait, who's passing the count props to the Count component.
Let's say that the component passing the count prop is a server component, but since server components can't re-run and count won't update in a server component. So the props can only change if the parent is client component.

But if we allow client component to be the parent of server component. Then server component will have to re-render, which is against the rules.

Hence the rule:
All the components that a client component owns, will be considered as a client component, whether or not they have the use client directive.

So in next.js all the components by default are considered server components. And in a way we are creating a client boundary when using the use client directive, causing all of its decedents to be client components.

You can think of use client as the gateway from server to client, anything below it will be considered a client component. So even though use client is used to create client components, it's more to say that all the components owned by this component are client component.

Hence the image below, all the decedents of the Article component are client components, even though they don't have the use client directive.

Client Boundary

Image Credit: joshwcomeau

This is why it is recommend to create client components on the leaf end of your DOM tree.

Also these different child components form a different JS bundle chunk, which called code splitting hence improved performance.

There is a similar logic for use server, since all the components in next.js are server components by default, you don't have to declare it above your server components files. But it is used as server boundary when defining server actions.

Rendering a page with server component importing client component

Consider the following files:

app/client-in-server/page.tsx
import ClientComponent from '@/components/client-component'
const OwnerServer = () => {
return (
<div>
<h1>This is a owner server component</h1>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus,
dignissimos.
</p>
<ClientComponent />
</div>
)
}
export default OwnerServer
components/client-component.tsx
'use client'
import React from 'react'
const ClientComponent = () => {
return (
<div>This is a client component import inside of a server component</div>
)
}
export default ClientComponent

On Server

If you look at the page source (SSR'd HTML), you would see things making sense now:

<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="stylesheet"
href="/_next/static/css/app/layout.css?v=1742580603548"
data-precedence="next_static/css/app/layout.css"
/>
<link
rel="preload"
as="script"
fetchPriority="low"
href="/_next/static/chunks/webpack.js?v=1742580603548"
/>
<script
src="/_next/static/chunks/main-app.js?v=1742580603548"
async=""
></script>
<script src="/_next/static/chunks/app-pages-internals.js" async=""></script>
<script
src="/_next/static/chunks/app/client-in-server/page.js"
async=""
></script>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16" />
<meta name="next-size-adjust" />
<script src="/_next/static/chunks/polyfills.js" nomodule=""></script>
</head>
<body class="__className_d65c78">
<div>
<h1>This is a owner server component</h1>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus,
dignissimos.
</p>
<div>This is a client component import inside of a server component</div>
</div>
<script
src="/_next/static/chunks/webpack.js?v=1742580603548"
async=""
></script>
<script>
;(self.__next_f = self.__next_f || []).push([0])
self.__next_f.push([2, null])
</script>
<script>
self.__next_f.push([
1,
'1:HL["/_next/static/media/a34f9d1faa5f3315-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]\n2:HL["/_next/static/css/app/layout.css?v=1742580603548","style"]\n0:D{"name":"r1","env":"Server"}\n',
])
</script>
<script>
self.__next_f.push([
1,
'3:I["(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n6:I["(app-pages-browser)/./components/client-component.tsx",["app/client-in-server/page","static/chunks/app/client-in-server/page.js"],"default"]\n7:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n8:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\nd:I["(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]\n4:D{"name":"","env":"Server"}\n5:D{"name":"OwnerServer","env":"Server"}\n5:["$","div",null,{"children":[["$","h1",null,{"children":"This is a owner server component"}],["$","p",null,{"children":"Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus, dignissimos."}],["$","$L6",null,{}]]}]\n9:D{"name":"RootLayout","env":"Server"}\na:D{"name":"NotFound","env":"Server"}\na:[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\\"Segoe UI\\",Roboto,Helvetica,Arial,sans-serif,\\"Apple Color Emoji\\",\\"Segoe UI Emoji\\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":',
])
</script>
<script>
self.__next_f.push([
1,
'{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]]\n9:["$","html",null,{"lang":"en","children":["$","body",null,{"className":"__className_d65c78","children":["$","$L7",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L8",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$a","notFoundStyles":[],"styles":null}]}]}]\nb:D{"name":"rQ","env":"Server"}\nb:null\nc:D{"name":"","env":"Server"}\ne:[]\n0:[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/app/layout.css?v=1742580603548","precedence":"next_static/css/app/layout.css","crossOrigin":"$undefined"}]],["$","$L3",null,{"buildId":"development","assetPrefix":"","initialCanonicalUrl":"/client-in-server","initialTree":["",{"children":["client-in-server",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],"initialSeedData":["",{"children":["client-in-server",{"children":["__PAGE__",{},[["$L4","$5"],null],null]},["$","$L7",null,{"parallelRouterKey":"children","segmentPath":["children","client-in-server","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L8",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","styles":null}],null]},["$9",null],null],"couldBeIntercepted":false,"initialHead":["$b","$Lc"],"globalErrorComponent":"$d","missingSlots":"$We"}]]\n',
])
</script>
<script>
self.__next_f.push([
1,
'c:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Create Next App"}],["$","meta","3",{"name":"description","content":"Generated by create next app"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]]\n4:null\n',
])
</script>
</body>
</html>

We have server component content twice (For reasons already explained),
We only have send the client component content once, also we have the reference to the client component file: \n6:I["(app-pages-browser)/./components/client-component.tsx",["app/client-in-server/page","static/chunks/app/client-in-server/page.js"],"default"]\n

If you look at the DOM content of the server component in the RSC payload you will find something interesting:
\n5:["$","div",null,{"children":[["$","h1",null,{"children":"This is a owner server component"}],["$","p",null,{"children":"Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus, dignissimos."}],["$","$L6",null,{}]]}]\n

It has reference to line 6 i.e., $L, which is the reference to the file for the client component.

Here's how this works:

On Client

  • The non-interactive preview HTML from the server is shown and creates an initial DOM tree.

    Once the JS bundle is loaded:

    • React processes the RSC payload containing the server component output and client component placeholders
    • React executes the client components and performs reconciliation by matching this newly created Virtual DOM with the existing DOM structure
    • Rather than rebuilding the DOM from scratch, React "adopts" the existing DOM nodes while establishing connections to its Virtual DOM representation
    • React adds event listeners and sets up component state for the client components
    • This reconciliation process during hydration creates an internal map of your components and their DOM elements, which React uses to efficiently update only specific parts of the page when state changes later

    Now the application is fully interactive

On subsequent page loads (after initial hydration):

  • The server sends a new RSC payload containing updated server component data and outputs
  • Client-side React processes this payload, maintaining the existing component tree structure
  • React renders client components directly on the client, using the latest props from the RSC payload
  • For server components, React directly applies the pre-rendered output from the RSC payload to the DOM
  • React performs targeted reconciliation to update only the portions of the page that have changed
  • The internal component map created during initial hydration helps React efficiently apply these updates
  • The application remains interactive throughout this process.

So server component render only on the server

Client component render on the server and then on the client on the first page load, and then on the client.

You might be wondering what happens if the server component send a prop to the client component: Well apart from the things discussed RSC payload also send the props that are passed from server to client component.

Say if we passed a prop foo from server component to client component, like so

<ClientComponent foo='this is a prop' />

The generated RSC payload will also contain this prop information like show below.

\n5:["$","div",null,{"children":[["$","h1",null,{"children":"This is a owner server component"}],["$","p",null,{"children":"Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus, dignissimos."}],["$","$L6",null,{"foo":"this is a prop"}]]}]

Rendering a page with client component importing a server component

This pattern is not supported but there is a work around. Let's consider what the problem here is:

We know all the descendent's of a client component are considered to be a client component so importing a server component inside a client component will be treated as a client component. Which we don't want.

Hence client component can't own a server component.

But there's a workaround, let's make an arrangement such that the client component won't own a server component but somehow it can still use the server component within itself.

To do this we can pass the server component to client component from a parent server component with the children prop. That way the parent server component will own the server component, hence it will remain outside the client boundary, and yet the client component will be able to call the server component. I know it sounds confusing, here's the code:

parent-server-component.tsx
import ClientComponent from './client-component'
import ServerComponent from './server-component'
export default function ParentServerComponent() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
)
}
client-component.tsx
'use client'
import { useState } from 'react'
export default function ClientComponent({ children }) {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
{children}
</>
)
}

Again why did this work, Here the parent server component owns the server component and not the client component. So even if we use the server component in the client component, it is treated as a server component, since none of its parent are client component.

Hydration Errors

Hydration errors are one of the common pain point, while developing SSR'd application.

We already have an idea of what the process of hydration is from the SSR'd SPA section.

There are two possible reasons for hydration errors to happen:

  1. Trying to server render a logic that uses browser specific API's

  2. When the is a difference between the React tree that was pre-rendered from the server and the React tree that was rendered during the first render in the browser during hydration.

Let's consider the following example:

'use client';
function Game() {
const [bestScore, setBestScore] = React.useState(() => {
return Number(
window.localStorage.getItem('best-score') ||
0
);
});
React.useEffect(() => {
window.localStorage.setItem('best-score', bestScore);
}, [bestScore]);
return (
<div>{bestScore}</div>
);
}

We get the following error:

ReferenceError: window is not defined.

Now if we follow the steps from the SSR'd SPA section, we need to first render this component on the server

But how do we render useState and useEffect on the server?

  1. useState:
    On server there is not point of the state setter function (setBestScore in this case) since we can't update the state on the server. Hence the server just initialize the state variable with the useState's default value.

  2. useEffect:
    Since useEffect runs after the DOM has been rendered, all the code inside a useEffect is ignored on the server. And it will only run on the client.

Now since the server was trying to use a browser API (localStorage ) to initialize the best score state, we get the error.

To solve this let's update the client component like so:

function Game() {
const [bestScore, setBestScore] = React.useState(() => {
if (typeof window === 'undefined') {
return 0;
}
return Number(
window.localStorage.getItem('best-score') || 0
);
});
React.useEffect(() => {
window.localStorage.setItem('best-score', bestScore);
}, [bestScore]);
return (
<div>{bestScore}</div>
);
}

Now we are saying that if only the window object is available (which is not the case on the server), we use the localStorage API, otherwise we return 0.

But now say the localStorage has a best-score value of 7. When we render the component on server, we have 0 as the best score in the HTML, but during hydration on the client we get it's value as 7 from the localStorage.

Hence the error:

Error: Text content does not match server-rendered HTML.
Warning: Text content did not match. Server: "0" Client: "7"

So let's set a default value of 0 for the first render, and after that we can get the actual value from the localStorage on client and update the best score value.

const [bestScore, setBestScore] = useState(0); // set default value
// Update after the first render (runs only on the client)
useEffect(() => {
const savedValue =
window.localStorage.getItem('best-score');
if (savedValue === null) {
return;
}
setBestScore(Number(savedValue));
}, []);

Now this does works, we don't have any errors. But is this the right way?

We are showing the users a value that is not accurate on the first load and then we are updating it to the actual value.

Now this doesn't feels transparent to me. Instead we should tell the user that we don't know the actual value yet, and we show them the actual value when we have it. And the tool to use here is the classic loading spinner.

'use client';
import React from 'react';
import Spinner from '../Spinner';
function Game() {
const [bestScore, setBestScore] = React.useState(null);
React.useEffect(() => {
const savedValue = window.localStorage.getItem('best-score');
setBestScore(savedValue ? Number(savedValue) : 0)
}, []);
React.useEffect(() => {
if (typeof bestScore === 'number') {
window.localStorage.setItem('best-score', bestScore);
}
}, [bestScore]);
return (
<div
>
Best Score:{' '}
{typeof bestScore === 'number' ? bestScore : <Spinner />}
</div>
);
}
export default Game;

Now we have null as the default value. And only after the first render we update the value with value from localStorage if available, else we set it to 0.

Summary

This has been a long read, thanks for sticking through.

We started our discussion with a in-depth look into how rendering looks like for MPA, SPA & SRR'd SPA. How RSC payload helps as a communication source from server to the client.

We then looked at what RSC aims to solve for us. Different ways of using client and server components, how are they rendered.

We also looked at what hydration errors are and how can we solve them.

Finally let's discuss the pros and cons of using React Server Components.

Pros

  1. Since we are fetching the data on server, which is closer to the database, the load time improves.

  2. Server Components are not shipped to the client: Hence if you have large dependency that is only used in server components, they won't be shipped with the JS bundle. Hence reducing the JS bundle size.

  3. Since we are sending fully formed HTML from the server, it is good for our applications SEO performance.

  4. We can also leverage streaming to stream the rendered UI from server to client as and when they are ready without blocking the entire page.

Now it's not all roses. Let's also talk about some tradeoffs that you make when using RSC.

Cons

  1. The server sends in the fully formed HTML, but it is not interactive until hydration is completed. Which sometimes can take longer than expected, and worse case can result into hydration errors.

  2. All the content is send over twice, once in server rendered HTML and also in the RSC payload.

  3. While we have better performance and lower client-side load the server load increases considerably.

All in all React Server Components is a really powerful tool that comes with it's own set of tradeoffs.

I will leave the justification of the quiz answers as an exercise to you.

Hopefully you now have a clear explanation on why the answers were what they were. If not you can always reach out to me on social media handles.