Next.js 16 App Router: Server Components vs Client Components Explained
TL;DR: Everything under app/ is a Server Component by default — it renders on the server, never ships its JavaScript to the browser, and can talk to a database directly. You reach for a Client Component ('use client') only when you need something a server genuinely can't do: state, event handlers, browser APIs. That one directive is the entire boundary, and I'm going to show you exactly how getting it slightly wrong quietly cost me caching on this very blog — real numbers, real fix, from earlier today.
The Default You Already Have
Every file under app/ — pages, layouts, anything they import — is a Server Component unless you say otherwise. It runs once, on the server, and does things a browser should never be trusted with:
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'
export default async function Page({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const post = await getPost(id) // runs on the server, zero client-bundle cost
return (
<div>
<h1>{post.title}</h1>
<LikeButton likes={post.likes} />
</div>
)
}params being a Promise you have to await isn't a typo — it's a real breaking change in recent Next.js versions, and it's directly relevant to the story below, because searchParams works the exact same way and it's what actually broke my caching.
A Server Component never ships its own code to the browser. The client only ever receives the React Server Component Payload — a compact description of what to render, plus placeholders marking exactly where a Client Component needs to take over.
Client Components: Opting In, Not Opting Out
You reach for a Client Component specifically when you need useState, onClick, useEffect, localStorage — something that only exists in a browser:
'use client'
import { useState } from 'react'
export default function LikeButton({ likes }: { likes: number }) {
const [count, setCount] = useState(likes)
return <button onClick={() => setCount(count + 1)}>{count} likes</button>
}What trips people up: 'use client' doesn't mark that one component as client-only — it marks a boundary. Every module that file imports, and every component it directly renders, gets pulled into the client bundle with it. Put the directive on a small leaf component, and only that small piece of the tree ships JS. Put it on a shared layout, and everything underneath does too. Which is why the standard advice is to push it as far down the tree as it'll go — a layout stays a Server Component even when one small piece of it, like a search box, needs to be interactive:
// app/layout.tsx — stays a Server Component
import Search from './search' // Client Component
import Logo from './logo' // Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<nav>
<Logo />
<Search /> {/* only this becomes client JS */}
</nav>
<main>{children}</main>
</>
)
}Interleaving: Passing Server Components Into Client Components
Can a Client Component render a Server Component? Not by importing it — but yes, through children. A Server Component passed as a child of a Client Component gets rendered on the server ahead of time; the Client Component just receives its already-rendered output as a slot to drop into place:
'use client'
// app/ui/modal.tsx
export default function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return open ? <div className="modal">{children}</div> : null
}// app/page.tsx — a Server Component
import Modal from './ui/modal'
import Cart from './ui/cart' // fetches data server-side
export default function Page() {
return (
<Modal>
<Cart />
</Modal>
)
}Cart never becomes part of Modal's client bundle, even though it's visually nested inside it — it was never imported by a 'use client' file, just passed through as already-rendered output. This is the escape hatch that means wrapping something in an interactive shell doesn't force your data-fetching code into the browser.
The Real Incident: How I Broke My Own Caching Without Touching a Single Byte of HTML
I want to walk through something that actually happened on this exact blog earlier today, because it's a far better teacher than a made-up example. An SEO audit flagged that every post page, every tag page, and the main blog listing were serving Cache-Control: private, no-store on every single request — meaning Next.js was fully recomputing the page from scratch for every visitor and every Googlebot crawl, with zero help from any CDN in front of it.
Here's the part that made it a genuinely interesting bug: the post page already declared export const revalidate = 60, explicitly asking for 60-second incremental static regeneration. The cache header should have been s-maxage=60. It wasn't. The revalidate export was just being silently ignored.
The culprit was three lines, buried in the middle of an otherwise unremarkable page component:
export const revalidate = 60 // <- declared, and then quietly overridden below
export default async function PostPage({ params }: Props) {
const { locale, slug } = await params
const post = await getBySlug(slug)
const session = await getServerSession(authOptions) // <- the actual culprit
const isAuthenticated = !!session
// ...later in the JSX, used only to decide whether to show a
// "sign in to read the rest" gate on the handful of posts that have one
}Reading the session — even just to decide whether to show a login prompt on premium content, a detail that affects maybe one component near the bottom of the page — is a "dynamic API" read. And in the App Router, touching a dynamic API anywhere in a route's server-rendered path opts the entire route into fully dynamic, per-request rendering. Not just the part that read the session. The whole page. The revalidate = 60 two lines above becomes dead code the moment that getServerSession() call executes.
The fix was to stop reading the session in the page's server component at all, and move that one decision into its own small client component instead:
// PostPage — back to a plain Server Component, nothing dynamic in its path
export const revalidate = 60
export default async function PostPage({ params }: Props) {
const { slug } = await params
const post = await getBySlug(slug)
return (
<article>
<h1>{post.title}</h1>
<PostContent content={post.content} />
<PostPersonalization post={post} /> {/* client component, reads session itself */}
</article>
)
}'use client'
// PostPersonalization.tsx — the ONLY part of the page that needs to know
// whether the visitor is signed in, isolated so nothing else pays for it
import { useSession } from 'next-auth/react'
export default function PostPersonalization({ post }: { post: Post }) {
const { data: session } = useSession()
const isAuthenticated = !!session
if (post.isPremium && !isAuthenticated) {
return <SignInPrompt />
}
return null
}After that change, plus one more fix I'll get to in a second, the same post page's response headers went from no-store to s-maxage=60, stale-while-revalidate, and TTFB on a cold request dropped from about 1.1 seconds to roughly 0.3–0.5 seconds — measured with the exact same curl -w timing check, before and after, on the same server.
Before you assume a fix like this worked, verify it actually did. I diffed the page's structured data (JSON-LD), title tag, and canonical URL byte-for-byte before and after this change went live — because "I moved a session check into a client component" sounds harmless, but I wanted proof it hadn't silently changed anything SEO-relevant, not just an assumption that it hadn't.
The Second, Sneakier Cause
Fixing the session read wasn't actually the whole story. After deploying that fix, the response headers were still showing no-store on dynamic-segment routes like /[locale]/[slug]. I found the real second cause by comparing against a page I hadn't touched at all — a series detail page that already had revalidate = 60 and read no session, no searchParams, nothing. It showed the identical no-store. That ruled out session/searchParams reads as the only explanation.
The actual rule: a dynamic-segment route ([slug], [locale]) with no generateStaticParams() defined is rendered fully per-request by default, completely independent of anything read inside it. revalidate alone doesn't override that. The fix was adding it:
export const dynamicParams = true // new content still renders on-demand and gets cached
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({
locale: post.locale,
slug: post.slug,
}))
}I could confirm this one worked without even checking a header — Next.js's own build output changed the route's symbol from ƒ (Dynamic) to ● (SSG, prerendered) the moment generateStaticParams was added. That's the framework itself telling you which rendering mode a route actually got, which is a much more reliable signal than guessing from behavior.
The lesson underneath both causes is the same one: revalidate is a request, not a guarantee. Two completely different things can silently override it — a dynamic API read anywhere in the route, or a missing generateStaticParams() on a dynamic segment — and Next.js won't warn you about either. It just quietly does what you told it to do, which happened not to be what I meant.
What Else Changed Around the Edges in 16
middleware.tsis nowproxy.ts— same edge-runtime job (auth checks, redirects, geolocation), renamed to describe what it actually is: a lightweight proxy in front of your routes.- The React Compiler is on by default, automatically memoizing Client Components without hand-written
useMemo/useCallbackscattered everywhere. - Server Actions (
'use server') are still the right way to handle a mutation without hand-rolling an API route, and they integrate with the same revalidation model as everything above.
None of that changes the core model. Server Components fetch and render; Client Components handle interactivity; 'use client' is the one line deciding which side of that boundary a piece of UI lives on. Caching, streaming, the Promise-based params — all of it sits downstream of getting that boundary right in the first place. I got it slightly wrong on my own blog for months before an SEO audit caught it. Check yours.
Your Turn
Go run this on your own Next.js site right now: curl -I a dynamic post or product page and check Cache-Control. If you find the same no-store surprise, tell me in the comments what actually caused it for you — I'm curious whether it's always a session/cookie read, or if there's a third cause I haven't run into yet.
Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.
Was this post helpful?
Reviews & Ratings
Sign in to leave a review.
