Skip to main content
Web app skills · Lesson 1 of 11 · 13 min

Server vs Client Components in Next.js for Beginners

Learn Next.js server vs client components with real code: what runs where, the use client boundary, passing children, and what ships to the browser.

By Matthews WongReviewed on

Key takeaway

In the Next.js App Router every component is a Server Component by default: it renders on the server and ships no JavaScript of its own. Adding use client at the top of a file marks a boundary, and that file and its imports join the browser bundle so they can use state, effects and browser APIs.

You will be able to

  • Decide per component whether it runs only on the server or also in the browser.
  • Nest server-rendered content inside a client component by passing it as children.
  • Keep heavy code out of the first load, and page text in the server HTML.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

One page, two environments: the /play route

The server renders the landing text into HTML, PlayShell crosses the use client boundary and hydrates in the browser, and the 3D engine downloads only after the visitor accepts.

Step 1 of 6. The play page is a Server Component. Its landing text stays on the server, its PlayShell gate is a Client Component, and the 3D game is a lazy chunk not yet fetched.

Keys: ← → step · Home / End · K play or pause

  1. The play page is a Server Component. Its landing text stays on the server, its PlayShell gate is a Client Component, and the 3D game is a lazy chunk not yet fetched.

  2. page.tsx runs only on the server. It reads the messages and renders PlayLanding, a Server Component, so none of that component's code is sent to the browser.

    Predict the next step Next is PlayShell, marked with use client. What does the browser receive for it? (Only its rendered HTML · Its JavaScript, so it can run)
  3. PlayShell starts with use client. That file and everything it imports join the client bundle, and its props must be serializable: here a locale string and a number.

    "use client";
  4. The browser gets finished HTML with the landing text already inside, plus a payload marking where PlayShell goes. Crawlers and slow phones can read this before any script runs.

    Predict the next step The visitor has not accepted the notice yet. Does the 3D engine download now? (Yes, together with the page · No, it waits for the visitor)
  5. JavaScript hydrates PlayShell, which shows the notice. Game sits behind next/dynamic with ssr false, so the engine chunk stays unfetched until the visitor accepts.

  6. After the visitor accepts, a client-state branch mounts Game and the browser fetches that lazy chunk. Only now does the engine's weight reach this visitor.

What runs on the server, and what ships to the browser?

In the App Router, layouts and pages are Server Components by default. A Server Component renders ahead of time, in an environment separate from the browser, either once at build time or for each request. The browser does not receive its code. It receives only the rendered output, which is why server code can query a database or use a secret without exposing either.

Read more (2)

Use a Client Component when you need state and event handlers, lifecycle logic such as useEffect, browser-only APIs such as localStorage or window, or custom hooks. A Server Component cannot use useState, because it is never sent to the browser to hold state. That one rule explains most of the errors beginners hit.

A common misreading: a Client Component is not client-only. On the first load, Next.js uses the Client Components and the Server Component payload to prerender HTML, then JavaScript hydrates the Client Components to attach event handlers. The user sees real HTML first. Later navigations fetch just the payload, and Client Components render in the browser.

What does the use client directive actually do?

Write use client at the very top of a file, before the imports. It declares a boundary between the server and client module graphs. Every module that file imports, and every component it renders directly, goes into the client bundle. You do not repeat the directive in each child file, and you only need it on files whose components a Server Component renders directly.

Read more (2)

Props that cross the boundary must be serializable, so React can send them from server to browser. A string, a number, an object of plain values: fine. A function: not allowed. This is why this site's PlayShell takes a locale string and a byte count, and nothing like an onClose callback.

Because the directive pulls in a whole subtree, put it as deep as you can. The Next.js docs make the point with a layout that is mostly a logo and links but contains one search bar: only the search bar becomes a Client Component, and the rest stays on the server.

How do you mix them? Pass server content as children

You can pass a Server Component as a prop, most often children, to a Client Component. The Client Component only provides a slot, such as a modal that toggles visibility with state. The Server Component is rendered on the server ahead of time, and the payload carries its rendered result plus a placeholder where the Client Component goes. The passed component is not imported into the client module graph, so its code does not ship.

Read more (2)

The same shape handles providers. React context is not supported in Server Components, so a provider is a small Client Component that takes children, and the server layout renders it. Render providers as deep as possible: wrap the children, not the whole html element, so Next.js can still optimize the static parts.

This site's SiteChrome is a worked example. It must read the pathname, so it is a Client Component. But the header, the footer and the floating helpers are passed in as props from the server layout, so SiteChrome only decides whether to show them.

How does this site keep heavy code and page text apart?

Heavy code goes behind next/dynamic. The docs describe it as a composite of React.lazy and Suspense, and lazy loading applies to Client Components. The ssr false option works only inside a Client Component. That is why the 3D engine is imported in PlayShell, a Client Component, and only mounts after the visitor accepts a notice. A build script enforces it: three may be imported only under the game folders, and no route may reach the engine through a static import.

Read more (2)

Page text goes the other way: it renders inline, never behind a loading state. A loading.js file wraps the page in a Suspense boundary. React then streams the real content in a hidden element and swaps it in with an inline script, so a reader that does not run scripts sees the fallback in main and the article only inside the hidden element. The Next.js docs say streaming does not hurt SEO for crawlers that render the page, but this site chooses the stricter rule.

So this site has no route loading file, and no loading option on a dynamic import that carries text. The one loading card you will find, GameLoading, shows only inside the game overlay after the visitor accepts, which is a client-state branch, so it is never in the server HTML.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

app/components/game/PlayShell.tsx:43-48
// The engine chunk (three, fiber, drei, bvh) only ever loads here, and only
// after the visitor accepts — the whole point of the notice gate.
const Game = dynamic(() => import("@/app/components/game/Game"), {
  ssr: false,
  loading: () => <GameLoading />,
});

The lazy boundary. PlayShell is a Client Component, so ssr: false is legal here. The comment names the intent: the engine chunk loads only after the visitor accepts. The loading card is safe because Game mounts only in a client-state branch, after acceptance.

app/[locale]/(pages)/play/page.tsx:48-63
      {/* The landing: stills, premise, steps, facts. No client JavaScript. */}
      <PlayLanding
        locale={pageLocale}
        firstLoadBytes={manifest.firstLoadBytes}
      />

      {/* The gate. Before acceptance this is the notice card; after it, the
          fixed overlay that is the game. The hero button jumps here. */}
      <div className="mt-12">
        <NextIntlClientProvider messages={stripToEssentialBlogPosts(messages)}>
          <PlayShell
            locale={pageLocale}
            firstLoadBytes={manifest.firstLoadBytes}
          />
        </NextIntlClientProvider>
      </div>

The server page. PlayLanding is a Server Component with no client JavaScript, so its text is in the HTML. PlayShell is the gate, the only Client Component here. It sits inside a provider that carries just the messages the game needs.

app/components/SiteChrome.tsx:32-38
interface SiteChromeProps {
  header: ReactNode;
  footer: ReactNode;
  /** Floating helpers: the chatbot, the selection explainer. */
  extras: ReactNode;
  children: ReactNode;
}

The slot pattern. SiteChrome is a Client Component, yet its props are ReactNode values: the server layout renders the header and footer and hands them over. SiteChrome decides only whether to show them.

app/[locale]/layout.tsx:499-503
              {/* SiteChrome drops the header, footer and floating helpers on
                the immersive /play route; every other route renders them as
                before. */}
              <SiteChrome
                header={<Header />}

The other half of the slot. The server layout renders Header and passes it in as a prop. Header is itself a Client Component, so passing it this way is allowed. The comment records why: /play is immersive and drops the chrome.

What breaks

Mistakes that cost real time

  • State or an event handler in a Server Component

    Importing useState or writing onClick in a file without use client fails, because a Server Component is never sent to the browser. Move only the interactive piece into its own small file with use client, and keep the page a Server Component.

  • A function passed from server to client

    Props that cross the use client boundary must be serializable, and a function is not. Pass plain data, and define the handler inside the Client Component. The docs show an onClick prop flagged as not serializable.

  • Server-only code imported into a client file

    A module is shared between both environments, so a data helper that reads a secret can be imported into a Client Component by accident. Only variables prefixed NEXT_PUBLIC_ reach the browser, so the secret turns into an empty string and the call fails quietly. Import the server-only package at the top of the helper to get a build error instead.

  • ssr: false in a Server Component

    next/dynamic with ssr false is not allowed in a Server Component, and Next.js shows an error. Move the dynamic import into a Client Component, as PlayShell does. The docs add that this also keeps the client code splitting working.

  • A loading skeleton hides the page text

    A loading.js or a loading option on a dynamic import makes React ship the page content after the footer inside a hidden element, with a skeleton in main. Anything that does not run scripts reads the skeleton. This site found exactly that on every page, and fixed it on 2026-10-13 by removing them.

Server Component or Client Component
How you get oneThe default in app/use client at the top of the file
State, effects, browser APIsNot availableAvailable
Secrets and direct data accessSafe to useNever put secrets here
JavaScript sent to the browserNone of its ownIts code and its imports
Where it rendersOn the server onlyServer for first HTML, then browser
Try it yourself

Do it on your own machine

You need Node and a fresh Next.js app. You will build one Server Component page with one small Client Component, then look at what the browser actually receives.

  1. Create the app and start it. Accept the defaults, including the App Router.

    npx create-next-app@latest lab
    cd lab
    npm run dev
  2. Add a Client Component that owns the only state on the page.

    // app/like-button.tsx
    "use client";
    
    import { useState } from "react";
    
    export default function LikeButton({ start }: { start: number }) {
      const [likes, setLikes] = useState(start);
      return <button onClick={() => setLikes(likes + 1)}>{likes} likes</button>;
    }
  3. Make the page a Server Component that renders text and passes a plain number to the button.

    // app/page.tsx
    import LikeButton from "./like-button";
    
    export default function Page() {
      return (
        <main>
          <h1>Server text, rendered once</h1>
          <LikeButton start={3} />
        </main>
      );
    }
  4. View the page source, not the inspector. The heading and the button text are already in the HTML, even though the button is a Client Component.

    curl -s http://localhost:3000 | grep -o 'Server text, rendered once'
  5. Break it on purpose: pass a function as a prop from page.tsx, such as onDone={() => {}}. Read the error, then remove it.

  6. Wrap the button in a Client Component slot that takes children, then pass a second Server Component through it.

    // app/panel.tsx
    "use client";
    
    import { useState, type ReactNode } from "react";
    
    export default function Panel({ children }: { children: ReactNode }) {
      const [open, setOpen] = useState(false);
      return (
        <div>
          <button onClick={() => setOpen(!open)}>Toggle</button>
          {open && children}
        </div>
      );
    }
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.A component file in app/ imports useState and has no use client line. What happens?
  2. 2.A server page tries to pass an onClose function to PlayShell, which has use client. Why does it fail?
  3. 3.A Server Component is passed as children into a Client Component modal. Where does its code end up?
Think it through

Answer in your head, then check

  1. 1.Does use client mean a component never runs on the server?Show the answer

    Answer:No. On the first load, Client Components are prerendered to HTML on the server together with the Server Component payload, then hydrated in the browser. Use client changes what is shipped as JavaScript, not whether the first HTML exists.

  2. 2.Why do pages other than /play not download the 3D engine?Show the answer

    Answer:The engine sits behind next/dynamic in PlayShell and mounts only after acceptance. A prebuild script also fails the build if three is imported outside the game folders or any route reaches the engine through a static import.

  3. 3.A teammate adds a loading skeleton to a text-heavy page. What do you tell them?Show the answer

    Answer:Render the text inline. A Suspense fallback puts the skeleton in main and the real text in a hidden element, which a reader without scripts never sees. Navigation feedback comes from the route progress bar instead.

Go deeper

Related deep dives on this site