If you have moved to the Next.js App Router and ever felt that something was off, that you were sprinkling "use client" at the top of files just to make an error disappear, you are in good company. The App Router changed the default question from "how do I fetch data on this page?" to "where should this code run?". Once that question clicks, a lot of confusing behaviour suddenly makes sense.
This article walks through the mental model I use, the rules that follow from it, and the mistakes I see most often.
The one idea that explains everything
In the App Router, every component is a Server Component unless you opt out. A Server Component renders on the server, and only its output (HTML plus a compact description of the React tree) is sent to the browser. Its JavaScript never ships to the user.
A Client Component is a component that also runs in the browser. You get one by putting "use client" at the top of the file. It is still pre-rendered to HTML on the server for the first paint, but its code is also shipped and "hydrated" so it can respond to clicks, hold state and use browser APIs.
Server Components are about what you can leave out of the bundle. Client Components are about what you need in the browser.
What each side can and cannot do
Server Components
- Can be
asyncandawaitdata directly: databases, internal APIs, the file system. - Can safely use secrets such as API keys and connection strings, because the code never reaches the browser.
- Cannot use
useState,useEffect, event handlers likeonClick, or browser-only globals such aswindowandlocalStorage.
Client Components
- Can use state, effects, refs, context and any browser API.
- Handle interaction: forms, dropdowns, drag and drop, animation driven by user input.
- Add to the JavaScript bundle, so every one has a real cost for your users on slow phones.
A simple example
Imagine a blog post page with a like button. Most of the page is static content; only the button needs interactivity.
// app/blog/[slug]/page.tsx (Server Component, no directive needed)
import { getPost } from "@/lib/posts"
import LikeButton from "./like-button"
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug) // runs on the server
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
)
}
// app/blog/[slug]/like-button.tsx
"use client"
import { useState } from "react"
export default function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes)
return (
<button onClick={() => setLikes((n) => n + 1)}>
♥ {likes}
</button>
)
}
Notice what happened: the article body, the data fetching and the markdown processing stay on the server. Only the tiny button ships JavaScript. This is the pattern to aim for: push "use client" down to the leaves of your tree.
The mistake I see most: marking whole pages as client
It is tempting to put "use client" on a page because one small part needs state. The problem is that the directive is contagious downwards: everything that file imports becomes part of the client bundle too. A single directive at the top of a large page can quietly turn a fast, server-rendered page into a big client-side app.
The fix is almost always the same. Extract the interactive piece into its own small component and keep the page itself on the server.
Passing things between the two worlds
There are a few rules worth memorising:
- A Server Component can import and render a Client Component. This is the normal direction.
- A Client Component cannot import a Server Component. The module would have to ship to the browser, which defeats the point.
- But a Client Component can receive a Server Component as
childrenor another prop. The server renders it, and the client just places it. - Props crossing the boundary must be serialisable. Strings, numbers, plain objects and arrays are fine. Functions, class instances and Dates-as-objects need care.
Rule three is a great trick. A client-side modal or sidebar can wrap server-rendered content without turning that content into client code:
// Server Component
<Sidebar>
<ExpensiveServerRenderedMenu />
</Sidebar>
Where should data fetching live?
Whenever you can, fetch on the server. You skip the loading spinner, avoid an extra network round trip from the browser, and keep tokens private. Use client-side fetching when the data truly depends on the browser: live updates, user-triggered searches, or state that changes constantly after load.
A good rule of thumb: initial data on the server, changes over time on the client. You can even combine them. Fetch the first page of results on the server and hand it to a client component as initialData for a library like React Query or SWR.
Waiting without blocking: Suspense and streaming
If one part of a page is slow, you do not have to hold up the rest. Wrap the slow Server Component in <Suspense> with a fallback, and Next.js will stream the page: the fast parts arrive immediately, and the slow part pops in when it is ready.
import { Suspense } from "react"
export default function Dashboard() {
return (
<>
<Header />
<Suspense fallback={<p>Loading revenue…</p>}>
<RevenueChart /> {/* async Server Component */}
</Suspense>
</>
)
}
A quick decision checklist
When you create a component, ask yourself these questions in order:
- Does it need state, effects, or event handlers? → Client.
- Does it use
window,documentor another browser API? → Client. - Does it only display data I can fetch ahead of time? → Server.
- Does it need a secret or direct database access? → Server.
- Unsure? Start on the server and move down to a client leaf only when the compiler complains.
Common pitfalls, quickly
- Reading
localStorageduring render. It does not exist on the server. Read it insideuseEffect, or you will get hydration mismatches. - Context providers. Providers need to be Client Components, but you can put them in one small
providers.tsxfile and wrapchildren. The rest of your app can stay on the server. - Importing a heavy library into a client file. Check your bundle with
@next/bundle-analyzer. Charts and editors are common offenders, andnext/dynamiccan load them only when needed. - Assuming a server component re-renders on click. It does not. Interaction lives on the client; use a Server Action or refresh the route to re-run server code.
Wrapping up
You do not need to memorise a long list of rules. Remember one sentence: keep as much as possible on the server, and add the client only where the user interacts. Do that, and you will ship less JavaScript, expose fewer secrets and end up with pages that feel faster without any extra tricks.
If you are migrating an existing app, do not rewrite everything at once. Pick one slow, content-heavy page, convert it to a Server Component, extract the interactive parts, and measure the difference. That first win usually convinces the whole team.