portfolio Anshul Bisen
ask my work

React 19 Server Components in production: the migration nobody warns you about

React 19 shipped with Server Components stable and the React Compiler, but migrating a real fintech dashboard was nothing like the blog posts suggested.

React 19 shipped on December 5, 2024, and within 48 hours I had started migrating our fintech dashboard. Server Components were stable, the React Compiler was included, and every blog post I read made it sound like a straightforward upgrade. Three weeks later, with 140 files changed and two production rollbacks behind us, I can confirm that migrating a real application with financial data tables, real-time updates, and complex form state is a fundamentally different experience than migrating a blog or an e-commerce storefront.

Where the data model or query started fighting back.

This is where the homelab stopped being a hobby and started acting like a leadership tool. It also builds on what I learned earlier in “What Payload CMS 3.0 taught me about choosing frameworks that grow with you.” The infrastructure and ctrlpane work gave me a cheap place to pressure-test release habits, GitOps discipline, and failure modes before I asked the team to trust those defaults at work.

The infrastructure mess that made the lesson stick.

The Client-Server Boundary Problem

The first thing that breaks when you adopt Server Components is your mental model of where code runs. In our old architecture, every component was a client component. State, effects, event handlers, and data fetching all lived in the same place. Server Components force you to draw a line between what runs on the server and what runs in the browser, and that line cuts through places you did not expect.

Our transaction data table was the worst offender. It fetches data on the server, renders 50 rows of financial data, supports column sorting, row selection, inline editing, and exports to CSV. The data fetching belongs on the server. Everything else belongs on the client. The problem is that the component was written as a single 400-line file where data fetching and interaction logic were interleaved. Splitting it into a server component that fetches data and a client component that handles interaction required rewriting the entire component from scratch.

The React team recommends pushing the client boundary as far down the tree as possible. In theory, your server components fetch data and your leaf components handle interaction. In practice, our financial dashboard has interaction at every level. The header has a date range picker that controls what data the server fetches. The table has sorting that affects the query. The rows have selection checkboxes that affect a summary calculation in the footer. The client-server boundary is not a clean horizontal line. It is a jagged diagonal through the entire component tree.

Hydration Mismatches in Financial Data

Financial data formatting is locale-sensitive. Currency amounts, percentage values, and date formats all depend on the user locale. When a Server Component renders a formatted currency value and the client hydrates it, any difference between the server locale and the client locale causes a hydration mismatch. React 19 handles these more gracefully than React 18, but they still produce console warnings and can cause a full client re-render of the component subtree.

We solved this by moving all locale-sensitive formatting into client components and passing raw numbers from server components. This feels wrong because you are shipping unformatted data to the browser and formatting it in JavaScript, which is slower and causes a flash of unformatted content. But the alternative is hydration mismatches on every financial value in the dashboard, which is worse.

// Server Component: passes raw data
async function TransactionRow({ tx }: { tx: Transaction }) {
return (
<tr>
<td><FormattedCurrency value={tx.amount} currency={tx.currency} /></td>
<td><FormattedDate value={tx.createdAt} /></td>
</tr>
)
}
// Client Component: handles locale formatting
'use client'
function FormattedCurrency({ value, currency }: { value: number; currency: string }) {
return <span>{new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)}</span>
}

The React Compiler Did Not Save Us

The React Compiler ships with React 19 and auto-memoizes components and hooks. The marketing suggests you can delete your useMemo and useCallback calls. In our case, the compiler correctly memoized about 70 percent of our components. The other 30 percent hit edge cases where the compiler could not prove the memoization was safe, mostly around our custom hooks that close over mutable refs and our components that accept render prop callbacks.

  • Components with mutable ref patterns were not auto-memoized
  • Render prop callbacks created new function identities that defeated memoization
  • Third-party library components that use forwardRef in unusual ways confused the compiler
  • The compiler output was harder to debug when performance issues did arise

We ended up keeping about half our manual useMemo calls. The compiler helped, but understanding your own render cycles is still your responsibility. Auto-memoization is a safety net, not a replacement for thinking about component boundaries.

Was It Worth It

After three weeks of migration work, our dashboard loads 40 percent faster on initial page load because the heavy data fetching and most of the rendering happens on the server. The JavaScript bundle shipped to the browser dropped by 35 percent because server-only code no longer gets bundled for the client. These are meaningful improvements for our users, who are financial analysts staring at this dashboard eight hours a day.

But the migration cost was real. Three weeks of engineering time. Two production rollbacks. A complete rewrite of our most complex component. And a new architectural pattern that every engineer on the team needs to understand. For a three-person team, that is a significant investment. I would make the same choice again, but I would budget six weeks instead of the two I originally estimated.