Skip to content

Architecture

React Server Components: A Practical Mental Model

Server Components are not 'SSR with extra steps'. Here is the mental model that finally made the client/server boundary obvious — and the rules I follow when deciding where a component belongs.

4 min read0 views
  • #react
  • #nextjs
  • #server components
  • #architecture
  • #performance

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 await directly, 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.

components/Dashboard.tsx
'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 this

One '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:

app/dashboard/page.tsx
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:

app/blog/[slug]/page.tsx
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:

lib/posts.ts
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 unifiedrehype-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.