Migrating from SPA to SSR, Part 2: The Reality
What actually happened when I migrated my single-page application to Next.js
This is Part 2 of a series about migrating from a single-page application to server-side rendering. If you haven't read it yet, start with Part 1: The Decision.
So I did it. I migrated my React SPA to Next.js with server-side rendering. Here's what actually happened—the good, the bad, and the things nobody tells you.
I thought the migration would take a weekend. It took three weeks. Not because Next.js is complicated—it's actually pretty straightforward—but because I kept discovering things I'd built that didn't translate directly. Client-side routing logic, state management patterns, even some of my component structures needed rethinking. I'd start migrating a page, think I understood what I was doing, then hit something that made me pause. How do I handle this client-side only library? How do I migrate this complex state management? How do I handle this routing pattern that doesn't exist in Next.js? The first weekend turned into a week. Then two weeks. Then three. I was learning as I went, and every page taught me something new. Some pages migrated easily. Others required complete rewrites. I'd finish migrating one page, feel good about it, then start on the next and realize I'd made assumptions that didn't hold. It was frustrating, but it was also educational. I was learning how Next.js actually worked, not just how the tutorials said it worked.
The Planning Phase
Before I started, I made a list of what I thought would be hard: routing, data fetching, and handling client-side interactions. I was right about those, but I was wrong about everything else. I thought styling would be the same. I thought components would translate directly. I thought the build process would be similar. I was wrong on all counts.
The routing was actually easier than I expected. Moving from React Router's programmatic navigation to Next.js file-based routing felt more natural. Instead of defining routes in a config file, I just created files. Want a nested route? Create a folder. Want dynamic routes? Use brackets in the filename. It felt intuitive in a way that React Router never did. But the data fetching? That was a whole new world. I'd been using useEffect hooks to fetch data on the client side. Click a button, fetch some data, update the state. Simple. But with server-side rendering, I needed to think about when data should be fetched. Should it be fetched at build time? Should it be fetched on every request? Should it be fetched on the client after the page loads? Each option had trade-offs, and I needed to understand them.
I spent the first few days just reading documentation and experimenting. I'd build a page, see how it worked, then tear it down and rebuild it differently. I'd try getStaticProps, see what happened. Then try getServerSideProps, compare the results. I'd build the same page three different ways just to understand the differences. It was frustrating, but I was learning. By the end of the first week, I had a much better understanding of when to use each approach. But I also had a much longer migration ahead of me than I'd anticipated.
I also realized that I needed to think about the build process differently. With my SPA, I'd build everything once and deploy it. With Next.js, I had to think about what could be built at build time versus what needed to be rendered on each request. Blog posts? Build them at build time. Dashboard data? Render it on each request. It was a different mental model, and it took time to adjust.
The Migration Process
I decided to migrate page by page, starting with the simplest ones. The blog post pages came first—they were mostly static content anyway. I figured they'd be easy. And they were, mostly. The content was already in markdown files, so I just needed to convert them to Next.js pages. But even that had complications. How do I handle the frontmatter? How do I generate all the pages at build time? How do I handle the routing? Each question led to more questions, and each answer required more code.
Then I moved to the homepage, which had some dynamic content but nothing too complex. Or so I thought. It had a list of recent posts, some featured content, a few interactive elements. I figured I could just fetch the data server-side and render it. But the interactive elements needed client-side JavaScript. So I needed to hydrate the page with client-side code, but only for the parts that needed it. It took me a while to figure out how to do that without loading unnecessary JavaScript.
The real challenge came with the dashboard. It had real-time updates, complex state management, and lots of client-side interactions. I couldn't just server-render it and call it done. The data changed constantly. Users could filter, sort, search. Everything was interactive. I needed a hybrid approach—server-render the initial state, then hydrate with client-side updates. But how do I do that? How do I keep the server-rendered state in sync with the client-side state? How do I handle the real-time updates without causing hydration mismatches?
This is where I learned about Next.js's flexibility. Some pages could be fully static. Others needed server-side rendering. And a few needed to stay mostly client-side. The framework let me choose what made sense for each page. I didn't have to force everything into the same pattern. I could use getStaticProps for blog posts, getServerSideProps for the dashboard, and client-side fetching for the interactive parts. It was more complex than my SPA, but it was also more powerful.
I also learned that migration isn't all-or-nothing. I could migrate pages gradually. I didn't have to do everything at once. I could keep some pages as client-side only while I migrated others. This made the process less overwhelming. I could migrate a page, test it, make sure it worked, then move on to the next. It took longer, but it was less risky. And it gave me time to learn as I went.
The hardest part was dealing with shared state. In my SPA, I had global state that was used across multiple pages. With Next.js, I had to think about where that state lived. Some of it could stay client-side. Some of it needed to be passed as props. Some of it needed to be fetched separately on each page. It required rethinking how I structured my application, but it also made things clearer. I could see exactly where data was coming from and how it flowed through the application.
I also had to rethink my component structure. Some components that worked fine in a client-side context needed adjustments for server-side rendering. Components that accessed window or document directly would break. Components that relied on browser APIs needed to be wrapped or conditionally rendered. It wasn't a huge problem, but it required attention. I'd migrate a component, test it, find it broke on the server, then fix it. Over and over.
The testing process was different too. With my SPA, I could test everything in the browser. With Next.js, I needed to test both server-side rendering and client-side hydration. I'd render a page server-side, check the HTML, then load it in the browser and check that it hydrated correctly. It was more work, but it also caught more bugs. I found issues I never would have found with just client-side testing.
Unexpected Challenges
The biggest surprise? Third-party libraries. Some of the packages I'd been using weren't designed for server-side rendering. They'd break during the build process or cause hydration mismatches. I had to find alternatives or wrap them in dynamic imports with ssr: false. Another thing I didn't expect: environment variables work differently. In my SPA, I'd been using process.env directly. In Next.js, I needed to prefix them with NEXT_PUBLIC_ for client-side access. Small thing, but it caused a few hours of debugging. And then there were the small details. Image optimization was different. Styling worked the same, but the way Next.js handles CSS took some getting used to. Even deployment was different—I had to rethink my CI/CD pipeline.
What Went Better Than Expected
But here's the thing—once I got past the learning curve, everything started clicking. The performance improvements were immediate. My time to first contentful paint dropped from 4.2 seconds to under 1 second. Mobile users stopped complaining. SEO got better almost automatically. Google started indexing my pages properly, and my search rankings improved within a month. Social media previews worked perfectly—no more blank cards. The developer experience was actually better than I expected. Hot reloading worked great. The file-based routing made it easier to find things. And having API routes in the same project simplified my workflow.
Where Things Stand Now
It's been six months since I finished the migration. The site is faster, more SEO-friendly, and easier to maintain. But I'm still learning. There are features I haven't explored yet, optimizations I could make, and patterns I'm still figuring out. Every week, I discover something new. A better way to structure data fetching. A more efficient way to handle state. A pattern that makes things simpler.
Would I do it again? Absolutely. The initial pain was worth it. But I'd do some things differently—I'd plan more upfront, migrate in smaller chunks, and test more thoroughly along the way. I'd spend more time understanding the framework before I started migrating. I'd set up better testing to catch issues earlier. I'd document my decisions better so I could remember why I did things a certain way.
The migration taught me that sometimes the right solution isn't the easiest one. It's the one that actually solves the problem. And for me, that was moving to server-side rendering. The site works better now. Users have a better experience. I have a better development experience. It was hard, but it was worth it.
Note: This is a mock-up post created as part of the Feather blog template demonstration. The content is provided as an example to showcase the blog's features including markdown rendering, search functionality, tags, and more.
Feather is a blog template built for Next.js. You can use these example posts as a reference when creating your own content.