Skip to main content
paulund

3 min read

#react#server-components#nextjs

React Server Components vs Client Components

The hardest part of React Server Components isn't the syntax. It's undoing the assumption that every component runs in the browser. Once that assumption is gone, most of the confusion falls away.

Two Different Runtimes

A Server Component runs once, on the server, during the request. It has access to the filesystem, environment variables, databases, and any other server-only resource. When it's finished rendering, it produces a serialised description of the UI that gets streamed to the browser. The component itself never ships to the client.

A Client Component runs in the browser. It's what React has always been: code that hydrates, holds state, handles events, and reacts to user input. It needs the full component function to be in the bundle so React can re-render it when state changes.

Server Components are the default in the Next.js App Router. You opt into Client Components explicitly with the 'use client' directive at the top of the file.

// app/products/page.tsx - Server Component (default)
import { db } from '@/lib/db'
import { AddToCart } from './add-to-cart'

export default async function ProductsPage() {
    const products = await db.product.findMany()

    return (
        <ul>
            {products.map((p) => (
                <li key={p.id}>
                    {p.name}
                    <AddToCart productId={p.id} />
                </li>
            ))}
        </ul>
    )
}
// app/products/add-to-cart.tsx - Client Component
'use client'

import { useState } from 'react'

export function AddToCart({ productId }: { productId: string }) {
    const [loading, setLoading] = useState(false)

    return (
        <button onClick={() => setLoading(true)} disabled={loading}>
            {loading ? 'Adding...' : 'Add to cart'}
        </button>
    )
}

The server component talks directly to the database. No API layer, no client-side fetch. The client component handles the interactivity that needs state.

The Boundary Is One-Way

A Server Component can render a Client Component. A Client Component cannot render a Server Component directly, because by the time the Client Component is running, the server is already gone.

It can, however, receive a Server Component as children or as a prop. This is the pattern that trips people up when they first hit it:

// Server Component
import { ClientWrapper } from './client-wrapper'
import { ServerContent } from './server-content'

export default function Page() {
    return (
        <ClientWrapper>
            <ServerContent />
        </ClientWrapper>
    )
}

The server renders ServerContent, wraps the result in the ClientWrapper, and ships both to the browser. The wrapper can then use state, effects, and event handlers around content that was fully rendered on the server.

Props Have to Be Serialisable

Anything you pass from a Server Component to a Client Component has to survive serialisation. Strings, numbers, booleans, arrays, plain objects, dates, and maps are fine. Functions, class instances, and React elements that came from other server imports are not.

If you need a callback on the client, define it in the Client Component and pass the data it needs, not the function, across the boundary.

Where to Draw the Line

My rule of thumb: start everything as a Server Component. Drop 'use client' only when a component actually needs it. That usually means one of four things:

  • It uses useState, useReducer, or another React hook
  • It has an event handler (onClick, onChange)
  • It uses browser-only APIs like window or localStorage
  • It depends on a third-party library that does any of the above

The goal is to push the 'use client' boundary as far down the tree as possible. Your leaf components are interactive. The pages, layouts, and lists above them stay on the server, where they can fetch data without a waterfall.

Why It Matters

The win is bundle size and data fetching. A Server Component never ships its code to the browser, so the rendered list of 500 products doesn't cost 50KB of React on the client. Data fetching happens in the same place as rendering, so there's no useEffect + loading state dance for data you already knew how to get on the server.

You still have the full React model for interactivity. You just have to be deliberate about where interactivity starts.

Related notes


Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.