The most surprising thing about Next.js Server Components is that they don’t actually run on the server in the way you might think; they’re more about what gets sent to the client and when.
Let’s see this in action. Imagine a simple app/page.tsx with a Server Component:
// app/page.tsx
async function getData() {
// Imagine this fetches from an external API
await new Promise(resolve => setTimeout(resolve, 100));
return { message: "Hello from Server Component!" };
}
export default async function HomePage() {
const data = await getData();
return (
<div>
<h1>Welcome</h1>
<p>{data.message}</p>
</div>
);
}
When a user requests this page, Next.js executes getData() on the server. The result of getData() and the rendered HTML of HomePage are then serialized. Crucially, the JavaScript for HomePage itself is not sent to the browser by default. What is sent is a special, compact JSON format that describes the component tree and its props, along with any data fetched within the component. The client-side React then uses this JSON to render the initial UI.
This solves the problem of shipping large amounts of client-side JavaScript to render initial content, which slows down Time To First Byte (TTFB) and First Contentful Paint (FCP). By rendering complex or data-intensive parts of your UI on the server, you send a much leaner payload to the browser.
Internally, Server Components are rendered into a special React format called a "React Server Component Payload" (RSC Payload). This isn’t HTML; it’s a tree-like structure of JSON that describes the UI. When the client receives this, React on the client interprets it and builds the DOM. If you have Client Components (marked with "use client";), they are embedded within this RSC Payload as references. When React encounters a reference to a Client Component, it then fetches the corresponding JavaScript bundle for that component and hydrates it on the client.
The exact levers you control are primarily where you place your "use client"; directive. Components without it are Server Components by default within the app directory. You can pass data fetched on the server directly as props to Client Components. For example:
// app/page.tsx
async function getUser() {
await new Promise(resolve => setTimeout(resolve, 100));
return { name: "Alice" };
}
export default async function HomePage() {
const user = await getUser();
return <UserProfile name={user.name} />;
}
// app/UserProfile.tsx
"use client"; // This is a Client Component
export default function UserProfile({ name }: { name: string }) {
// You can now use client-side features like useState, useEffect, etc.
return <p>Hello, {name}!</p>;
}
Here, HomePage is a Server Component, fetching user data. This data is then passed as a prop to UserProfile, which is a Client Component. The RSC Payload from HomePage will contain the name prop, and only when the client encounters UserProfile will it fetch the JavaScript for UserProfile.tsx and render it.
The mental model to drop is that Server Components are just "server-rendered HTML." They are not. They are server-rendered component trees serialized into a special JSON format. This distinction is critical for understanding why you can’t directly use browser-only APIs like window or document within them, and why they are so efficient for initial loads. They are designed to send declarative instructions to the client React, not raw HTML strings.
The next concept to grapple with is how to effectively manage state and interactivity when you have this split between Server and Client Components.