React Server Components in production — what actually changed
Six months of shipping with the App Router and RSC. The mental model shifts, the gotchas, and the patterns that emerged after the hype settled.
I've been building with React Server Components in production for about six months across three projects. The mental model is genuinely different from what came before, and most of the existing tutorials still treat RSC as if it were just "server-rendered React." It isn't.
The mental model that finally clicked
Server Components are not server-rendered Client Components. They're a fundamentally different thing. Server Components run once on the server, output a serialized tree, and never re-render. Client Components are what you used to call "React components" — they run in the browser, can hold state, and re-render.
The interesting move is composition: you can pass Server Components as children of Client Components. This lets you keep most of your tree on the server while putting interactivity exactly where it's needed.
// Client Component (interactive shell)
"use client";
export function Tabs({ children }: { children: React.ReactNode }) {
const [active, setActive] = useState("overview");
// ...returns interactive tab UI wrapping children
}
// Server Component (data-fetching content)
export default async function ProjectPage({ slug }: { slug: string }) {
const project = await db.projects.findBySlug(slug); // runs on server
return (
<Tabs>
<TabPanel name="overview">{project.summary}</TabPanel>
<TabPanel name="metrics">
{/* Server-side fetch, rendered into a client tab */}
<MetricsBoard projectId={project.id} />
</TabPanel>
</Tabs>
);
}The gotcha that ate a week
Anything imported by a Client Component becomes a Client Component. This is enforced quietly, and it cascades. We accidentally pulled half our component library into the client bundle because a single utility file at the root was marked `"use client"`.
The fix is discipline at the boundary: the `"use client"` directive should be at the leaves of your tree, not the root. Treat it like a careful import.
Patterns that actually emerged
1. Data fetching at the page level
Server Components made it painless to fetch data exactly where you need it. We dropped TanStack Query from one project entirely — it was solving problems that no longer existed.
2. Server Actions for mutations
Form submissions and mutations got dramatically simpler. No more API route boilerplate, no more client-side fetch logic. Just a function with `"use server"` at the top.
// Server Action — runs on the server, called from a form
async function submitContact(formData: FormData) {
"use server";
const name = formData.get("name") as string;
const email = formData.get("email") as string;
await db.contacts.create({ name, email });
redirect("/thank-you");
}
// Used directly in a form
<form action={submitContact}>
<input name="name" />
<input name="email" />
<button>Submit</button>
</form>3. Streaming with Suspense
Streaming is the biggest UX win in RSC. Wrap any slow data fetch in Suspense and the rest of the page renders immediately. Time-to-first-paint dropped 60% on our slowest dashboard route.
What I'd tell my past self
- Read the React Server Components RFC, not just framework docs — it explains the why
- Keep `"use client"` at the leaves; never default it
- Stream slow data with Suspense — this is mostly free performance
- Skip global state libraries for new projects; you may not need them
RSC is a real shift, not marketing. Six months in, I write fewer effects, fewer fetches in components, and the apps feel faster. The learning curve is real, but it's worth the climb.
Scaling Postgres with pgvector — what we learned at 2M embeddings
→Lessons from running a production HNSW vector index on Postgres for code search at scale. Recall, latency, and the operational tradeoffs nobody warns you about.