Blog
5 min read

React Server Components Explained: "use client", "use server", and What Runs Where

Server Components render on the server and send no JavaScript; Client Components hydrate in the browser for interactivity. How the boundary works, what 'use client' and 'use server' actually mean, passing data across, common errors, and how to keep secrets and bundles where they belong.

React Server Components (RSC) changed where React code runs. In frameworks that support them — Next.js's App Router most prominently — components are server-only by default, and you opt into the browser with "use client". Most confusion (and many AI-generated bugs) comes from not knowing which side a piece of code is on.

Two kinds of components

Server Components (the default):

  • Run only on the server — at build time or per request.
  • Can be async and fetch data directly — query the database, read files, call APIs with secret keys.
  • Send rendered output, not their code, to the browser. Zero JavaScript for the component itself.
  • Can't use state, effects, event handlers or browser APIs (useState, onClick, window).

Client Components (marked with "use client"):

  • Render on the server for the first HTML, then hydrate in the browser and become interactive.
  • Can use state, effects, event handlers and browser APIs.
  • Their code — and everything they import — ships to the browser.
// app/products/page.tsx — a Server Component
import { db } from '@/lib/db'
import { AddToCart } from './add-to-cart'

export default async function ProductsPage() {
  const products = await db.product.findMany()   // runs on the server
  return (
    <ul>
      {products.map(p => (
        <li key={p.id}>
          {p.name} <AddToCart productId={p.id} />
        </li>
      ))}
    </ul>
  )
}
// app/products/add-to-cart.tsx — a Client Component
'use client'
import { useState } from 'react'

export function AddToCart({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false)
  return <button onClick={() => setAdded(true)}>{added ? 'Added' : 'Add'}</button>
}

"use client" marks a boundary, not a component

"use client" at the top of a file means: this file and everything it imports become part of the client bundle. It's an entry point into browser land.

Practical consequences:

  • Put "use client" as low in the tree as possible — on the small interactive leaf (a button, a form), not on the page or layout. Marking a layout as client pulls the whole subtree into the bundle.
  • A Client Component can't import a Server Component. But it can render one passed in as children or another prop:
<ClientTabs>
  <ServerRenderedContent />   {/* still rendered on the server */}
</ClientTabs>

Passing data across the boundary

Props from a Server Component to a Client Component must be serialisable: strings, numbers, booleans, plain objects and arrays, Dates, Promises, and a few others. Not functions (except Server Actions), class instances, or database connections.

Error you'll see:

Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".

Fix: pass data, not behaviour — or move the logic into the Client Component, or make it a Server Action.

"use server" is something else

"use server" doesn't make a component a Server Component (they already are). It marks Server Functions (Server Actions) — async functions that run on the server but can be called from the client, typically from forms:

// app/actions.ts
'use server'
export async function createTodo(formData: FormData) {
  const title = String(formData.get('title'))
  // validate, check the user's permissions, then write to the DB
}
<form action={createTodo}>…</form>

Every Server Action is effectively a public API endpoint. Anyone can call it with any input. Validate input and check authorisation inside every one, exactly as you would in an API route. (Validate input with Zod, IDOR explained)

Keeping secrets on the server

Server Components let you use secrets safely — but only if the code stays server-side. To make sure a module can never be imported into client code, add:

import 'server-only'

at the top of files that touch the database or secret keys. The build fails if a Client Component imports them. (Keep API keys out of an AI-built app)

Common errors and what they mean

Error Cause
useState only works in Client Components Hook used in a Server Component — add "use client" to that (leaf) file
Event handlers cannot be passed to Client Component props onClick on a Server Component element — move it into a Client Component
window is not defined Browser API used during server rendering — use it inside useEffect
Hydration mismatch Server and client rendered different HTML (hydration errors)
async/await is not yet supported in Client Components async component marked "use client" — fetch in a Server Component and pass data down

Why bother?

  • Smaller bundles — non-interactive UI ships no JavaScript.
  • Simpler data fetching — await the database in the component; no API layer just for your own UI, no loading-spinner waterfalls for initial data. (TanStack Query vs useEffect covers client-side fetching.)
  • Secrets stay on the server by default.

The summary

  • Components are server-only by default: they fetch data, send HTML, ship no JS.
  • "use client" marks the boundary into the browser; put it on small interactive leaves.
  • Props across the boundary must be serialisable.
  • "use server" marks Server Actions — public endpoints that need validation and auth checks.
  • import 'server-only' protects modules with secrets.

EasySpawn runs Next.js apps as a real Node server with your database on the same machine, so Server Components query Postgres directly with no cold starts. See how it works or join the waitlist.

Related: What Is Next.js? · Next.js App Router vs Pages Router · Next.js Hydration Errors · Self-Hosting Next.js

Keep reading