Back to News & Insights
Web Development September 13, 2026 · 6 min read

The Good, The Bad, and The Hydration Errors: Migrating a Production React SPA to Next.js App Router

Two and a half years ago I built a corporate website for a Dubai-based food manufacturer as a plain...

The Good, The Bad, and The Hydration Errors: Migrating a Production React SPA to Next.js App Router

Two and a half years ago I built a corporate website for a Dubai-based food manufacturer as a plain React SPA — about 20 pages covering their brands, factories, product categories and export markets. Create React App, react-router, useEffect for data, react-helmet for meta tags. It looked good and the client was happy.

Then the SEO reports came in. The company sells in 50+ countries, and search is where their distributors and private-label buyers find them. A client-rendered SPA where Google sees an empty and a loading spinner is a bad place to be.

So I rewrote it in Next.js App Router. Not "migrated" — rewrote. Every single file. With two hard constraints from the client: Every URL stays exactly the same. /about, /our-brands, /private-labels, /travel-retail — all of it. Years of indexed pages and backlinks were not going to be thrown away. The site has to look identical. No redesign. Users should not notice anything changed except that it's faster.

Everyone recommends incremental migration. I tried for about two days and gave up.

The SPA's architecture was fundamentally client-first: routing in react-router, data fetching in components, layout state in context providers wrapping the whole tree. App Router wants the opposite — server-first, with client interactivity as the exception. Trying to run both mental models in one codebase meant every component needed a "which world am I in?" check.

For a ~20-page marketing site, a clean rewrite was faster than a careful migration. If you have a 200-page app with complex client state, your answer might be different. But don't assume incremental is always the right call.

One folder per route, named exactly as the old path. I literally opened the old router file and created folders line by line. Then I ran a script against the old sitemap to hit every URL on the new build and check for 200s.

Two gotchas: Trailing slashes. The old SPA served /about and /about/ identically. Next.js redirects one to the other by default. Check which version Google had indexed and set trailingSlash in next.config.js to match. Legacy URLs you forgot about. Old PDFs, a /catalogue vs /catalog spelling inconsistency, a factory page that had been linked from a partner site. Grep your analytics for every path that got a hit in the last year and add redirects() for anything that doesn't map cleanly.

What stayed on the server (most of it): Page layouts, headers, footers Static content sections — text, images, brand grids, factory listings Anything reading from a CMS or JSON at build time

What had to become 'use client': The mobile navigation (needs useState for open/close) Image carousels and sliders The hero video with a mute/unmute button The contact form (form state, validation, submit) Anything using framer-motion or scroll-triggered animations Anything touching window, document, or localStorage

The trap I fell into early: marking a whole page 'use client' because one small piece needed interactivity. That throws away the entire point of the migration. The fix is to push the client boundary as far down as possible — the button is a client component, the section containing it stays on the server.

No loading state, no spinner, HTML arrives with the content in it. This one change is 80% of the SEO win.

The old site used react-helmet to set titles and descriptions client-side. Which means crawlers that don't execute JS never saw them.

Per page, statically, in the HTML. I also added sitemap.ts and robots.ts in the app folder so those are generated automatically instead of being hand-maintained files that drift out of date.

The SPA used plain tags pointing at Cloudinary. I kept Cloudinary but wrapped everything in next/image with a custom loader so I got proper srcset, lazy loading, and no layout shift — without moving 200+ images anywhere.

One annoyance: next/image requires width and height (or fill). The old code had none of that. I spent an afternoon adding dimensions. Worth it — CLS dropped to nearly zero.

A hydration error happens when the HTML the server rendered doesn't match what React renders on the client's first pass. React then throws away the server HTML and re-renders from scratch — which is slow, ugly, and defeats the purpose.

Where they came from in this project: window and localStorage checks in render. Any typeof window !== 'undefined' branch that changes output. Server takes one path, client takes another, mismatch. Fix: move it into useEffect, or render a neutral state first. Dates and locales. new Date().getFullYear() in the footer copyright. Rendered on a server in one timezone, hydrated in a browser in another — usually fine, until it's midnight somewhere. Fix: compute it once and pass it as a prop, or accept it's a client component. Third-party scripts injecting DOM. A chat widget and an analytics tag both modified the body before React hydrated. Fix: load them with next/script using strategy="afterInteractive". Nested tags. The old site had a card component that was an wrapping content that also contained an . Browsers silently "fix" invalid HTML, and the fixed version doesn't match React's output. Fix: fix your HTML. Random IDs. A component generated Math.random() IDs for accessibility attributes. Different on server and client every time. Fix: useId().

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation