React Server Components in Production: Lessons Learned#
I have now shipped several production apps on the Next.js App Router, including this portfolio. React Server Components changed how I structure frontends more than any React feature since hooks. They also introduced a new class of mistakes.
These are the lessons that took real projects to learn.
The mental model that finally clicked#
Server Components run once on the server and send rendered output to the browser. They never ship their JavaScript to the client. Client Components (marked with "use client") are the classic React components that hydrate and handle interactivity.
The key insight: the boundary is about where code runs, not about what the component looks like. A data table can be a Server Component. A single "copy to clipboard" button inside it is a Client Component.
Default to server, opt into client#
Start every component as a Server Component and only add "use client" when it needs state, effects, event handlers or browser APIs. Then push that boundary as far down the tree as possible:
1// page.tsx — Server Component2export default async function ProjectPage({ params }: { params: Promise<{ slug: string }> }) {3 const { slug } = await params;4 const project = await getProject(slug);5 6 return (7 <article>8 <h1>{project.title}</h1>9 <Markdown content={project.body} />10 <ShareButton url={`/projects/${slug}`} /> {/* small client island */}11 </article>12 );13}
Marking the whole page as a client component to get one interactive button ships the markdown parser and everything else to every visitor. Keep client islands small.
Fetch data where it is used#
Because Server Components can be async, each component can fetch its own data. This feels like it would cause duplicate requests, but React's cache() deduplicates calls within a single render:
1import { cache } from "react";2 3export const getProfile = cache(async () => {4 const res = await fetch(`${API}/profile`, { next: { revalidate: 300 } });5 if (!res.ok) throw new Error("Failed to load profile");6 return res.json() as Promise<Profile>;7});
The header and the hero can both call getProfile() and only one request goes out. Colocated data fetching makes components far easier to move and reuse.
Avoid accidental waterfalls#
Async components introduce a new performance trap. Awaiting one request, then starting the next, serializes them:
1// Slow: the second request waits for the first2const user = await getUser(id);3const posts = await getPosts(id);4 5// Fast: both start immediately6const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);
For independent sections of a page, go further and wrap each in its own <Suspense> boundary, so the fast parts stream to the browser while the slow parts are still loading.
Serialization is the real boundary#
Props passed from a Server Component to a Client Component must be serializable. You cannot pass functions, class instances or database models with methods. This bites during refactors:
- Pass plain data, not ORM entities.
- Pass IDs and let client code call a Server Action, instead of passing callbacks.
- Be careful with
Date objects and anything with prototypes.
It is also a security boundary. Everything you pass to a Client Component ends up in the HTML payload. I have reviewed code that passed a full user record — including internal fields — to a client avatar component. Select only the fields the client needs.
Keep secrets on the server, deliberately#
Server Components make it easy to call your database or a secret-bearing API directly. That is a feature, but import discipline matters. Mark server-only modules so they can never be bundled into the client by mistake:
1import "server-only";2 3export async function getStripeBalance() {4 return stripe.balance.retrieve(); // uses STRIPE_SECRET_KEY5}
If a client component ever imports this file, the build fails instead of leaking the key.
Caching needs an explicit strategy#
The most confusing part of the App Router has been caching. Decide per route whether it is static, revalidated on a timer, or fully dynamic, and write that decision down. For a content site backed by an admin panel, I use time-based revalidation for public pages and on-demand revalidation when content changes.
Conclusion#
Server Components reward a simple discipline: default to the server, keep client islands small, fetch in parallel, pass only plain and necessary data across the boundary, and be explicit about caching. Follow it and you get faster pages with less JavaScript. Ignore it and you get a slower version of the SPA you started with.