RizTech Academy logo
RizTech Academy
Next.js: Routing and RenderingLesson 2 of 635 min

Server components versus client components

This is the most important lesson in the course. Module 1 said two computers; this is where you decide, per component, which one runs it — and most Next.js confusion traces back to this one idea.

The default is the server

In the App Router, every component is a server component unless you say otherwise.

// Runs on the server. Never sent to the browser.
export default async function ProductPage() {
  const products = await db.product.findMany();
  return <ProductGrid products={products} />;
}

That component can be async, can query the database directly, and can read secrets. Its code never reaches the browser — only the HTML it produced.

Three consequences worth sitting with:

No API call needed. A server component talking to a database is already on the server. There is no round trip because there is no trip.

Less JavaScript shipped. A server component contributes nothing to the bundle. On a cheap phone, that is the difference between a page being usable immediately and after three seconds.

Secrets are safe. process.env.DATABASE_URL in a server component is fine. The same line in a client component is a leak.

When you need a client component

"use client";

import { useState } from "react";

export function QuantityPicker() {
  const [quantity, setQuantity] = useState(1);
  return <button onClick={() => setQuantity(quantity + 1)}>{quantity}</button>;
}

"use client" at the very top of the file. You need it for:

  • useState, useEffect, or any hook
  • Event handlers — onClick, onChange, onSubmit
  • Browser APIs — window, localStorage, navigator
  • Third-party libraries that use any of the above

The error tells you when you have forgotten:

You're importing a component that needs useState. This React hook only works
in a client component.

That message is a good one. Read it rather than adding "use client" reflexively — sometimes the right fix is to move the interactive part into its own small component instead.

The directive is contagious downwards

"use client";

import { Something } from "./something";     // now also client

Everything a client component imports becomes client code. The directive marks a boundary, not a single file.

This is why "use client" on a layout or a page is expensive: it drags the whole subtree into the browser bundle.

Push the boundary as far down as you can. Not this:

"use client";                    // the whole page is now client

export default function ProductPage({ product }) {
  const [quantity, setQuantity] = useState(1);
  return (
    <div>
      <ProductGallery images={product.images} />
      <ProductDescription html={product.description} />
      <QuantityPicker value={quantity} onChange={setQuantity} />
    </div>
  );
}

This:

// server component
export default async function ProductPage({ params }) {
  const product = await getProduct((await params).slug);
  return (
    <div>
      <ProductGallery images={product.images} />
      <ProductDescription html={product.description} />
      <AddToCartForm product={product} />      {/* only this is client */}
    </div>
  );
}

The gallery and description stay on the server. Only the form ships JavaScript.

Passing server components into client ones

A client component cannot import a server component. It can render one passed as children:

"use client";

export function Accordion({ title, children }: {
  title: string;
  children: React.ReactNode;
}) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>{title}</button>
      {open && children}
    </div>
  );
}
// server component
<Accordion title="Reviews">
  <ProductReviews productId={product.id} />   {/* stays a server component */}
</Accordion>

ProductReviews was rendered on the server and handed in as already-rendered output. The accordion controls whether it is shown without ever running its code.

This pattern is how you keep an interactive shell around server-rendered content, and it is worth knowing because the alternative — making everything client — is the mistake most people make.

What crosses the boundary

Props passed from a server component to a client component are serialised. They travel as data, so they must be serialisable.

Works: strings, numbers, booleans, null, arrays, plain objects, Date, Map, Set.

Does not:

<ClientThing onSomething={() => console.log("hi")} />
Error: Functions cannot be passed directly to Client Components

Class instances do not survive either — a Prisma model comes back as a plain object, and a Decimal field arrives as something you must convert. Pass plain data.

Server actions are the exception: they are functions that can be passed, because they are references to something on the server rather than the function itself. That is two lessons away.

Hydration mismatches

"use client";
export function Clock() {
  return <p>{new Date().toLocaleTimeString()}</p>;
}
Error: Text content did not match. Server: "14:30:01" Client: "14:30:02"

The server rendered HTML with one value, the browser rendered another, and React noticed they disagree.

Causes: Date.now() or new Date(), Math.random(), window or localStorage, anything varying by timezone or locale.

The fix is to render something stable on the server and fill in the real value after mounting:

const [time, setTime] = useState<string | null>(null);
useEffect(() => setTime(new Date().toLocaleTimeString()), []);
return <p>{time ?? "—"}</p>;

There is suppressHydrationWarning for genuinely unavoidable cases. It silences the warning without fixing anything, so use it only when you have understood why the mismatch is harmless.

Deciding

Ask two questions.

Does it need data or secrets? Then server.

Does it need interaction, state or a browser API? Then client.

Both? Split it. A server component fetches and passes plain data into a small client component that handles the interaction. That shape — server shell, client islands — is the whole architecture of a well-built Next.js application.

Check your work

The default: every component is a server component unless the file says "use client".

Three things server components give you: no API round trip when reading a database, nothing added to the browser bundle, and safe access to secrets.

Why "use client" is contagious: everything a client component imports becomes client code, so the directive marks a boundary rather than one file.

Where to put the boundary: as far down as possible — a server page with small client islands, not a client page containing server-shaped content.

How a client component can render a server one: by receiving it as children. It cannot import one.

Why a function cannot be passed as a prop across the boundary: props are serialised, and functions are not serialisable. Server actions are the exception.

What causes a hydration mismatch: anything that differs between the server render and the browser render — Date.now(), Math.random(), window, locale-dependent formatting.

How to fix one: render something stable on the server and fill in the real value in an effect after mounting.

Practice

  1. Build a server component that logs to the console. Note the log appears in your terminal, not the browser.
  2. Add useState to it without "use client". Read the error, then fix it.
  3. Put "use client" on a page and inspect the JavaScript bundle size. Move it down to one small component and compare.
  4. Read process.env.DATABASE_URL in a server component, then in a client one. Note the second is undefined.
  5. Pass a function as a prop from a server to a client component. Read the error.
  6. Build an Accordion client component that takes server-rendered children. Confirm the inner content is not in the client bundle.
  7. Render new Date() in a client component and produce a hydration mismatch. Fix it with useEffect.
  8. Take a product page and identify the smallest possible client boundary.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship