Server and Client Components
By the end of this lesson
Decide where a component should run, and why it matters.
In a Next.js application every component is one of two kinds. A server component runs on the server, produces HTML, and sends no JavaScript for itself to the browser. A client component runs on the server for the initial HTML and then again in the browser, where it becomes interactive.
Components are server components by default. A file becomes a client component when its first line is "use client", and everything it imports becomes part of the browser bundle too.
The decision is not about preference. Each kind can do things the other cannot, and the interesting part is that the boundary between them is something you place deliberately.
What each kind can and cannot do:
| Server component | Client component | |
|---|---|---|
| Where the code runs | On the server only | On the server for the first HTML, then in the browser |
| JavaScript sent to the browser | None for this component | This component and everything it imports |
| Data access | Await a database query or an API call directly; read server environment variables | Through an API request, or passed in as props |
| State, effects, event handlers | No — there is no interaction to respond to | Yes. This is what it is for |
| Browser APIs | No window, no localStorage, no measuring elements | Yes, once running in the browser |
| Secrets | Safe: the code and its values never leave the server | Unsafe: assume anything here is readable by the user |
// app/employees/page.tsx — a server component. No "use client" here.
import { getEmployees } from "@/data/employees";
import { EmployeeFilterPanel } from "./EmployeeFilterPanel";
export default async function EmployeesPage() {
// Runs on the server: a direct data call, no API round trip,
// and no loading state because the HTML waits for it.
const employees = await getEmployees();
return (
<section>
<h1>Employee directory</h1>
{/* The interactive part receives already-loaded data. */}
<EmployeeFilterPanel employees={employees} />
</section>
);
}- The page has no "use client", so it is a server component. Its code is never sent to the browser, and neither is getEmployees or anything that function imports.
- It awaits data directly. There is no effect, no state and no loading flag, because the component runs once and produces finished HTML.
- It imports a client component and renders it. That direction works, which is the key fact for structuring an application: server components sit above, client components are islands within them.
- The employees array crosses the boundary as a prop. It gets serialised into the page, so the filter panel has its data the instant it becomes interactive rather than requesting it again.
"use client";
import { useState } from "react";
export function EmployeeFilterPanel({ employees }: { employees: Employee[] }) {
const [term, setTerm] = useState("");
const matches = employees.filter((employee) =>
employee.name.toLowerCase().includes(term.toLowerCase())
);
return (
<div>
<label htmlFor="term">Search by name</label>
<input id="term" value={term} onChange={(event) => setTerm(event.target.value)} />
<p aria-live="polite">{matches.length} of {employees.length} employees</p>
<ul>
{matches.map((employee) => (
<li key={employee.id}>{employee.name}</li>
))}
</ul>
</div>
);
}- "use client" marks the boundary. This file and its imports go into the browser bundle, which is the price of the state and the change handler.
- The filtering happens in the browser with no network involved, so typing is instant. The data arrived with the page.
- aria-live="polite" announces the changing result count, so a screen reader user learns that the list narrowed. Without it the count updates silently.
- This component is as small as the interactivity requires. Everything around it — the heading, the page shell, the navigation — stayed on the server.
A component needs "use client" when it uses any of these. Otherwise leave it on the server:
- useState, useReducer, or any other hook that holds state
- useEffect or useLayoutEffect
- An event handler: onClick, onChange, onSubmit, onKeyDown
- A browser API: window, document, localStorage, matchMedia, IntersectionObserver
- useRef pointing at a DOM element you focus, measure or scroll
- A third-party component that itself needs the browser — a chart that measures its container, a map, a rich text editor
- Context: createContext and useContext both need a client boundary, and so does the provider
Summary
- Components are server components by default; "use client" marks where the browser bundle begins
- Server components fetch data directly, read secrets and ship no JavaScript for themselves
- Client components are needed for state, effects, event handlers and browser APIs
- Marking everything "use client" keeps the errors away and discards the benefit — push the boundary down instead
- A server component can import a client one, not the reverse; pass server markup down as children
- Props crossing the boundary must be serialisable data, not functions or class instances
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
You have a course page that loads a course from the database, shows its outline, and has an expandable module list plus a "Mark as complete" button that writes to local storage.
Decide which files are server components and which are client components, and where the boundary goes. Aim for the smallest client bundle you can.
Show solution
The page stays on the server: it queries the course and renders the heading, description and outline as HTML with no JavaScript attached. Two small client components handle the interactive parts — one for the expandable module list, one for the completion button that touches local storage.
The important detail is that the module list's content does not have to be a client component. The client wrapper owns the open and closed state and renders whatever children it is given, so the server-rendered lesson markup passes straight through. That keeps the formatting, the links and any helper functions out of the browser bundle.
A tempting alternative is to mark the whole page "use client" and use one piece of state for everything. It is fewer files, and it sends the entire page's code plus the database helper's imports to the browser, and forces the course data to be fetched through an API route instead of directly.
The completion button is separate from the module list on purpose. They share no state, so combining them would put both into one bundle chunk and couple two unrelated pieces of behaviour.
// app/courses/[slug]/page.tsx — server component
import { getCourseBySlug } from "@/data/courses";
import { ExpandableSection } from "./ExpandableSection";
import { CompletionButton } from "./CompletionButton";
export default async function CoursePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const course = await getCourseBySlug(slug);
return (
<article>
<h1>{course.title}</h1>
<p>{course.shortDescription}</p>
{course.modules.map((module) => (
// Client component for the toggle; server-rendered markup as children.
<ExpandableSection key={module.slug} title={module.title}>
<ul>
{module.lessons.map((lesson) => (
<li key={lesson.slug}>{lesson.title}</li>
))}
</ul>
</ExpandableSection>
))}
<CompletionButton courseSlug={course.slug} />
</article>
);
}
// ExpandableSection.tsx
"use client";
import { useState, type ReactNode } from "react";
export function ExpandableSection({ title, children }: { title: string; children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<section>
<h2>
<button type="button" onClick={() => setIsOpen((open) => !open)} aria-expanded={isOpen}>
{title}
</button>
</h2>
{isOpen && children}
</section>
);
}Think about it
Think about it
A colleague passes a formatDate function from a server component to a client component as a prop and gets an error about a prop not being serialisable.
Why is this a genuine limit rather than something the framework could paper over? What are the options?
Show solution
Props that cross the boundary have to be written into the page as data for the browser to read back. A function is code with a scope around it — the variables it closed over, the modules it imported. There is no honest way to turn that into data and reconstruct it in another process.
The options, roughly in order of preference. Define the function in the client component, since formatting is browser-safe code that belongs where it is used. Or do the formatting on the server and pass the finished string, which sends less JavaScript. Or, if the function has to run on the server in response to something the user does, make it a server action — which works because the framework passes a reference the browser can call, not the function itself.
The general point is that the boundary is a process boundary. Anything crossing it is data in transit, which is also why a Date survives and a database row object with methods on it does not. Mapping server data to plain fields before passing it is a good habit regardless: it keeps the client component's inputs small and stops internal fields leaking into the page source.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.