The first time I moved a real app to the App Router, I did the thing everybody does: I hit a useState error, slapped 'use client' at the top of the file, and moved on. Three weeks later my "server-first" app was shipping 340 KB of JavaScript and I had learned nothing.
The fix wasn't a trick. It was replacing my mental model.
The model that actually works
Stop thinking of Server Components as "components that render on the server." Think of them as a template that runs once, produces a description of UI, and never runs again.
That's it. A Server Component:
- runs during the request (or at build time), on the server
- has no lifecycle, no state, no effects — it runs, it returns, it's gone
- can
awaitdirectly, because there's nothing to suspend on the client - ships zero JavaScript to the browser
A Client Component is the opposite: it's code that gets serialized, sent down the wire, hydrated, and then lives in the browser with state and event handlers.
So the real question is never "should this render on the server?" It's "does this need to be alive in the browser?"
If the answer is no, it's a Server Component. Everything else follows.
The boundary is a wall in one direction only
This is the part that trips people up. 'use client' doesn't mark a component as client-side — it marks a module boundary. Everything imported from that point down goes to the browser too.
'use client';
import { motion } from 'framer-motion';
import { formatCurrency } from '@/lib/format';
import HeavyChart from '@/components/HeavyChart'; // now client-side too
import ReportTable from '@/components/ReportTable'; // and thisOne 'use client' at the top of a layout can drag your entire component tree into the bundle. I've seen a 12 KB page become 200 KB because of a single directive on a wrapper.
But the wall only blocks imports. It does not block children. This composes fine:
import Collapsible from '@/components/Collapsible'; // 'use client'
import RevenueTable from '@/components/RevenueTable'; // server component
export default async function Page() {
const rows = await db.revenue.findMany();
return (
<Collapsible title="Revenue">
{/* Rendered on the server, passed through as already-rendered UI */}
<RevenueTable rows={rows} />
</Collapsible>
);
}Collapsible holds the open/closed state in the browser. RevenueTable never reaches the browser at all — it arrives as serialized output. The client component just renders {children} into a slot.
Rule of thumb: push 'use client' to the leaves, and pass server-rendered content in as children.
What can cross the boundary
Props going from a Server Component into a Client Component must be serializable. Roughly:
| Crosses fine | Does not cross |
|---|---|
strings, numbers, booleans, null |
functions (except Server Actions) |
| plain objects and arrays | class instances |
Date, Map, Set |
Mongoose documents |
| JSX elements | anything with a closure |
That Mongoose row matters. This is the single most common error in a Next.js + MongoDB codebase:
// ✗ Mongoose documents carry prototypes and internal state
const posts = await Blog.find({ published: true });
return <PostList posts={posts} />;
// ✓ .lean() gives plain objects — then normalise the ObjectId and Dates
const docs = await Blog.find({ published: true }).lean();
const posts = docs.map((doc) => ({
id: String(doc._id),
title: doc.title,
publishedAt: doc.publishedAt?.toISOString(),
}));
return <PostList posts={posts} />;I now treat this mapping step as a hard rule: the database shape never leaves the data layer. It's a serialization boundary and an API contract, and having one function that owns it has saved me repeatedly during refactors.
Data fetching gets boring, which is the point
No useEffect. No loading flags. No isLoading && <Spinner />. Just:
export default async function Page({ params }) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
return <Article post={post} />;
}Two things worth knowing:
Parallelise siblings. Sequential awaits waterfall. If two queries don't depend on each other, start both:
// ✗ 180ms + 140ms = 320ms
const post = await getPost(slug);
const related = await getRelated(slug);
// ✓ max(180ms, 140ms) = 180ms
const [post, related] = await Promise.all([getPost(slug), getRelated(slug)]);Deduplicate with cache(). If a layout and a page both need the same record, wrap the accessor and React will run it once per request:
import { cache } from 'react';
export const getPostBySlug = cache(async (slug: string) => {
await dbConnect();
return Blog.findOne({ slug, published: true }).lean();
});Now generateMetadata and the page component can both call it, and you pay for one query.
Streaming: the thing you get for free
Because the server renders progressively, a slow section doesn't have to block the fast ones. Wrap it in Suspense and the rest of the page flushes immediately:
export default function Page() {
return (
<>
<Hero /> {/* instant */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* streams in when the query resolves */}
</Suspense>
</>
);
}The LCP element paints while the slow query is still running. That's the single highest-leverage performance change available in the App Router, and it's four lines.
The decision table I actually use
| Needs… | Component type |
|---|---|
useState, useReducer, useRef |
Client |
useEffect, subscriptions, timers |
Client |
onClick, onChange, any DOM event |
Client |
browser APIs (window, localStorage) |
Client |
| Framer Motion, most chart libraries | Client |
| database or filesystem access | Server |
| secrets, API keys, tokens | Server |
| large dependencies used once (markdown, syntax highlighting) | Server |
| everything else | Server (default) |
That last row is the one that matters. Server is the default. You opt out deliberately, and you note why.
Where it paid off
On this site, article pages render markdown and highlight code on the server via a unified → rehype-pretty-code → Shiki pipeline. Shiki alone is well over 1 MB of grammars and themes. None of it reaches the browser — readers get finished HTML, and crawlers see fully-formed markup with no hydration step.
The interactive parts — the ⌘K palette, the scroll-spy table of contents, the reading dial — are small leaf Client Components. They're the only JavaScript on the page that does anything.
That split is the whole point. Ship behaviour where behaviour is needed. Ship HTML everywhere else.