← Back to knowledge mapfrontend

Server Components

Updated Oct 3, 2026

Server Components

Disclaimer: the examples here are for the Next.js App Router. The idea (a component that runs on the server and sends a description of UI, not its own JavaScript) is a React feature. Next.js is where I actually use it.

A Server Component is the default in the App Router. There is no directive to opt in. If a file under app/ does not start with 'use client', it is a Server Component.

It runs while the page is being built on the server. It can be async, it can talk to a database, and it can read secrets. The browser never downloads that function. It downloads the result: HTML, plus a payload that describes the tree.

What never leaves the server

The component function itself stays on the server. So do the modules it imports, as long as those modules are not pulled into a Client Component.

That is the practical win:

  • Database clients, file reads, and private env vars stay off the bundle.
  • A heavy markdown parser or a query builder does not ship to the phone.
  • The first HTML can already contain the data. The browser does not need a second round trip just to fill in the title and the list.

What the browser does receive is the rendered output, and any props you pass into a Client Component. Those props have to be data React can serialize: strings, numbers, booleans, null, and plain objects and arrays of those. A function, a class instance, or a database row that still has methods on it will not cross.

Where the client boundary actually is

'use client' is not a label on one component. It is a door. The file with the directive, and everything that file imports, is client code. That code is in the JavaScript bundle and can use state, effects, and browser APIs.

The door only swings one way:

  • A Server Component can render a Client Component. That client component is a hole in the server tree. The server leaves a slot and sends the props for it.
  • A Client Component cannot import a Server Component. If you try, the server component gets pulled through the door and stops being a server component. The build fails if that file touches server-only code.
Where the boundary sits

Server Component renders Client Component: allowed. The client file is a hole, and only serializable props go through it. Client Component imports Server Component: not allowed. Client shell needs server-rendered content inside it: the server passes that content as children, so the inner component never gets imported by the client file.

A button that needs onClick does not mean the page is a Client Component. The page stays a Server Component. The button is a small client island.

How a route gets to the browser

Read top to bottom. The server stays on the left. The browser starts on the right once the HTML is sent, then keeps going down.

Step 4 is the one people call reconcile. React is holding two copies of the same page and has to make them agree.

One copy is the HTML already on screen. The other is the RSC Payload: a component tree. That tree says which chunks are finished Server Component output, and which holes are Client Components still waiting for JavaScript.

React walks both copies and pairs each piece of HTML with the component that produced it. Where they already match, the page stays put. Where the payload says something different, React updates that part of the page. After that pairing, step 5 can attach clicks and state to the Client Components.

Until step 5, the preview is real content, not an empty shell. Clicks do nothing yet, because the client islands have not been hydrated. That is why a page can look finished and still ignore the first tap if the client bundle is slow.

Data fetching moves next to the component

In the Pages Router, data loading lived in getServerSideProps or getStaticProps, or it happened later in useEffect against an API route. The component was a separate step from the fetch.

In the App Router the Server Component is the fetch. It awaits what it needs and returns the UI. There is no extra API route unless something outside this React tree needs that endpoint.

import { db } from "@/lib/db";

const PostsPage = async () => {
    const posts = await db.post.findMany();

    return (
        <ul>
            {posts.map((post) => (
                <li key={post.id}>{post.title}</li>
            ))}
        </ul>
    );
};

export default PostsPage;

Two awaits in a row are a waterfall. The second waits even when it does not need the first. Start independent work together, then await both:

const PostsPage = async () => {
    const postsPromise = db.post.findMany();
    const tagsPromise = db.tag.findMany();
    const [posts, tags] = await Promise.all([postsPromise, tagsPromise]);

    return <Posts posts={posts} tags={tags} />;
};

cookies(), headers(), and searchParams are request data. Reading them means this render depends on the current request, so the page cannot be a fully static file. That is fine for a logged-in view. It is wasteful for a marketing page that is the same for everyone.

Caching is a separate decision from "this is a Server Component". The component can run on each request. If a result is safe to reuse, that is an explicit cache (use cache when Cache Components are enabled, or the older fetch cache when they are not). I would not reach for a cache until the same query is obviously repeated or the page is public and stable.

The usual split

The server page loads the data. A client child owns the interaction. Props that cross are plain data: an id, a number, a title.

app/posts/page.tsx
import { db } from '@/lib/db'
import { LikeButton } from './like-button'
const PostsPage = async () => {
const posts = await db.post.findMany()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.title}
<LikeButton postId={post.id} initialLikes={post.likes} />
</li>
))}
</ul>
)
}
export default PostsPage
app/posts/like-button.tsx
'use client'
import { useState } from 'react'
type LikeButtonProps = {
postId: string
initialLikes: number
}
export const LikeButton = (props: LikeButtonProps) => {
const [likes, setLikes] = useState(props.initialLikes)
return (
<button onClick={() => setLikes(likes + 1)}>
{likes} likes
</button>
)
}

LikeButton is the only part of this tree in the client bundle. PostsPage and db stay on the server. The button does not fetch the list. It receives initialLikes and then keeps its own state.

The same shape works for forms, dialogs, tabs, and charts. Server renders the document. Client owns the piece that has to react to the user.

Children, when the shell is the client

Sometimes the interactive part is the wrapper: a modal, a tab panel, a disclosure. The inside is still server-rendered content that can fetch. Do not import that inner component from the client file. Pass it as children. Composition happens on the server, before the client file ever sees it.

app/products/[id]/page.tsx
import { Modal } from './modal'
import { ProductDetails } from './product-details'
type PageProps = {
params: Promise<{ id: string }>
}
const ProductPage = async (props: PageProps) => {
const { id } = await props.params
return (
<Modal>
<ProductDetails id={id} />
</Modal>
)
}
export default ProductPage

ProductDetails can be an async Server Component. Modal is 'use client' and only renders props.children. The client file never imports ProductDetails, so the details stay on the server.

Pitfalls

A function cannot be a prop. This fails. The server would have to serialize onClick and send it to the browser.

<LikeButton onClick={() => saveLike(post.id)} />

Put the handler inside the Client Component, or pass a Server Action (below). Pass data, not behavior, unless you mean an action.

'use client' on a shared file is contagious. A layout, a Button, or a formatDate helper that starts with the directive drags every import of that file into the client graph. A date formatter that only formats strings should stay a normal module with no directive, so both sides can import it. A button that uses useState should be its own small file, not the module that also fetches posts.

Server-only code in a client import fails, or worse, leaks. fs, a database URL, and a secret key do not belong in a file that a Client Component can import. The server-only package makes that a build error instead of a silent bundle:

import "server-only";

export const getPosts = async () => db.post.findMany();

Client Components cannot be async. Hooks and event handlers live in the client file. The await lives in the server file above it.

Do not fetch in useEffect what the server already knew. A loading spinner, then a fetch to your own API, then a render, is the old path. If the data is needed for the first paint, await it in the Server Component. Keep the client fetch for something that truly depends on a click or on browser state.

Passing a whole ORM object. Spread a row into a client prop and you often pass fields you did not mean to show, or methods that will not serialize. Pick the fields: id, title, likes.

Server Actions are the exception

A Server Action is a function the client is allowed to call, because React does not send the function body. It sends a reference. The body still runs on the server.

const saveLike = async (postId: string) => {
    "use server";

    await db.post.update({
        where: { id: postId },
        data: { likes: { increment: 1 } },
    });
};

The Client Component can receive saveLike as a prop and call it from onClick. That is different from passing an arbitrary function. The action is a server endpoint with a typed argument, not a closure full of server variables you hoped would survive the trip.

Actions are still request handlers. They need the same checks as an API route: who is calling, and is this id theirs.

When I would not use one

  • The UI is mostly local state: a canvas, a drag-and-drop board, a multi-step form that should not hit the server on every keystroke. That tree is client from the start.
  • The data only exists in the browser: localStorage, a media device, the current cursor position.
  • A third-party widget that calls useEffect or touches window at import time. Import it from a Client Component, and keep the page around it on the server.

If I am unsure, I leave the file as a Server Component and extract a client island the moment I need a hook or an event. Moving the directive upward is the expensive direction, because it pulls the whole subtree into the bundle.