Introduction
With the update to Next.js version 13, I've started utilising server components. I'd like to share my experience applying server components to my blog project and explain their work principles.
What are Server Components?
Essentially, these are components that work and render on the server.
- They work on the server, enabling access to backend resources.
- They're transmitted not as HTML, but in a specialised stream format, which helps in reducing the JavaScript bundle size sent to the client.
- Utilising RSC allows for a clear division of responsibilities between the server and the client.
The Genesis of Server Components
According to Dan and Lauren's video on server components, titled Data Fetching with React Server Components, server components emerged to address issues with the traditional client-side data fetching approach.
What was problematic about the usual client-server data fetching method?
Typically, we choose one of two methods for fetching data on the client side:
- Call all data from one API and pass it from the parent component to child components via Props.
- Each component calls its required API.
The first method reduces the number of API requests as the parent component makes a single API call, but it creates a dependency of child components on the parent.
The waterfall is eliminated, but the components do not own their required data. ⇒ Maintenance becomes difficult if the component structure changes.
The second method involves each component receiving its required API. Each component can render only the data it needs when it is rendered.
const PostsPage = ({ posts}) => {
return (
<PostDetail postId={postId}>
<PostTags postId={postId}></PostTags >
<PostImage postId={postId}></PostImage >
</PostDetail >
);
}
Multiple API requests occur, and the parent component starts fetching data after it has rendered. During this process, the rendering of child components and their API calls are delayed.
This delay in calls leads to a Network Waterfall effect.
Resolving the Network Waterfall
To address the Network Waterfall issue, components are moved to the server. This shift allows for more efficient data handling and rendering processes, reducing the delays caused by multiple API requests and rendering dependencies in client-side components.
By handling data requests on the server and allowing the client to fetch only the necessary data, unnecessary data fetching and network requests can be reduced.
In essence, server components emerged to address the issues with asynchronous data fetching methods in traditional client components.
// app/projects/page.tsx const PostsPage = async () => { const posts = await getArticles() return (<Posts posts={posts} />) }
This is a part of the code for the page that displays the projects currently used in the blog.
By making network requests on the server, the Network Waterfall issue is resolved.
Reducing JavaScript Bundle Size
Server components do not transmit JavaScript files to the client, which can significantly reduce the JavaScript bundle size.
In the current blog that you are viewing, the markdown
library and the syntaxHighlighter
library were used to display code and markdown. Measuring the bundle size reveals that it is quite large, indicated by a red warning.
The JavaScript bundle size also impacts the page build time.
Let's compare the build times when using libraries in traditional client components versus server components.
When loading libraries in client components
The original code loaded libraries on the client, increasing the bundle size that needs to be downloaded by the client.
When building the project and viewing the blog detail page, it can be observed that the size is marked in red at 419kB, indicating a significant bundle size.
To visualise how much space is being taken up, let's use the bundle analysis tool @next/bundle-analyzer. This tool will provide a graphical representation of the bundle size, helping to identify which parts are contributing most to the overall size.
Can you see the markdown library and the highlight library? It's evident that they occupy a significant portion of the bundle. This substantial usage highlights the impact these libraries have on the overall size of the JavaScript bundle, contributing to the increased load on the client-side.
When loading libraries in server components
import { CSSProperties } from 'react' import { ReactMarkdown } from 'react-markdown/lib/react-markdown' import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/default-highlight' import { nord } from 'react-syntax-highlighter/dist/esm/styles/hljs' import remarkGfm from 'remark-gfm' import PostPage from '@/components/blog/organisms/postPage' import { getPost } from '@/service/notion' const BlogPost = async ({ params: { slug } }: TProps) => { const post = await getPost(slug) const codeStyle: CSSPropertiesMap | undefined = nord return ( <PostPage post={post}> <ReactMarkdown remarkPlugins={[remarkGfm]} children={post.markdown} components={{ code({ node, inline, className, children, ...props }) { const match = /language-(\w+)/.exec(className || '') return inline ? ( <code {...props}>{children}</code> ) : match ? ( <SyntaxHighlighter children={String(children).replace(/\n$/, '')} language={match[1]} PreTag="div" {...props} style={codeStyle} /> ) : ( <code className={className} {...props}> {children} </code> ) }, }} /> </PostPage> ) } export default BlogPost
The code is from app/[slug]/page.tsx
. Next.js's app router inherently uses server components.
After moving the libraries to the server, let's rebuild and measure the build time and bundle size again. This process will demonstrate the impact of server-side rendering on reducing the client-side load, potentially leading to a more efficient and faster-loading application.
The JavaScript size, which was previously 419kB, has significantly reduced to 104kB. This reduction represents a 22% decrease in bundle size and also leads to a shorter build time. This substantial decrease highlights the efficiency gains achieved by moving resource-intensive libraries to the server side.
The disappearance of the markdown
and highlight
libraries can be confirmed in the analyzer.
By using large libraries, which were previously utilized in client components, within server components, we've managed to reduce the JavaScript bundle size delivered to the client and improve build times. This approach effectively demonstrates the advantage of server components in optimizing web application performance.
How does it Sever component work?
Consider a scenario with the following page setup:
When a user requests to render a page,
The server initiates the page's lifecycle by performing pre-rendering, meaning that the lifecycle of the page always starts on the server. This approach ensures that the initial rendering and necessary data fetching are handled server-side before the page is delivered to the client.
If you examine the root of the current blog page, you can observe that the server component is at the top level.
The server executes the component tree and reconstructs it in a serialized JSON format.
The serialization process involves executing all server components until they are transformed into JSON format.
- For basic HTML tags, they can be processed into JSON without any special handling.
- In the case of server components, the server component function is called along with its props, and the result is converted into JSON for transmission. The goal is to transform all server components into HTML tags.
- For components that are not basic HTML tags, serialization is not possible because they attempt to reference a component (function).
If a component is a client component, it is skipped during this process.
For client components, a placeholder
is placed to mark the location where the client component will render.
{ $$typeof: Symbol(react.element), type: { $$typeof: Symbol(react.module.reference), name: "default", //export default filename: "./src/ClientComponent.js" // file path }, props: { children: "some children" }, }
Client components introduce a new value in the type
field of the React element, known as a module reference
. Instead of serializing the component function, this reference
is serialized. This approach allows for efficient handling of client components during the server-side rendering process.
- The browser receives the JSON output from the server and begins to reconstruct the React tree that will be rendered in the browser.
- Whenever it encounters an element with a
module reference
type, it attempts to replace it with an actual reference to the client component function. This process involves dynamically loading the client components as needed, based on the references provided in the serialized JSON. This approach ensures that only the necessary client-side code is executed, contributing to a more efficient and optimized rendering process.
The client receives the data in a streamed format and refers to the concurrently downloaded JavaScript bundle. Upon encountering a module reference type, the client component is rendered to fill the vacant spaces. These changes are then applied to the DOM, resulting in the actual screen display for the user.
How are Server Components Delivered to the Client?
Server components are commonly said to be delivered to the client in a streamed format. This special delivery method helps reduce the JavaScript bundle size.
Let's examine the form in which server components are delivered.
I looked at the streamed format delivered when rendering one of my blog posts, useState-DeepDive.
The stream format was easily checked using the RSC Server Tool. For more details, read Devtools for React Server Components.
0:["lxf9qd6QpO-pTDwvg7vTG",[["children",["slug","UseState-Deep-Dive","d"],[["slug","UseState-Deep-Dive","d"],{"children":["__PAGE__",{}]}],"$L1",[[],"$L2"]]]] 3:HL["/_next/static/css/78c733b96d3e7e0c.css","style"] 4:I[6954,[],""] 5:I[7264,[],""] 1:["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children",["slug","UseState-Deep-Dive","d"],"children"],"loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"error":"$undefined","errorStyles":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","childProp":{"current":["$L6","$L7",null],"segment":"__PAGE__"},"styles":[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/78c733b96d3e7e0c.css","precedence":"next","crossOrigin":"$undefined"}]]}] 8:I[5458,["342","static/chunks/342-5816a658813e519f.js?dpl=dpl_5figEm5EWGZSAeWWMhia2B6T6DAr","391","static/chunks/391-77112400ffd28c29.js?dpl=dpl_5figEm5EWGZSAeWWMhia2B6T6DAr","42","static/chunks/app/%5Bslug%5D/page-d089f4892d1d999d.js?dpl=dpl_5figEm5EWGZSAeWWMhia2B6T6DAr"],""]
-
I
Type (Import Type): This type primarily references JavaScript files. -
3:HL["/_next/static/css/78c733b96d3e7e0c.css","style"]
references a CSS file. -
4:I[6954,[],""]
and5:I[7264,[],""]
reference other JavaScript files necessary for rendering the page's content. -
null
Type: This type represents React elements.1:["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children",["slug","UseState-Deep-Dive","d"],"children"],"loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"error":"$undefined","errorStyles":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","childProp":{"current":["$L6","$L7",null],"segment":"__PAGE__"},"styles":[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/78c733b96d3e7e0c.css","precedence":"next","crossOrigin":"$undefined"}]]}]
includes React elements and properties used to define the page's structure and style.
This data shows how each component and resource is transmitted from the server to the client during streaming. It allows developers to understand which components and resources are used during the page loading process and in what order they are loaded.
This information can be very useful for performance optimization and debugging.
The React Environment with Added Server Components
What does the React environment look like with the addition of server components?
Dan's article, Why do Client Components get SSR’d to HTML, provides a good explanation through images. This resource can help in understanding how server components integrate with the existing React architecture, illustrating the interaction between server and client components, and how they contribute to the overall rendering process.
In the existing state with a React Tree,
RSC (React Server Components) simply adds a Server Layer that operates on the server, while keeping the original React Tree intact. This approach integrates server-side rendering capabilities into the existing React architecture without altering the fundamental structure of the React Tree used on the client side.
Apart from the addition of the label 'Client' and the emergence of a Server Tree, there are no significant differences.
This means that the core structure and functionality of the React environment remain largely unchanged, with the primary addition being the Server Tree that works alongside the existing Client Tree, enhancing the overall capabilities of the React application.
Server Components vs SSR
What are the differences between server components and SSR (Server-Side Rendering)?
Let's first understand the concept of server-side rendering.
The primary goal of SSR is to quickly deliver a non-interactive version of client components to the browser, improving the initial page's First Contentful Paint
or Largest Contentful Paint
speed.
- In SSR, all component codes are included in the JavaScript bundle and sent to the client.
- In contrast, the code for server components is not sent to the client.
- While server components do perform SSR, the result is not basic HTML but a streamed format (
["$","div",null,{"children":[...]}"]
). - Server components transmit components in a special format, not as HTML, allowing the maintenance of client states like focus and input values when necessary.
- While server components do perform SSR, the result is not basic HTML but a streamed format (
In essence, server side rendering, which aims to create HTML in response to requests, and RSC, which operates components on the server, are not substitutes for each other but rather have a complementary relationship.
By using SSR to quickly show the initial HTML page and server components to reduce the JavaScript bundle size sent to the client, a much faster and more interactive page experience can be provided to the user.
References
https://www.alvar.dev/blog/creating-devtools-for-react-server-components
https://yceffort.kr/2022/01/how-react-server-components-work#rsc-렌더링의-라이프-사이클
https://tech.kakaopay.com/post/react-server-components/#리액트-서버-컴포넌트rsc와-서버-사이드-렌더링ssr
https://github.com/reactwg/server-components/discussions/4
https://www.youtube.com/watch?v=TQQPAU21ZUw&t=1s&ab_channel=MetaOpenSource