Apr 26, 2026
Next.js Client Components Best Practices for App Router Apps
A practical guide to Next.js client components best practices covering the use client directive, server/client boundaries, hydration safety, state, effects, and bundle control.
8 min read
Next.js Client Components Best Practices for App Router Apps
If you are looking for Next.js client components best practices, you are probably trying to answer a deceptively simple question: where should "use client" go? The App Router makes Server Components the default, but real applications still need interactivity, browser APIs, client state, analytics, forms, optimistic UI, and third-party widgets. Client Components are not a failure of server-first architecture. They are the precise tool for the parts of the interface that must run in the browser.
The challenge is that Client Components can quietly spread. One broad "use client" directive can pull a large section of your tree into the JavaScript bundle, expose logic that belonged on the server, or create hydration bugs that only show up after production data changes. This guide explains the use client directive Next.js teams should use, how to reason about server components vs client components, and how to keep interactive islands fast, secure, and maintainable. For the server-side half of this decision, read Next.js Server Components Patterns for Faster App Router Apps. If effects are the reason a file became client-side, pair this with React useEffect Best Practices for Next.js Apps. When the symptom is mismatched HTML, React Hydration Mismatch in Next.js: Causes, Fixes, and Prevention is the deeper debugging guide.
Start with server components vs client components
The cleanest App Router mental model is simple: Server Components prepare the page; Client Components handle browser-only interaction.
Use a Server Component when the code:
- fetches data from a database, CMS, or private API
- reads secure cookies, headers, or environment variables
- renders static or request-time HTML
- composes layouts, content, and non-interactive UI
- should avoid shipping its implementation to the browser
Use a Client Component when the code:
- uses state, effects, refs, or event handlers
- reads browser APIs such as
window,localStorage, ormatchMedia - depends on client-only libraries
- manages drag-and-drop, charts, maps, editors, or media controls
- coordinates optimistic UI or interactive filters
That split keeps most code server-rendered by default while preserving rich UI where the browser is actually needed.
// app/projects/page.tsx
import { ProjectFilters } from "@/components/projects/ProjectFilters";
import { ProjectsTable } from "@/components/projects/ProjectsTable";
import { getProjects } from "@/lib/projects";
export default async function ProjectsPage() {
const projects = await getProjects();
return (
<main>
<ProjectFilters />
<ProjectsTable projects={projects} />
</main>
);
}
The page fetches on the server. Only ProjectFilters needs browser interactivity. ProjectsTable can remain server-rendered if it only displays the data.
Put the use client directive as low as possible
The most important Next.js client components best practices rule is to keep "use client" close to the interactive leaf. A file marked with "use client" becomes a client entry point, and everything it imports must be compatible with the browser bundle.
Weak pattern:
"use client";
import { getProjects } from "@/lib/projects";
import { ProjectFilters } from "./ProjectFilters";
import { ProjectsTable } from "./ProjectsTable";
export function ProjectsPageShell() {
const projects = getProjects();
return (
<>
<ProjectFilters />
<ProjectsTable projects={projects} />
</>
);
}
This pushes too much into the client and risks importing server-only code into the browser.
Better pattern:
// components/projects/ProjectFilters.tsx
"use client";
import { useState } from "react";
export function ProjectFilters() {
const [query, setQuery] = useState("");
return (
<label>
Search
<input value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
);
}
The directive belongs on the smallest file that needs React client features. That usually produces smaller bundles, clearer review boundaries, and fewer accidental server/client import mistakes.
Pass serializable props across the boundary
Server Components can render Client Components, but the props crossing that boundary must be serializable. Plain objects, arrays, strings, numbers, booleans, and null are safe. Functions, class instances, database clients, and complex runtime objects are not.
// Server Component
import { TeamSwitcher } from "@/components/TeamSwitcher";
export default async function Layout() {
const teams = await getTeamsForCurrentUser();
return (
<TeamSwitcher
teams={teams.map((team) => ({
id: team.id,
name: team.name,
}))}
/>
);
}
// components/TeamSwitcher.tsx
"use client";
type Team = {
id: string;
name: string;
};
export function TeamSwitcher({ teams }: { teams: Team[] }) {
return (
<select>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
);
}
Do the privileged work on the server, then pass the minimum display shape into the client. This is also easier to test because the client component receives plain data instead of hidden infrastructure.
Keep data fetching server-first
Many Client Components exist because a team copied an older useEffect fetching pattern into the App Router. That is often unnecessary. If the data is needed for the first render and does not depend on browser-only state, fetch it in a Server Component.
// app/account/page.tsx
import { AccountPreferences } from "@/components/account/AccountPreferences";
import { requireUser } from "@/lib/auth";
import { getPreferences } from "@/lib/preferences";
export default async function AccountPage() {
const user = await requireUser();
const preferences = await getPreferences(user.id);
return <AccountPreferences initialPreferences={preferences} />;
}
The client component can still handle toggles and form state, but the secure read happens before the browser receives the page. If the screen needs client-side refetching, pagination, or optimistic mutations, use a dedicated server-state layer. TanStack Query with Next.js App Router: Server State Without useEffect covers that path without turning every fetch into a hand-written effect.
Use Client Components for local interaction, not server truth
Client state is excellent for transient UI. It is weaker as the source of truth for permissions, billing status, inventory, or private records. Keep that distinction sharp.
Good client-state examples:
- open or closed panels
- selected tabs
- unsaved form drafts
- table sort and filter controls
- optimistic pending states
- temporary wizard progress
Server-owned examples:
- authenticated user roles
- subscription entitlements
- product inventory
- audit logs
- payment status
- organization membership
For shared client state, choose the smallest tool that fits. Local useState is enough for one component. Context works for stable values in a subtree. Zustand fits frequently updated global UI state. The tradeoffs are covered in React Context vs Zustand: Which State Management Pattern Fits Your App?.
Avoid hydration drift from browser-only values
A common App Router bug happens when the server renders one value and the browser immediately renders another. Client Components make browser APIs available, but they do not make the server aware of localStorage, viewport size, timezone formatting, or user agent decisions.
"use client";
import { useEffect, useState } from "react";
export function TimezoneNotice() {
const [timezone, setTimezone] = useState<string | null>(null);
useEffect(() => {
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}, []);
if (!timezone) {
return <p>Using your browser timezone.</p>;
}
return <p>Timezone: {timezone}</p>;
}
This pattern is acceptable because the first render is intentionally generic. It would be risky if the server rendered a specific timezone label and the client replaced it with a different one during hydration. For layout-changing preferences, prefer a server-readable cookie or a stable fallback.
Keep effects narrow and cleanup obvious
Client Components often need useEffect, but effects should not become the default place for application logic. Use them when synchronizing with systems outside React: subscriptions, event listeners, browser APIs, analytics, timers, and imperative widgets.
"use client";
import { useEffect, useState } from "react";
export function ReducedMotionToggle() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
function updatePreference() {
setPrefersReducedMotion(media.matches);
}
updatePreference();
media.addEventListener("change", updatePreference);
return () => {
media.removeEventListener("change", updatePreference);
};
}, []);
return <span>{prefersReducedMotion ? "Reduced motion" : "Full motion"}</span>;
}
The setup and cleanup mirror each other, which makes review straightforward. If an effect has unrelated responsibilities, split it or move event-driven work into event handlers.
Watch bundle cost at every client boundary
Every Client Component contributes to the JavaScript the browser must parse, execute, and hydrate. That does not mean client code is bad. It means broad client entry points need justification.
Use this review checklist before adding "use client":
- Does this file actually use state, effects, refs, browser APIs, or events?
- Can the interactive part be extracted into a smaller child component?
- Are server-only imports kept outside the client boundary?
- Are props crossing the boundary plain and minimal?
- Does the first render remain useful before effects run?
- Is remote data fetched server-first unless client freshness is required?
- Could a heavy client-only library be lazy-loaded behind the interaction that needs it?
For expensive widgets such as charts, editors, maps, and visualizers, consider dynamic imports so the main route does not pay for the widget before the user needs it.
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
const ChartEditor = dynamic(() => import("./ChartEditor"), {
ssr: false,
loading: () => <p>Loading editor...</p>,
});
export function ChartEditorLauncher() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Edit chart</button>
{isOpen ? <ChartEditor /> : null}
</>
);
}
Use ssr: false only for components that truly cannot render on the server. It is a tool for browser-only dependencies, not a default performance strategy.
Final takeaway
The best Next.js client components best practices are really boundary practices. Keep Server Components responsible for data access, secure logic, and static composition. Keep Client Components responsible for local interaction, browser APIs, and focused client state. Put the use client directive Next.js requires as low as practical, pass plain props across the boundary, and make every effect or heavy dependency earn its place.
Once the team gets that boundary right, server components vs client components stops feeling like a framework debate. It becomes a normal architecture decision: run code on the server when it prepares trusted HTML, and run code in the browser when the user needs direct interaction.