Bhalli B.
  • Home
  • About
  • Services
  • Projects
  • Blogs
  • Booking
  • Contact
  • Home
  • About
  • Services
  • Projects
  • Blogs
  • Booking
  • Contact
Back to Blogs
  1. Home
  2. Blogs
  3. React SPA to Next.js 16 Migration: Steps, Redirects, SEO

React SPA to Next.js 16 Migration: Steps, Redirects, SEO

React SPA to Next.js migration: a client-rendered single-page app moving into a Next.js 16 App Router project through a shared catch-all route while URLs stay identical

Bhalli B - Full-Stack Engineer & SaaS MVP Architect

Written by

Bhalli B

Full-Stack Engineer & SaaS MVP Architect

Certified Full-Stack Developer & MVP Specialist · Lahore, Pakistan

I'm Bhalli B, a Full-Stack Engineer and SaaS MVP Architect based in Lahore, Pakistan, with 5+ years of experience shipping production web and mobile applications. I write about Next.js, the MERN stack, AI integration, and MVP delivery based on systems I have actually built and run in production - not theory. Every article here comes from real client work, real debugging sessions, and real launch deadlines.

  • BSc Software Engineering - Hajvery University, Lahore
  • Minimum Viable Product (MVP) Development Certified - Alison.com
  • Agile Project Management Essentials Certified - Management and Strategy Institute
  • Web Designing and Development Certified - PNY Trainings Lahore
About Author•GitHub•LinkedIn•X / Twitter•Daily.dev

To migrate a React SPA to Next.js without a rewrite, install Next.js, turn your index.html into an app/layout.tsx, and mount your existing app inside a client-only optional catch-all route. Every URL stays the same, so nothing breaks on day one. Then move one route at a time to real pages, adding redirects only for URLs that change.

Most migration guides stop at "it runs on localhost". The expensive mistakes come later: a static export that silently ignores your redirects, a Next.js 16 build that fails on a leftover webpack config, or a launch where old URLs return 404 and rankings drop. This guide covers the whole path for a Create React App or Vite project: the shell-first setup from the official guides, a route-by-route plan for leaving React Router, the Next.js 16 breaking changes that hit migrations, a redirect and URL parity model, a planning formula for effort, and a pre-launch test script you can run against your old sitemap.


1. Should you migrate a React SPA to Next.js at all?

Migrate only if a specific problem justifies it: pages that must be found in search, a slow first load caused by a large client bundle, or data-fetching waterfalls that server rendering would remove. If none of those describes your product, a working SPA is a perfectly good place to stay.

The official migration guides for Create React App and Vite give the same list of reasons, and it is worth reading as a checklist instead of a sales pitch. A client-only app makes the browser download and run your whole bundle before it can even request data. Bundles grow with every feature. Manual code splitting tends to create network waterfalls, where a child component cannot start fetching until its parent finishes. Next.js addresses these with automatic code splitting per route, server-side data fetching, and streaming through React Suspense.

There is a second reason that matters for a business site: the HTML a crawler receives first. A strict SPA serves one near-empty document and fills it in with JavaScript. A server-rendered page ships the headline, body text, and metadata in the first response. That does not make a client-rendered page unindexable, but it does mean your content depends on a second step succeeding.

When is staying on your SPA the better call?

Run each product area through this table before you commit any engineering time. The answers differ by area, so you may migrate the marketing site and leave the dashboard alone.

SituationDecisionReason
Public pages that should rank (landing, docs, blog, pricing)Migrate firstServer-rendered HTML and per-page metadata are the main gain
Logged-in dashboard behind authenticationUsually stayCrawlers never see it, so the benefit is mostly bundle size and data loading
Internal tool with no search trafficStayThere is no ranking benefit to buy
Product in maintenance modeStayNo roadmap work to absorb the regression risk
Active roadmap, small teamMigrate incrementallyFeature work continues while routes move one at a time

If you are still deciding between a plain React app and Next.js for a new build rather than an existing one, Next.js vs React for startups compares the two for MVP work. This post assumes you already have a SPA in production and want to move it safely.

What is the three-question gate I use before scoping a migration?

As a Certified Project Manager, I will not scope a migration until three questions have written answers. First, which URLs need server-rendered content, and what is their current organic traffic? Second, what measurable result would make the migration a success, such as a faster Largest Contentful Paint on the top five landing pages or an indexed page count? Third, what is the rollback plan if the first deployment goes wrong?

If the answer to the first question is "none of them", the project is a developer preference, not a business need. That is allowed, but it should be budgeted as a refactor, with the lower urgency and tighter scope that implies. If the answer to the third question is "we cannot roll back", the plan is not ready, and the shell-first approach in the next section is how you fix that.


2. How do you migrate a React SPA to Next.js without a rewrite?

Treat the migration as two stages. Stage one wraps your existing app in a Next.js project and renders it client-only, so behavior is identical and rollback is trivial. Stage two removes the static export and replaces SPA routes with real Next.js pages, one at a time.

This is the approach in the official Next.js guides for Create React App and Vite. Both begin by keeping your router and treating the app as a single-page application, to reduce complexity and merge conflicts. The diagram shows the full path, including the checkpoints I add around it.

Migration flow diagram: a React SPA is wrapped by Next.js as a client-only shell, then routes are converted to server-rendered pages one at a time, with a redirect map and an SEO crawl as checkpoints before launch Figure 1: The diagram shows the two-stage path. Stage one mounts the old app inside a Next.js catch-all route. Stage two converts routes to server-rendered pages while a redirect map and a staging crawl guard every release.

Step 1: What do you install and configure first?

Install Next.js in the existing project and add a config file. The two guides differ only in where the build output goes.

npm install next@latest react@latest react-dom@latest
npm install -D @types/react@latest @types/react-dom@latest
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'export', // SPA mode: static HTML, CSS, and JS only
  distDir: 'build', // CRA used build; the Vite guide uses ./dist
}

export default nextConfig

Next.js 16 needs Node.js 20.9 or newer and TypeScript 5.1 or newer, so check your CI image before anything else. A common first failure is a build server still on Node 18.

Step 2: How do you replace index.html with a root layout?

Create app/layout.tsx. It is the Next.js equivalent of the HTML shell in public/index.html (CRA) or index.html (Vite). Copy your head content into the Metadata API and keep a single root div, as the guides do.

// app/layout.tsx
import type { Metadata } from 'next'
import '../src/index.css' // your existing global stylesheet

export const metadata: Metadata = {
  title: 'Your App Name',
  description: 'Your existing meta description',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <div id="root">{children}</div>
      </body>
    </html>
  )
}

Delete the charset and viewport tags, since Next.js adds both. Move favicon.ico, icon.png, and robots.txt into the app directory so they are injected automatically.

Step 3: How do you mount the old app in a catch-all route?

This is the core trick. An optional catch-all segment named [[...slug]] receives every URL, and a client-only wrapper loads your existing App component with server rendering switched off.

// app/[[...slug]]/client.tsx
'use client'

import dynamic from 'next/dynamic'

const App = dynamic(() => import('../../src/App'), { ssr: false })

export function ClientOnly() {
  return <App />
}
// app/[[...slug]]/page.tsx
import { ClientOnly } from './client'

export function generateStaticParams() {
  return [{ slug: [''] }] // prerender a single shell for /
}

export default function Page() {
  return <ClientOnly />
}

Your React Router tree, state library, and data layer run untouched inside that component. For a Vite project, also import your global CSS in the page file, move the tsconfig changes the guide lists, and rename VITE_ environment variables.

Step 4: What changes in environment variables, images, and scripts?

Three small edits cause most of the first-day errors.

ConcernCreate React AppVite
Public env variable prefixREACT_APP_ becomes NEXT_PUBLIC_VITE_ becomes NEXT_PUBLIC_
Output directory in configdistDir: 'build'distDir: './dist'
Start script in the guidenpx serve@latest ./buildnext start
TypeScriptAdd next-env.d.ts to includeSeveral tsconfig changes, listed in the guide
Static image importsReturn an object, use the src propertyReturn an object, use the src property

Static image imports are the sneaky one. In CRA and Vite, importing a PNG returns a URL string. In Next.js it returns an object, so an img tag needs logo.src instead of logo, or you can reference the file by its public path.

What does stage one actually give you?

Be honest about this with your team. Stage one gives you a Next.js build pipeline, a layout, the Metadata API, and a safe place to start converting routes. It does not give you server-rendered content, because the app is still client-only. Search engines receive the same empty shell as before. Do not announce stage one as an SEO improvement.

Also remember what a static export cannot do. The static exports guide lists redirects, rewrites, headers, proxy, Server Actions, and cookies as unsupported. The guide's own generateStaticParams example only produces the root route, so on a plain static host, deep links to routes like /pricing still depend on your host's SPA fallback, exactly as they did with your old build. Keep whatever fallback rule your host already uses.


3. How do you move from React Router to the Next.js App Router, route by route?

Remove output: 'export', deploy to a host that runs Next.js, and convert routes in order of organic traffic. A real page such as app/pricing/page.tsx is a static route, so it is matched before the catch-all. Everything you have not converted keeps running in the shell.

Check that precedence on a preview deployment before you rely on it. The next.config docs describe the full routing order, in which dynamic routes are matched after static files and pages, so a concrete page should win over the optional catch-all.

How do React Router concepts map to the App Router?

React Router (SPA)Next.js App Router
Route path="/users/:id"app/users/[id]/page.tsx
Link with the to propLink from next/link with the href prop
useNavigateuseRouter from next/navigation
useLocationusePathname and useSearchParams from next/navigation
Navigate componentredirect() from next/navigation
Outlet in a layout routelayout.tsx rendering children
Route-level lazy loadingAutomatic code splitting per route

One caveat from the official docs: with a static export, the useParams hook is not supported, so keep route-parameter logic in the old shell until you drop the export.

What does a converted data-fetching route look like?

The biggest rewrite inside a route is data loading. An SPA renders a placeholder, mounts, then fetches. A Server Component fetches before the first byte reaches the browser, and the user receives finished HTML.

❌ SPA PAGE: FETCH AFTER MOUNT
function Profile() {
const { id } = useParams();
const [user, setUser] = useState(null);

useEffect(() => {
  fetch("/api/users/" + id)
    .then((r) => r.json())
    .then(setUser);
}, [id]);

if (!user) return <Spinner />;
return <h1>{user.name}</h1>;
}

The browser downloads the bundle, mounts the component, and only then requests the data. The first response contains no user name, and every nested component that also fetches adds another round trip.

✅ APP ROUTER PAGE: FETCH ON THE SERVER
// app/users/[id]/page.tsx
export default async function Page(
props: PageProps<"/users/[id]">
) {
const { id } = await props.params;
const user = await getUser(id);
return <h1>{user.name}</h1>;
}

The data is loaded before the response is sent, so the headline is in the first HTML. Next.js 16 passes params as a Promise, which is why the component is async and awaits it.

Keep interactive pieces such as forms, charts, and drag-and-drop widgets as Client Components with the use client directive, and pass them data from the server page as props. If the split between server and client code is new to you, React Server Components for founders explains where the boundary belongs without the jargon.

Which migration pattern fits your hosting: catch-all or fallback rewrite?

There are two ways to run old and new code side by side, and the right one depends on where the SPA lives.

PatternHow it worksBest whenWatch out for
Catch-all shell in the same projectOld app mounts at the optional catch-all, real pages take priorityYou want one repository and one deployThe old bundle ships with the project until the last route moves
Fallback rewrite to the legacy siteNext.js proxies unmatched paths to the old hostThe old app is large and deployed separatelyTwo deployments to monitor, and an extra network hop for old routes
Big-bang rewriteReplace everything in one releaseAlmost neverNo rollback path and every URL changes at once

The second pattern uses the fallback array from the rewrites configuration, which the docs describe as the way to incrementally adopt Next.js. Fallback rewrites run after pages, static files, and dynamic routes have been checked, so every route you migrate quietly takes over from the legacy site without a config change.

// next.config.js
module.exports = {
  async rewrites() {
    return {
      fallback: [
        {
          source: '/:path*',
          destination: 'https://legacy.example.com/:path*',
        },
      ],
    }
  },
}

Rewrites and redirects both require a Next.js server, so this pattern belongs to stage two. Which URLs do you convert first? Sort the Pages tab of your Search Console Performance report by clicks over the last 90 days and start at the top. High-traffic pages get the benefit sooner, and any mistake there shows up in your numbers quickly while the blast radius is still small.

What if you also run on a static host today?

A static host works for stage one but not for stage two. Once you drop the export, you need a runtime for server rendering, rewrites, and redirects. Budget for that change before you start, because it alters your monthly bill and your deployment process. Next.js 16 hosting costs walks through the options, and CI/CD with GitHub Actions caching and performance budgets covers build caching so migration pull requests stay fast.


4. What breaks when you migrate to Next.js 16?

Six things break most often: synchronous access to params, searchParams, cookies, and headers; a custom webpack configuration now that Turbopack is the default; the middleware file name; the next lint command; image configuration defaults; and code that touches window during prerendering.

The Next.js 16 upgrade guide lists each change with a fix. The table collects the ones that land on a SPA migration, with the official codemod where one exists.

Change in Next.js 16What breaksFix
Async Request APIsSynchronous params, searchParams, cookies(), headers(), draftMode() access is removedAwait them, and run the next-async-request-api codemod
Turbopack by defaultnext build fails if a custom webpack config existsUse the --webpack flag, or migrate the config to turbopack options
middleware renamed to proxymiddleware.ts is deprecated, and proxy runs on the Node.js runtimeRename the file and function, or keep middleware if you need the edge runtime
next lint removedScripts that call next lint fail, and next build no longer lintsRun ESLint or Biome directly, using the lint codemod
images defaultsDefault quality list is 75 only, minimum cache TTL is 4 hours, domains is deprecatedSet images.qualities, and use remotePatterns
Node and TypeScript floorNode 18 is unsupportedNode 20.9 or newer, TypeScript 5.1 or newer

Run the two codemods early, in a clean branch, so the diff is reviewable.

npx @next/codemod@canary upgrade latest
npx @next/codemod@canary next-async-request-api .
npx next typegen

The first command applies the general upgrade. The docs note that it does not run every migration, which is why the async Request APIs codemod is listed separately. The typegen command, available since Next.js 15.5, generates the PageProps and LayoutProps helpers used in the examples in this post.

❌ NEXT.JS 15 STYLE: SYNCHRONOUS PARAMS
export default function Page({ params }) {
const slug = params.slug;
const theme = cookies().get("theme");
return <h1>{slug}</h1>;
}

Next.js 15 allowed this for compatibility. In Next.js 16 the synchronous access is fully removed, so params and cookies must be awaited.

✅ NEXT.JS 16: AWAIT EVERYTHING ASYNC
export default async function Page(
props: PageProps<"/blog/[slug]">
) {
const { slug } = await props.params;
const theme = (await cookies()).get("theme");
return <h1>{slug}</h1>;
}

The page becomes async, params arrives as a Promise, and cookies is awaited before use. The codemod performs most of this rewrite for you.

Why does the build fail on a webpack config you never touched?

Because a plugin may be adding one. The upgrade guide says that if your project has a custom webpack configuration and you run next build, which now uses Turbopack, the build fails to prevent misconfiguration. It also says that if you see this failure without defining a webpack config yourself, a plugin is probably adding the option. Your choices are to build with the --webpack flag, to build with --turbopack and ignore the config, or to port the config. Decide on purpose, because the flags change what ships.

The CRA guide points out the same trade-off from the other direction: custom webpack tweaks can be carried over, but the dev script then needs the --webpack flag.

What about the old middleware file?

If your SPA never had middleware, you have nothing to rename. If you add authentication redirects during migration, create proxy.ts, not middleware.ts. The docs state that proxy runs on the Node.js runtime and the runtime cannot be configured. If you depend on the edge runtime, the guide says to keep using the middleware convention. Treat the proxy as a place for cheap redirects and session refresh, and check authorization again inside each route and Server Function, since a matcher change can silently remove coverage.

Why does window is not defined appear after the migration?

Client Components are prerendered on the server during the build, so any top-level reference to window, localStorage, or document throws. In stage one the dynamic import with ssr set to false hides this, because your whole app skips prerendering. In stage two, as you convert routes, move browser-only access into a useEffect, or load that one component with next/dynamic and ssr false. Fix it at the component level, not by wrapping whole pages in a client-only shell, or you give back the server-rendering gain you migrated for.

If you would rather hand the upgrade and route conversion to someone who has done it before, book a 30-minute call and bring your route list and your build log.


5. How do you keep your SEO when you migrate a React SPA to Next.js?

Keep every indexed URL working with the same path, then redirect only the URLs that change, with a single permanent hop to the final destination. Replicate titles, descriptions, and canonicals through the Metadata API, ship a sitemap, and compare a crawl of the old and new sites before launch.

The most important design decision is also the cheapest: keep your URLs. The catch-all shell does this by default, because every path your SPA served is still served at the same address. Redirects are only needed for paths you deliberately change, so the fewer you change, the smaller your risk.

Should you use 301 or 308 for permanent redirects in Next.js?

Use permanent: true and do not worry about it. In Next.js, permanent set to true returns a 308 status code, and false returns 307. The framework chose these over 301 and 302 because the older codes let some clients change the request method. Google's documentation on redirects lists both 301 and 308 as permanent redirects that signal the new URL as canonical, so the 308 is the right choice for Next.js. A 307, on the other hand, is a temporary redirect, so do not leave permanent: false on a URL you intend to retire.

Note that Google's redirect documentation also says to use JavaScript redirects only when server-side redirects are not possible, because rendering can fail and Google may never see them. A client-side navigate call in your old SPA is not a substitute for a real redirect.

How do you write the redirect map?

Export your old sitemap and your Search Console page list, put every URL in a spreadsheet, and add a column for the new path. Only rows where the path changes become redirects.

// next.config.ts (stage two only: redirects are not supported with output: export)
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  trailingSlash: false,
  async redirects() {
    return [
      // exact one-to-one moves
      { source: '/pricing-plans', destination: '/pricing', permanent: true },
      // a whole section, keeping the rest of the path
      { source: '/app/blog/:slug*', destination: '/blog/:slug*', permanent: true },
      // numeric legacy ids only
      { source: '/post/:id(\\d{1,})', destination: '/blog/:id', permanent: true },
    ]
  },
}

export default nextConfig

Three rules keep the map healthy. Never point a redirect at another redirect, because chains waste crawl budget and slow users. Always include a leading slash before a colon in path parameters, or the pattern becomes a literal string and can loop. And mind trailing slashes: if your old host served paths with a trailing slash, set trailingSlash to true so Next.js does not redirect every URL to a slashless version.

How much traffic depends on your redirects?

You can put a number on the risk before you write a line of config. The model uses Search Console clicks over a 90-day window and counts only the URLs whose path will change.

Redirect-Dependent Traffic Share

S = C_changed ÷ C_total × 100

S: Share of clicks that rides on redirects
C_changed: Clicks to URLs whose path changes
C_total: Clicks to all indexed URLs
Window: Last 90 days, from Search Console

Take a hypothetical site with 1,200 organic clicks over 90 days across 40 URLs. Six of those URLs change path, and together they hold 300 of the clicks. The illustration below uses made-up numbers to show how to read the result, so replace them with your own export.

Skip the redirect map
300 ÷ 1,200 × 100 = 25% of clicks hit a 404
Six changed URLs return not found at launch. A quarter of this example's search entry points are dead until they are fixed.
Map and test the six rows
300 ÷ 1,200 × 100 = 25% rides on 6 redirects
Same traffic share, but each of the six URLs now answers with one permanent hop. Those six rows are the first thing you test and the first thing you watch in Search Console after launch.

The number does two jobs. It tells you whether the redirect work is a footnote or the main risk, and it tells you which rows to test first. If S is near zero because you kept every URL, your SEO risk shifts to metadata and rendering instead.

What else must match between the old and new site?

Redirects are only one part of URL parity. Check the following on a staging crawl before launch.

  • Titles and meta descriptions. Replicate them exactly through the Metadata API or generateMetadata. A framework migration is not the moment to rewrite them, because if traffic moves you will not know which change caused it.
  • Canonical URLs. Each page needs a self-referencing canonical on the final domain and path.
  • Status codes. Missing pages should return a real 404, not a 200 page that says not found.
  • Sitemap and robots. Generate the sitemap from the same route list you used for the redirect map, and confirm robots.txt did not change.
  • Structured data. If your SPA injected JSON-LD with JavaScript, move it into the server-rendered page so it exists in the HTML.
  • Internal links. Replace old paths in your own navigation so users and crawlers do not travel through redirects.

After launch, submit the sitemap in Search Console, then watch the page indexing report and the 404 list for a few weeks.


6. How long does a React SPA to Next.js migration take?

A small app of around ten routes takes roughly two to three weeks for one developer, a 24-route app roughly six weeks, and a 60-route app roughly three months, using the planning formula below. These are estimates from stated assumptions, not industry benchmarks, so replace the inputs with your own.

Published timelines for migrations vary so much that quoting any of them without your route count is misleading. A better approach is a formula you can recalculate once you have counted routes and sorted them by difficulty.

Migration Effort Planning Model

Days = (F + S × s + D × d) × (1 + B)

F: Fixed work, 3 days (shell, redirect map, staging crawl, launch)
S, s: Simple routes, 0.5 day each
D, d: Data-heavy routes, 1.5 days each
B: Contingency, 20%

The 3, 0.5, 1.5, and 20% inputs are my own planning assumptions for one developer who knows the codebase. A simple route is mostly static content or a thin view. A data-heavy route has fetching, forms, authorization, or heavy client interaction. Adjust the inputs for your team, then use the result to set expectations instead of a promise.

Guess from a blog post
"2 to 4 weeks" for any app
A range with no route count behind it. It is wrong for a ten-route app and for a sixty-route one, and it gives the team nothing to track.
24 routes: 16 simple, 8 data-heavy
(3 + 8 + 12) × 1.2 = 27.6, about 28 days
Twenty-three working days of base effort plus 20% contingency, which is a little under six calendar weeks for one developer.
App sizeRoutes (simple, data-heavy)Base daysWith 20% contingencyCalendar estimate
Small10 (6, 4)3 + 3 + 6 = 1214 daysAbout 3 weeks
Medium24 (16, 8)3 + 8 + 12 = 2328 daysAbout 6 weeks
Large60 (40, 20)3 + 20 + 30 = 5364 daysAbout 13 weeks

Two things move these numbers more than anything else. The first is a second developer, which shortens calendar time less than people hope because the fixed work and the redirect map do not split well. The second is test coverage: a route with no tests costs more to convert, because you are guessing what it does.

How do you run the work so it never becomes a four-week black box?

As a Certified Project Manager, I scope migrations in one-week increments, and each increment must end in something deployable. Week one is the shell, the redirect map, and a staging crawl. Every later week converts the next batch of routes by traffic, and ships. If a week slips, the previous week is still live and nothing is blocked.

That cadence also gives you a built-in rollback. Each conversion is a separate pull request, so reverting a route means reverting one change, and the catch-all shell is still there to serve it.


7. How do you test a React SPA to Next.js migration before launch?

Test three things on a staging deployment: every old URL returns either a 200 or a single permanent redirect to the right place, the server-rendered HTML contains the real content, and your key flows still work. Automate the first two with a script that runs against your old sitemap.

A manual spot check of ten pages misses the long tail, which is exactly where redirect mistakes live. The script below takes a list of old URLs and the expected destination for any that moved, then checks the status without following redirects.

// scripts/check-urls.ts (Node 20 or newer; run with: npx tsx scripts/check-urls.ts)
const BASE = process.env.STAGING_URL ?? 'https://staging.example.com'

// old path -> expected final path (null means it should return 200 as is)
const urls: Record<string, string | null> = {
  '/': null,
  '/pricing': null,
  '/pricing-plans': '/pricing',
  '/app/blog/hello-world': '/blog/hello-world',
}

let failures = 0

for (const [path, expected] of Object.entries(urls)) {
  const res = await fetch(BASE + path, { redirect: 'manual' })

  if (expected === null) {
    if (res.status !== 200) {
      failures++
      console.error('FAIL', path, 'expected 200, got', res.status)
    }
    continue
  }

  const location = res.headers.get('location') ?? ''
  const finalPath = location.replace(BASE, '')
  const permanent = res.status === 301 || res.status === 308

  if (!permanent || finalPath !== expected) {
    failures++
    console.error('FAIL', path, 'status', res.status, 'location', location)
  }
}

console.log(failures === 0 ? 'All URL checks passed' : failures + ' failures')
process.exit(failures === 0 ? 0 : 1)

The script treats 301 and 308 as permanent and rejects 307, which catches the case where permanent was left as false. Because it uses manual redirect handling, it also fails when a redirect points at another redirect, since the location will not match the final path.

How do you confirm the page is actually server-rendered?

Fetch the raw HTML without running JavaScript and look for your content. This is the check that stage one will fail and stage two should pass.

curl -s https://staging.example.com/pricing | grep -o "<h1[^>]*>[^<]*</h1>"
curl -s https://staging.example.com/pricing | grep -o "<title>[^<]*</title>"
curl -s https://staging.example.com/pricing | grep -c "application/ld+json"

If the first command prints nothing for a page you converted, that page is still being rendered in the browser and you have not yet gained what you migrated for.

What belongs on the pre-launch checklist?

  • A crawl of the old site and the staging site, with the URL, title, description, canonical, and status of each page compared in a spreadsheet.
  • The URL script above passing against your complete redirect map.
  • A production build with the same Node version and flags as CI, including the webpack or Turbopack decision from section 4.
  • End-to-end tests for sign-in, checkout or sign-up, and the two or three flows that earn revenue.
  • A Lighthouse or WebPageTest run on the top five landing pages before and after, so you can show the result you set as the goal in section 1.
  • A named rollback step, such as pointing the domain back to the old build, and one person who knows how to do it.

8. Conclusion and Actionable Roadmap

A React SPA to Next.js migration is a sequence of small, reversible releases, not a rewrite. Wrap the old app in a client-only catch-all route, drop the static export, convert routes in order of traffic, keep every URL you can, redirect the rest with one permanent hop, and test the redirect map against a staging crawl before launch.

Do the work in this order. This week, export 90 days of Search Console data, count routes, and calculate the redirect-dependent share and the effort formula from this guide. Next, stand up the shell and confirm your CI runs on Node 20.9 or newer. Then convert the highest-traffic routes, run the two codemods, and decide on webpack versus Turbopack on purpose. Before launch, run the URL script, crawl staging, and name your rollback. Afterwards, watch indexing and 404 reports for a few weeks, and change titles and URLs later, in separate releases.

Move your React SPA to Next.js without losing the traffic you already have: I plan and run incremental SPA-to-Next.js 16 migrations, from the URL and redirect audit to route conversion, codemods, and a staging crawl before launch, in one-week deployable increments. Contact me today to book a 30-minute migration audit.


9. Frequently Asked Questions

Q: Can I migrate a React SPA to Next.js without rewriting everything?
A: Yes. Install Next.js, replace index.html with an app/layout.tsx, and load your existing App component through an optional catch-all route with a client-only dynamic import. The app behaves as before, so you can ship that first and then convert routes to real pages one at a time. Stage one does not give you server-rendered content yet; the gains arrive as routes move to Server Components.
Q: Do redirects in next.config work with output export?
A: No. The static exports guide lists redirects, rewrites, headers, proxy, and Server Actions as unsupported. During the static shell stage, keep your old URLs unchanged and use your host's own redirect and fallback rules. Add Next.js redirects after you remove the export and deploy to a host that runs a Next.js server.
Q: Should I use 301 or 308 redirects when migrating to Next.js?
A: Use permanent: true in next.config, which sends a 308. Google's redirect documentation lists both 301 and 308 as permanent redirects, so you do not need to hand-roll 301s. Avoid permanent: false for retired URLs, because it sends a temporary 307 that does not signal a canonical change. Keep each redirect to a single hop.
Q: What breaks when upgrading to Next.js 16 during a migration?
A: Synchronous access to params, searchParams, cookies, and headers is removed, so they must be awaited. A custom webpack config fails next build now that Turbopack is the default, the middleware file is renamed proxy, next lint is removed, and some image defaults changed. Node 20.9 and TypeScript 5.1 are the minimums. The official codemods handle most of the changes.
Q: How long does a React SPA to Next.js migration take?
A: It depends on route count and difficulty. With my planning assumptions of 3 fixed days, half a day per simple route, 1.5 days per data-heavy route, and 20 percent contingency, a 24-route app comes to about 28 working days, a little under six weeks for one developer. Treat that as a starting estimate and recalculate with your own route list.
Free Scoping Session

Have Something to Build?

Pick what you're trying to build below, and see exactly what a working engagement with me looks like - timeline, stack, and deliverables.

Product LaunchEst. Timeline: 4 to 8 Weeks

Build a SaaS MVP Roadmap

Turn your idea into a production-ready SaaS - architected, built, and shipped by one engineer, not a handoff chain.

Tech Stack

Next.js 16 + Tailwind v4 + PostgreSQL or MongoDB

Deliverables

Fully functional app with auth, billing, and database integrations.

Included With Your Scoping Call

MoSCoW-scoped feature list and a database architecture roadmap.

Book Free MVP ScopingRequest MVP Cost Estimate
🔒 NDA Available⚡ Free scoping call, no obligation

Latest Articles

How to Build an AI Agent SaaS MVP in 2026: 6-Week Plan

October 11, 2026

How to Build an AI Agent SaaS MVP in 2026: 6-Week Plan

How to Migrate React to Next.js Without a Full Rewrite

October 10, 2026

How to Migrate React to Next.js Without a Full Rewrite

Micro SaaS Architecture: Stack, Schema, and Real Costs

October 9, 2026

Micro SaaS Architecture: Stack, Schema, and Real Costs

Why Choose Next.js for Your MVP? Cost, Speed, and Scale

October 8, 2026

Why Choose Next.js for Your MVP? Cost, Speed, and Scale

How to Take Your SaaS Codebase In-House (Handoff Checklist)

October 7, 2026

How to Take Your SaaS Codebase In-House (Handoff Checklist)

How to Price Your SaaS Product: Which Model Should You Pick?

October 6, 2026

How to Price Your SaaS Product: Which Model Should You Pick?

View All Articles

Bhalli B.

Full-Stack Developer and SaaS MVP Architect, helping startups turn ideas into production-ready products with clean architecture, AI-powered features, and reliable delivery.

Quick Links

  • Home
  • About
  • Projects
  • Services
  • Blogs
  • Book Consultation
  • Contact

Services

  • SaaS MVP Development
  • Full-Stack Development
  • AI & Agentic Systems
  • Technical Architecture & Strategy
  • Project Delivery & Engineering

Contact

  • +92 349 7273482
  • WhatsApp
  • me@bhalli.dev
BHALLI B.BHALLI B.

© 2026 Bhalli B.All rights reserved.

Designed & Built by Bhalli B.

Terms of Service|Privacy Policy