DEV Community

mhk sameera
mhk sameera

Posted on

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 <div id="root"> 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:

  1. 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.
  2. The site has to look identical. No redesign. Users should not notice anything changed except that it's faster.

Here's what that actually involved.

Why a full rewrite instead of incremental migration

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.

Step 1: Routes — react-router to the file system

This was the easy part, and the most important one for SEO.

Old:

<Route path="/confectionery" element={<Confectionery />} />
<Route path="/snacks" element={<Snacks />} />
<Route path="/our-brands" element={<Brands />} />
Enter fullscreen mode Exit fullscreen mode

New:

app/
  confectionery/page.tsx
  snacks/page.tsx
  our-brands/page.tsx
Enter fullscreen mode Exit fullscreen mode

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.

Step 2: The Server vs Client Component split

This is where the real work was, and where the mental model shift happens.

My rule became: everything is a Server Component until it can't be.

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.

// app/about/page.tsx — Server Component, no directive
import HeroVideo from './HeroVideo'; // 'use client' lives in here

export default function AboutPage() {
  return (
    <>
      <section>...static content, rendered on server...</section>
      <HeroVideo src="..." />   {/* only this hydrates */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Data fetching — killing useEffect

The old site had this pattern everywhere:

const [brands, setBrands] = useState([]);
useEffect(() => {
  fetch('/api/brands').then(r => r.json()).then(setBrands);
}, []);
Enter fullscreen mode Exit fullscreen mode

Loading spinner, then content. Google sees the spinner.

In App Router, the component is async and just fetches:

export default async function BrandsPage() {
  const brands = await getBrands();
  return <BrandGrid brands={brands} />;
}
Enter fullscreen mode Exit fullscreen mode

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

Step 4: Meta tags — react-helmet to the Metadata API

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

App Router has this built in:

export const metadata = {
  title: 'Company Name | Confectionery, Date & Snack Brands from Dubai',
  description: '...',
  openGraph: { images: ['...'], type: 'website' },
  alternates: { canonical: 'https://example.com' },
};
Enter fullscreen mode Exit fullscreen mode

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.

Step 5: Images

The SPA used plain <img> 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.

The Bad: Hydration errors

Now the part from the title.

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 <a> tags. The old site had a card component that was an <a> wrapping content that also contained an <a>. 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().

Debugging tip: the error message in dev tells you which element mismatched. Don't ignore it and don't suppress it with suppressHydrationWarning unless you understand exactly why (the footer year is the one legitimate case).

Step 6: The contact form

The old form POSTed to an external service from the client. I moved it to a Route Handler at app/api/contact/route.ts — the API key is now server-side only, I added server validation with Zod, and rate limiting so the form can't be spammed. (If you read my last post, you know why I care about this now.)

Step 7: Caching — the part nobody explains well

App Router caches aggressively by default, and the rules changed between versions. For a marketing site the simple approach is:

  • Pages with content that rarely changes: let them be static. Build once, serve from CDN.
  • Pages pulling from a CMS: export const revalidate = 3600 so they rebuild at most hourly.
  • Anything that must be fresh on every request: export const dynamic = 'force-dynamic'.

Don't try to be clever. Start fully static, and only opt into dynamic where you actually saw stale content.

The Good: what changed

Before (SPA): a blank page and a spinner until the JS bundle loaded, meta tags set by JavaScript, Lighthouse performance in the 50s–60s on mobile.

After: full HTML on first byte, every page with server-rendered meta and canonical tags, Lighthouse performance in the 90s, and the site indexed properly within a few weeks of launch.

The site looks exactly the same to a visitor. That was the requirement. But Google sees a completely different site.

What I'd tell someone about to do this

  1. Map every old route to a folder first, before writing a single component. Routes are your SEO contract.
  2. Default to Server Components. Add 'use client' only when the compiler complains, and add it as low in the tree as possible.
  3. Hunt down every window, Date, Math.random, and localStorage in render paths before you deploy — those are your hydration errors.
  4. Test the build with JS disabled. If the page is readable, you did it right.
  5. For a small-to-mid site, seriously consider a clean rewrite over incremental migration. It's less scary than it sounds.

If you've done a similar migration and hit a hydration error I didn't list, drop it in the comments — I'm sure I haven't seen them all.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.