We’ve already explored how SSR and ISR can significantly offload the client by moving heavy data processing to the server. But rendering in React continues to evolve—and a powerful new tool, server components, is coming to the forefront.
Server components represent a true evolution in rendering. They allow for flexible separation of logic between the client and the server. Previously, we had to carefully consider where and how rendering happens, but server components remove that headache. Now, complex calculations and database queries can be performed on the server, with the client only receiving what’s necessary.
In practice, this means less client-side code (a smaller bundle size), faster page load times, and more control over rendering. Let’s dive into how this works.
The Power of Server Components
As the name implies, server components run exclusively on the server. They don’t hold state or interactivity but can execute database queries directly:
import React from "react";
import ProductsGrid from "@/components/ui/ProductsGrid";
import EmptyScreen from "@/components/ui/EmptyScreen";
import { getProducts } from "@/app/actions/getProducts";
import { SearchParams } from "@/app/(home)/page";
interface ICatalogProps {
children?: React.ReactNode;
searchParams: SearchParams;
}
const Catalog: React.FC<ICatalogProps> = async ({ searchParams }) => {
const products = await getProducts({
category_id: searchParams.category_id as string,
min_price: searchParams.min_price as string,
max_price: searchParams.max_price as string,
});
if (!products) return <EmptyScreen />;
return <ProductsGrid items={products} />;
};
export default Catalog;
What sets server components apart from client components? First, they can be asynchronous, allowing side effects like API or database requests to be made during rendering. In client components, you’d use useEffect
for this, but server components render only once on the server and don’t re-render on the client.
This means server components don’t have state or hooks like useState
or useEffect
, as they don’t re-execute on the client side after the initial render.
Server components are ideal for rendering data that doesn’t require frequent updates or interactivity. However, for more dynamic tasks, a hybrid approach—combining server and client components—works best.
How Server Components Work in Next.js
If server components only render on the server, aren’t included in the bundle, and can contain client components, how does React understand how to render the entire UI?
Let’s imagine we have a ProductInfo component:
import React from "react";
import CartButton from "../CartButton";
interface IProductInfoProps {
children?: React.ReactNode;
}
const ProductInfo: React.FC<IProductInfoProps> = (props) => {
return (
<header id="container">
<h1 id="product-title">Banana</h1>
<p id="product-price">$1.00</p>
<CartButton id="cart-button" />
</header>
);
};
export default ProductInfo;
Where CardButton is a client component:
'use client'
import React, { ComponentProps, useState } from 'react';
interface ICartButtonProps extends ComponentProps<"button"> {
children?: React.ReactNode;
}
const CartButton: React.FC<ICartButtonProps> = (props) => {
const [added, setIsAdded] = useState(false);
const toggle = () => setIsAdded(v => !v);
return (
<button onClick={toggle}>
{added ? "Remove from" : "Add to"} cart
</button>
);
}
export default CartButton;
So, how does the rendering happen in this case? Let’s break it down.
On the server side:
- React transforms the server component into a React Server Component Payload (RSC Payload)—a compact data representation used to update the DOM on the client. This payload contains the render results, placeholders for client components, and links to the corresponding JavaScript files.
- Next.js uses this payload to generate HTML on the server.
If you inspect the result, you’ll see a <script>
tag containing something like self.__next_f.push(…)
with JSON data inside. When unpacked, you can see the rendered ProductInfo along with a placeholder and a link to the client component CartButton:
7: {
name: "ProductInfo",
env: "Server"
},
7: [
"$","header",null,
{
"id":"container",
"children": [
["$", "h1", null, { "id":"product-title", "children":"Banana"}],
["$", "p", null, { "id":"product-price", "children":"$$1.00"}],
["$", "$L8", null, { "id":"cart-button" }]]
}
]
8: ["(app-pages-browser)/./app/components/CartButton/CartButton.tsx"]
On the client side:
- The HTML immediately displays a non-interactive version of the interface on the first page load.
- React uses the RSC Payload to reconcile the trees of server and client components, updating the DOM.
- JavaScript instructions are then used to hydrate client components, turning the UI interactive.
This separation of logic between the client and the server helps maintain high performance and reduces the amount of data transferred.
Anything Imported in a Client Component Becomes Client-Side
When you add the "use client"
directive to a component, remember one key rule: anything you import into that component automatically becomes client-side. Even if a component was meant to be a server component, it will now execute on the client, which can lead to unexpected outcomes.
Let’s imagine we have a product page that needs to store state—whether an item is added to the cart:
"use client"
import ProductDetails from "@components/ProductDetails";
import ProductPrice from "@components/ProductPrice";
import Button from "@components/Button";
export default ProductPage = () => {
const [isInCart, toggleIsInCart] = useCartState();
return (
<>
<ProductDetails />
<ProductPrice />
<Button onClick={toggleIsInCart}>
{isInCart ? "Remove from" : "Add to"} cart
</Button>
</>
);
}
In this example, the "use client"
directive applied to ProductPage
automatically turns all imported components—ProductInfo
, ProductPrice
, and Button
—into client components. Even if they were initially server components, they now render on the client.
But what if you want to keep these components server-side to avoid unnecessary client-side load? The solution is simple—separate the client logic and move it into isolated components:
"use client"
export default AddToCartWrapper = ({ children }) => {
const [isInCart, toggleIsInCart] = useCartState();
return (
<>
{children}
<Button onClick={toggleIsInCart}>
{isInCart ? "Remove from" : "Add to"} cart
</Button>
</>
);
}
Now you can remove the "use client"
directive from the main ProductPage
component and keep the server components server-side:
import ProductDetails from "@components/ProductDetails";
import ProductPrice from "@components/ProductPrice";
import AddToCartWrapper from "@wrappers/AddToCartWrapper";
export default ProductPage = () => {
return (
<AddToCartWrapper>
<ProductDetails />
<ProductPrice />
</AddToCartWrapper>
);
}
Now, ProductInfo
and ProductPrice
are rendered on the server, while the client-side logic is isolated in the AddToCartWrapper
component.
It’s important to shift your mindset when working with server components—it can be confusing at first, but once you get the hang of it, you’ll unlock a powerful optimization tool.
At this point, we’ve taken a deep dive into server-side rendering and server components in Next.js. These techniques not only improve SEO but also create a smoother, more responsive user experience.
In the next part, we’ll explore Server Actions—a new way of handling server logic in Next.js. You’ll learn how they allow you to perform server-side actions directly from components, reducing code duplication and improving your application’s performance.
Top comments (0)