React Server Components Migration: Moving Existing Components From Client to Server Without Breaking Your App
Most teams that adopt the Next.js App Router do it in two phases, whether they plan to or not. Phase one is the routing migration: files move into app/, layouts replace _app.tsx, and everything gets 'use client' slapped on top so the build passes. Phase two is the actual React Server Components migration: going back through those components and moving the ones that belong on the server to the server. Plenty of teams finish phase one, ship it, and never start phase two. They run the App Router with a fully client-rendered tree and wonder why the bundle did not shrink.
This post is about phase two. Not the framework migration itself (we covered that in our guide to incremental App Router adoption), but the component-by-component work of converting existing client components to server components in an app that is already live, without breaking it.
What Qualifies for a React Server Components Migration
A component can become a server component when it needs none of the following:
- Event handlers (
onClick,onChange,onSubmit) - State or lifecycle hooks (
useState,useReducer,useEffect,useLayoutEffect) - Browser-only APIs (
window,localStorage,IntersectionObserver,matchMedia) - Custom hooks that depend on any of the above
- Class component features (
React.Componenthas no server equivalent)
That list sounds simple, but in a real codebase the disqualifiers hide two or three levels down. A ProductCard looks static until you notice it imports useAnalytics, which calls useEffect internally. The import chain matters as much as the component body: a server component cannot import anything that transitively touches client-only code, unless that import is itself behind a 'use client' boundary.
So the first practical rule of a react server components migration: do not audit components by reading their JSX. Audit their imports.
The quick triage
For each candidate component, answer three questions:
- Does it or any of its non-boundary imports use hooks or browser APIs?
- Does it receive functions as props (callbacks cannot cross the server-client boundary)?
- Does it read from a React context that is provided by a client component?
Three noes means it can move today. Any yes means it either stays client-side or gets split, which is what most of the rest of this post covers.
Map the Boundary Before You Move Anything
The mistake we see most often in code audits is teams converting components opportunistically, one file at a time, wherever 'use client' looks removable. That produces a boundary that zigzags through the tree, with client islands wrapping server holes wrapping client leaves. Every crossing is a serialization point and a place where props must be JSON-safe. The more crossings, the more fragile the tree.
Before touching code, map where the boundary should be. The React DevTools profiler plus a bundle analyzer run gives you the raw material: which components render frequently, which ones carry heavy dependencies into the bundle, and which subtrees never handle interaction. What you want to find are large, mostly static regions (headers, product grids, article bodies, footers, settings summaries) separated by thin interactive seams (a search input, a filter bar, an add-to-cart button).
The target shape is almost always the same: server components own the trunk of the tree, and client components hang off it as small leaves. Interactive wrappers stay client-side; static content inside them gets extracted and passed through as children. Because children rendered by a server component can be passed into a client component without becoming client code themselves, this pattern lets a client-side accordion, tab panel, or modal contain server-rendered content. That single trick (server content threaded through client wrappers as props) does more to shrink the bundle than any other step in the migration.
Handling Data Fetching: Kill the Waterfall Before It Kills You
Moving a component to the server usually means moving its data fetching too, from useEffect or React Query into an async server component. This is where migrations quietly regress performance. In the client version, three components fired their fetches in parallel after mount. In the naive server version, three nested async components await sequentially, and your time to first byte now includes a waterfall that never existed before.
Two rules prevent this:
Hoist fetches, not just components. Start requests at the highest sensible level and pass promises or resolved data down. Promise.all in a page-level server component keeps sibling requests parallel:
export default async function ProductPage({ params }) {
const [product, reviews, related] = await Promise.all([
getProduct(params.id),
getReviews(params.id),
getRelated(params.id),
]);
return <ProductView product={product} reviews={reviews} related={related} />;
}
Use Suspense to decouple slow data from fast data. If reviews are slow and the product core is fast, wrap the reviews subtree in <Suspense> and let it stream in. The page shell renders immediately and the slow section fills in without blocking anything else.
Also deduplicate at the data layer. If two server components independently need the current user, wrap the lookup in React's cache() so both calls resolve from one request. This replaces what React Query's cache was doing for you client-side; forget it and you will double or triple your backend traffic. For a deeper treatment of when server fetching, React Query, or both make sense, see our comparison of React data fetching approaches.
Migrating Context Providers
Context is the part of a react server components migration that teams underestimate. The classic SPA setup has a stack of providers at the root: theme, auth, feature flags, analytics, i18n. Providers use context, context needs client code, and a client component at the root drags the entire tree back to the client.
The fix is a dedicated providers file that keeps the boundary thin:
// app/providers.tsx
'use client';
export function Providers({ children }) {
return (
<ThemeProvider>
<AnalyticsProvider>{children}</AnalyticsProvider>
</ThemeProvider>
);
}
The root layout stays a server component and renders <Providers>{children}</Providers>. Because the page content arrives via children, it is not converted to client code by passing through the provider stack.
Then reduce what context is for. Server components cannot consume React context at all, so any data your server components need must arrive another way: read the session directly in the server component via a cached helper, pass feature flags as props from the layout, resolve locale from the request. In most migrated apps, context ends up serving only truly client-side concerns like theme toggling and analytics, and the provider stack shrinks to two or three entries.
A Worked Example: The Product Listing Page
Here is the before and after from a migration we ran on a B2B commerce app, simplified but structurally faithful.
Before. ProductListPage was one client component: useEffect fetched /api/products on mount, useState held products, loading and filter state, and the file imported a date library, a currency formatter and a markdown renderer for product blurbs. Client bundle contribution: 87 kB gzipped. Users saw a spinner on every visit while the fetch round-tripped.
After. The page split into three parts:
ProductListPage(server): async, fetches products directly from the backend service, no HTTP hop through an API route. Renders the grid.ProductCard(server): pure presentation. The date library, currency formatter and markdown renderer now execute only on the server and left the bundle entirely.FilterBar(client): the one interactive piece. It writes filter selections to URL search params viarouter.replace, and the server page re-renders with filtered results.
The interesting decision was filter state. The client version filtered in memory. Moving filters to URL params meant every change hits the server, so we kept one deliberate client-side concession: text search input is debounced before it touches the URL. Category and price filters go straight to params. Filtered views became linkable and shareable, which the old in-memory version never supported.
Measured result: the route's client JavaScript dropped from 87 kB to 9 kB gzipped, and first contentful paint improved by roughly 40 percent on mid-range mobile because there was no fetch-after-mount spinner cycle. The numbers will differ for your app, but the shape of the win (dependencies leaving the bundle, data arriving before first paint) is typical.
Verifying That Nothing Broke
A migration like this succeeds when users notice nothing except speed. The verification strategy has three layers:
Output equivalence. For each converted component, snapshot the rendered HTML before and after against the same fixture data. The DOM should be identical or trivially different (whitespace, attribute order). We script this rather than eyeballing it: render both versions in a test harness, diff the serialized output, and review any non-trivial delta by hand. Hydration mismatch warnings in the console during this pass are failures, not noise.
Behavioral tests at the seams. Every server-client boundary you created is a place where props serialize. Integration tests (Playwright or Cypress) should cover each interactive seam: the filter bar updates results, the accordion opens with server-rendered content inside, the form still submits. Unit tests on the old client components mostly carry over to the extracted client leaves; the server parts are better covered by rendering tests than by mocking hooks that no longer exist.
Production canary. Ship converted routes behind a gradual rollout and watch three metrics: hydration error rate, p95 TTFB (the waterfall detector) and backend request volume (the missing-cache() detector). Each of those catches a class of mistake that local testing reliably misses.
Migrate in the Order That Pays
Do not convert alphabetically. Rank routes by client bundle size multiplied by traffic, and start where the product of the two is highest. A heavy, high-traffic listing page is worth migrating this sprint; a settings page that renders twice a day per user can keep its 'use client' forever without anyone noticing. Server components are a tool with a payoff curve, not a purity standard. Our earlier post on production RSC patterns and pitfalls goes deeper on the runtime behavior once you are on the other side.
If you are staring at an App Router codebase where every file still starts with 'use client', or a migration attempt stalled halfway with a boundary that zigzags through the tree, this is work we do regularly as part of legacy code optimization engagements. Write to hello@wolf-tech.io or take a look at wolf-tech.io, and we can map your component tree and give you a migration order that pays for itself in the first sprint.

