Frontend Inbox

Server components in one sitting

Intermediate10 minUpdated 2026-08-23

Where the server/client boundary actually falls, what crosses it, and the four mistakes that turn a server component tree back into a client-rendered app.

#react
#ssr

In one sentence

Server components run once, on the server, and send their rendered output — never their code — to the browser; use client marks the boundary where that stops being true.

Why it matters

The default in the App Router is inverted from what most React developers expect. Every component is a server component unless something marks it otherwise. That is not a rendering optimisation bolted on top of the old model; it is a different model, and the bugs come from carrying old assumptions into it.

What each kind can do:

CapabilityServer componentClient component
async / await in the bodyYesNo
Read secrets, hit the databaseYesNever
useState, useEffectNoYes
onClick and other handlersNoYes
Ships JavaScript to the browserNoYes
Re-renders after mountNoYes

The size win is the part people notice; the data-fetching win is the part that changes how you write code. A server component can await directly, which deletes the loading-state machinery that a useEffect fetch requires:

// app/orders/page.tsx — a server component. No 'use client'.
export default async function OrdersPage() {
  const orders = await db.order.findMany({ take: 20 }) // runs on the server only
  return <OrderList orders={orders} />
}

There is no isLoading, no useEffect, no request waterfall from the browser, and db never reaches the client bundle.

Where the boundary actually falls

use client does not mean "this component renders in the browser and its parent does not". It marks an entry point into the client bundle: that file, and everything it imports, becomes client code.

app/page.tsx              (server)
└── <Dashboard>           (server)
    ├── <Chart>           'use client' → client, and so is everything Chart imports
    └── <RecentOrders>    (server)

Two consequences that surprise people:

  1. A server component cannot be imported by a client component. The import would pull it into the client bundle, where await db.… is meaningless. Pass it as children or a prop instead — that composes fine, because it is rendered on the server and passed through as already-rendered output.

    // Works: the client component receives rendered output, not the component.
    <ClientTabs>
      <ServerOnlyPanel />
    </ClientTabs>
    
  2. Props that cross the boundary must be serializable. Strings, numbers, plain objects, arrays, Dates, Maps, Promises — yes. Functions, class instances, and Symbols — no. The error message names the prop, which is the fastest thing about debugging it.

Server components still render on the server for the client too

The naming is the single biggest source of confusion, so it is worth stating plainly: a client component is also rendered on the server during the initial request. use client does not mean "client-only". It means "this also runs in the browser, so it must also be shipped there".

That is why window is not defined errors happen inside components marked use client. The fix is to guard the browser-only access, not to remove the directive:

'use client'
export function Width() {
  const [width, setWidth] = useState<number | null>(null)
  useEffect(() => setWidth(window.innerWidth), []) // effects never run on the server
  return <span>{width ?? '—'}</span>
}

The four mistakes that undo the whole thing

1. use client at the top of the layout. Everything below it becomes a client component, and you have rebuilt a client-rendered SPA with extra build steps. Push the directive down to the leaf that actually needs interactivity — usually a button, a form, or a chart.

2. A use client file that re-exports server code. Barrel files are the usual culprit: components/index.ts marked use client drags every component in the directory into the bundle. Import from the specific file.

3. Sequential awaits that should be parallel. Server components make waterfalls easy to write and invisible to spot.

// Two round trips, one after the other
const user = await getUser(id)
const orders = await getOrders(id)

// One round trip's worth of wall clock
const [user, orders] = await Promise.all([getUser(id), getOrders(id)])

4. Leaking server-only modules. Anything that reads process.env secrets or opens a database connection should import server-only at the top, so an accidental client import fails at build time rather than shipping your credentials.

import 'server-only'
export const db = createClient(process.env.DATABASE_URL!)

Streaming is the other half

Because the server renders progressively, a slow section does not have to hold back the whole page. Wrap it in <Suspense> and the shell is sent immediately, with the slow part streamed in when it resolves.

export default function Page() {
  return (
    <>
      <Header /> {/* sent immediately */}
      <Suspense fallback={<OrdersSkeleton />}>
        <Orders /> {/* streamed when its await resolves */}
      </Suspense>
    </>
  )
}

The rule of thumb: put a Suspense boundary anywhere the data is slower than the rest of the page and the user can do something useful without it.

Common pitfalls

  • Reaching for useEffect to fetch data in a component that could simply have been async.
  • Marking a component use client to fix a hydration mismatch. The mismatch is usually non-deterministic output — Date.now(), Math.random(), or a locale format that differs between server and browser.
  • Assuming server components re-run on interaction. They do not; they re-run on navigation or when you invalidate their cache.
  • Passing a large payload across the boundary as props because it was convenient. It is serialized into the HTML response, and you pay for every byte twice.

Further reading