Next.js 15 and Server Components: The Migration Nobody Warns You About
#nextjs #react #webdev

Next.js 15 and Server Components: The Migration Nobody Warns You About

The Page That Broke Us

In April, our main dashboard page started timing out. Not always — maybe 5% of requests, which is worse than 100% because it's unpredictable. Users would see a blank screen for three seconds, then the page would load. Or they'd see a spinner that spun forever. Or they'd see the page, click something, and watch it disappear.

The cause was a classic waterfalls problem. The dashboard was a client-side React app. It loaded, then made an API call to get user data, then made three more API calls based on that data, then made two more based on those. Six sequential network requests before the page was interactive. On a fast connection, it took 1.8 seconds. On a mediocre connection — a phone on cellular, a laptop on hotel wifi — it took four to six seconds. Our timeout threshold was three seconds.

I'd been reading about React Server Components and the Next.js App Router for months. The pitch was exactly what we needed: compute on the server, ship less JavaScript to the client, eliminate the waterfalls by fetching data where it's rendered. I'd even prototyped a small internal tool using the App Router and it worked beautifully.

But the dashboard was 4,000 lines of client-side React code. Migrating it wasn't a prototype. It was a refactor of the most critical page in our application. I started the migration in May. I finished in August. Here's what happened.

What Server Components Actually Are (Without the Marketing)

I'm going to explain this the way I wish someone had explained it to me before I started.

In the old Next.js model — the Pages Router — every component you write runs in the browser. The server sends HTML, the browser hydrates it with JavaScript, and then everything runs client-side. When a component needs data, it fetches it in a useEffect or in getServerSideProps. Either way, the data eventually ends up in the browser, and the browser does the rendering.

React Server Components flip this. A server component runs only on the server. It can directly access databases, read files, call internal APIs — anything you'd do on a backend. It produces rendered HTML that's sent to the browser. The browser never sees the JavaScript that produced it. No bundle size cost, no hydration cost, no client-side data fetching.

A client component — marked with "use client" at the top of the file — works the way React always has. It runs on the server for the initial HTML, then hydrates in the browser where it owns interactivity: click handlers, form inputs, state updates, effects.

The mental model is simple: everything is a server component by default. You opt into client components only where you need interactivity. A blog post is a server component — it renders text, needs no interactivity. A like button is a client component — it needs to respond to clicks. A page that's mostly text with one interactive element should be a server component that imports a single client component for that element.

Here's what this means in practice: a page that used to ship 200 KB of JavaScript to render a blog post now ships 0 KB. The server does the work. The browser receives finished HTML. The JavaScript bundle shrinks because only the interactive bits need client-side code.

The First Win (and Why It's Misleading)

I started with a small, low-risk page: our terms of service. Static content, no interactivity, no data fetching. I converted it from a client component to a server component by removing "use client" and deleting the useEffect that fetched the content from an API.

The result: the page went from 12 KB of JavaScript to 0 KB. It loaded instantly. Lighthouse score jumped from 88 to 99. It was beautiful.

Then I tried the dashboard.

The problem is that the terms of service page was never the bottleneck. Nobody complains about a static page loading slowly. The dashboard — with its six sequential API calls, complex state management, and interactive charts — was the actual problem. And converting it to server components meant confronting every shortcut I'd taken over two years.

The Pain Nobody Talks About

Here's what actually happened during the dashboard migration.

The data fetching rewrite took three weeks. Our dashboard fetched data using useEffect hooks with a custom useFetch wrapper. Moving that data fetching to server components sounds simple — just await the data in the component. But our data fetching was conditional and dependent on state. The component fetched different data based on which tab the user had selected, which filters were active, and which date range was chosen. Server components can't react to client-side state. You can't await something in a server component based on a click that hasn't happened yet.

The solution was splitting the component into a server component that fetches the default data and a client component that handles the interactive parts. That sounds clean in theory. In practice, it meant tearing apart a 4,000-line component into fifteen smaller components, defining clear boundaries between server and client, and rewriting the data flow from scratch.

Context providers had to move. We used React Context for global state — the current user, theme preferences, notification state. Context providers must be client components because they use state. But their children can be server components if you pass the data as props instead of consuming context.

This sounds like a small change. It's not. Every component that consumed context had to be refactored to accept data through props instead. That's hundreds of touch points across the codebase. Some components were three levels deep in the tree and consumed context indirectly through intermediate components. Tracing the data flow and restructuring it took longer than any other part of the migration.

Third-party libraries broke. We used a charting library that assumed it was running in the browser. It accessed window on import, which crashes server components. We used a date library that had server-compatible exports, but the imports were different. We used a form library that relied on client-side state management and couldn't work as a server component.

Each of these required a wrapper: a client component that imported the library and passed the result to a server component. It worked, but it added complexity. The clean mental model of "server components by default, client components where needed" collided with the messy reality of an existing codebase full of client-side assumptions.

The build got slower. Server components require a different compilation step. The Next.js build time for our project went from 45 seconds to 2 minutes 10 seconds. Not catastrophic, but noticeable in daily development. The hot reload also got slower — changes to server components took 2-3 seconds to reflect in the browser instead of the near-instant updates we were used to.

What I Wish I'd Known

If I were starting the migration over, here's what I'd do differently.

Migrate route by route, not all at once. I tried to convert the entire app in one pass. That meant both the old Pages Router and the new App Router were running simultaneously for weeks, with shared code that had to work in both environments. It was a nightmare. Start with one route, get it fully working, then move to the next. The App Router supports this — you can have both pages/ and app/ directories at the same time.

Identify the boundary first. Before writing any code, I should have drawn a diagram: which components need client-side state, which can be pure server components, and where the boundary falls. I figured this out as I went, which meant I rewrote some components twice — once as server components that I later realized needed to be client components, and once as the final hybrid.

Don't fight the model. The biggest mistakes I made were trying to force client-side patterns into the server component world. Server components are async — just await the data. Server components can't use state — that's the point, not a limitation. The faster I stopped thinking "how do I make this work like before" and started thinking "what does the component actually need," the faster the migration went.

Upgrade your dependencies first. Half the third-party library issues would have been avoided if I'd updated to the latest versions before starting. Most popular libraries have added server component support in the last year. Check compatibility before you begin, not when something breaks at 2 AM.

The Results After Three Months

Here's what changed.

Bundle size: 68% reduction. The dashboard went from 340 KB of JavaScript to 108 KB. Most of the reduction came from data-fetching libraries and utilities that no longer needed to run in the browser. The charting library is still client-side (it has to be), but everything around it is server-rendered.

Time to interactive: 73% improvement. The dashboard loads in 0.5 seconds on a good connection, down from 1.8 seconds. On a mediocre connection, it's 1.2 seconds instead of 4-6 seconds. The waterfalls are gone because data is fetched on the server, close to the database, in a single round trip.

API calls: eliminated. The dashboard used to make six API calls from the client. Now it makes zero. The server component queries the database directly. That means no API routes to maintain, no serialization overhead, no CORS headers, no authentication middleware on the API layer. The data fetching code shrank by about 70%.

SEO: improved. The dashboard isn't public-facing, but other pages that migrated — our pricing page, our features page — got noticeably better search ranking after the switch. Googlebot receives fully rendered HTML instead of a JavaScript shell that needs to be executed. The Core Web Vitals scores improved across the board.

Developer experience: mixed. The initial learning curve was steep. The server-client boundary is genuinely confusing at first, and the error messages — while better than they used to be — still sometimes point you in the wrong direction. But after the migration, daily development is faster. Data fetching is simpler (no more useEffect boilerplate), the code is more readable (server components are just async functions), and the build output is smaller.

Is It Worth It for Your Project?

Honest answer: it depends.

Yes, if your app has pages that are mostly content with some interactivity. Blogs, marketing sites, documentation, dashboards with read-heavy views. These are the sweet spot. The server does the heavy lifting, the browser gets finished HTML, and the JavaScript bundle shrinks dramatically.

Maybe, if your app is heavily interactive. A real-time collaborative editor, a complex form builder, a drag-and-drop interface — these need client-side state for almost everything. Server components help at the page level (layout, navigation, initial data), but the core interaction layer is still client-side. The migration benefit is smaller.

No, if your app is already fast and the current architecture works. If your Pages Router app loads in under a second and your Lighthouse scores are in the 90s, the migration might not be worth the effort. The App Router is the future of Next.js, so you'll probably migrate eventually. But "eventually" is different from "urgently."

And here's the uncomfortable truth: the App Router is better. Server components produce smaller bundles, faster pages, and simpler data fetching. The mental model is cleaner once it clicks. The ecosystem is moving this direction. But the migration from an existing Pages Router app is genuinely hard — harder than the marketing suggests, harder than the tutorials show. Budget three to four times what you think you need. Start with the easiest routes first. And accept that some third-party libraries will need workarounds.

I don't regret the migration. The dashboard is faster, the code is cleaner, and I understand the model well enough to build new features more quickly than I could before. But I went in expecting a weekend project and got a three-month refactor. That gap between expectation and reality is the thing I'd most want to warn you about.