SearchMaxx > Blog > Migration guides

How to migrate a React SPA to Next.js without losing search traffic

To move a React single-page app to Next.js without losing traffic, keep every public URL the same, render your marketing pages to HTML on the server or at build time, and send a permanent redirect for any URL you do change. Then check the raw HTML with curl before and after launch. The framework switch is the easy part. Most lost traffic comes from changed URLs and pages that still arrive empty.

Why a single-page app is invisible to AI crawlers

A single-page app sends the browser a nearly empty HTML file and a bundle of JavaScript. The JavaScript then builds the page. People see the full page. A crawler that does not run JavaScript sees the empty file.

In December 2024 Vercel and MERJ studied AI crawler traffic across Vercel's network and found that "none of the major AI crawlers currently render JavaScript." That covered OpenAI's crawlers, Anthropic's ClaudeBot and PerplexityBot. Gemini and Applebot were the exceptions. (Vercel: The rise of the AI crawler)

Google does render JavaScript, but in a separate step. Pages wait in a render queue, which Google says "may stay on this queue for a few seconds, but it can take longer than that." Google still recommends server-side rendering or pre-rendering because "not all bots can run JavaScript." (Google: JavaScript SEO basics)

So an SPA can rank in Google and still be missing from ChatGPT, Claude and Perplexity answers. We cover the test in detail in AI crawlers don't run JavaScript.

Step 1: Inventory every URL that gets traffic

Before you change any code, write down every URL that exists today. You will check each one again after launch.

Pull URLs from four places:

  1. Your sitemap, if you have one.
  2. Your router config. Every <Route path> in React Router is a URL.
  3. Search Console. Export the Pages tab of the Performance report for the longest date range it offers. These are the URLs Google actually shows.
  4. Analytics. Export landing pages, so you catch URLs that get traffic from links and ads.

To dump a sitemap into a file:

curl -s https://paidwick.com/sitemap.xml \
  | grep -o '<loc>[^<]*' | sed 's/<loc>//' > urls.txt

Merge the lists, remove duplicates, and keep urls.txt. It is your test suite for the rest of the migration.

Also record a baseline for your top 20 pages: the title, the meta description, and the word count from the raw HTML. You will compare against it later.

Step 2: Map each route to a Next.js file

Next.js uses folders for routes. Map every React Router path to a file in the app directory, and keep the path identical.

React Router path Next.js file Render as
/ app/page.tsx Static at build
/pricing app/pricing/page.tsx Static at build
/blog/:slug app/blog/[slug]/page.tsx Static, with generateStaticParams
/customers/:slug app/customers/[slug]/page.tsx Static, with generateStaticParams
/app/* (logged in) app/app/[[...path]]/page.tsx Client-only is fine

The split matters. Public pages that you want found need real HTML. The logged-in app does not, because crawlers cannot log in. You can leave it as client-side code.

If your SPA uses hash routes like /#/pricing, plan for extra work. Google says "don't use fragments to load different page content" (Google: JavaScript SEO basics). The part after # never reaches your server, so a server redirect cannot see it. Add a small script on the home page that reads location.hash and sends the visitor to the new path.

Step 3: Get the app running on Next.js

The official guides cover the mechanics: migrating from Vite and migrating from Create React App.

Read one warning before you ship anything. The Vite guide's first stage keeps your app "as a purely client-side application (SPA)". It loads your whole app with dynamic(() => import('../../App'), { ssr: false }), which turns off server rendering for everything below it. That is a sensible way to start the work. It changes nothing for crawlers. If you launch at this stage, AI crawlers still get an empty page.

Treat stage one as a branch, not a release. Ship once your public pages render on the server.

Step 4: Render public pages on the server or at build time

In the App Router, components are Server Components unless you mark them with 'use client'. Server Components render to HTML before they reach the browser. Even Client Components "are still prerendered to HTML on the server" (Next.js: migrating from Vite), unless you turn that off with ssr: false.

Three rules get most pages right:

  • Fetch content on the server. A component that fetches its text in useEffect renders a loading state into the HTML. Move that fetch into the Server Component.
  • Keep interactive pieces small. Put 'use client' on the pricing toggle, not on the whole pricing page.
  • Pre-build dynamic routes. Tell Next.js which slugs exist so it can build each page ahead of time.
// app/blog/[slug]/page.tsx
import { getPost, getAllSlugs } from '@/lib/posts'

export async function generateStaticParams() {
  const slugs = await getAllSlugs()
  return slugs.map((slug) => ({ slug }))
}

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  )
}

When you run next build with a static export, "Next.js generates an HTML file per route" (Next.js: static exports). Static output is the simplest way to make sure every crawler gets the full page.

Step 5: Move titles, descriptions and canonicals to the Metadata API

Most SPAs set titles with a library like React Helmet, after JavaScript runs. Crawlers that skip JavaScript see only the default title in index.html, so every page looks the same to them.

Next.js puts metadata in the HTML on the server. The metadata object and generateMetadata "are only supported in Server Components," because metadata "must be resolved on the server before the page component is rendered" (Next.js: generateMetadata).

Set defaults once in the root layout:

// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  metadataBase: new URL('https://paidwick.com'),
  title: { template: '%s | Paidwick', default: 'Paidwick' },
  description: 'Payroll for remote teams.',
  alternates: { canonical: '/' },
}

Then override per page:

// app/blog/[slug]/page.tsx
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)
  return {
    title: post.title,
    description: post.summary,
    alternates: { canonical: `/blog/${slug}` },
  }
}

Copy titles and descriptions from your Step 1 baseline. A migration is a bad time to rewrite them all, because then you cannot tell which change moved your traffic.

One detail for AI crawlers. When generateMetadata runs at request time, Next.js can stream the metadata tags into the <body> after the page starts sending. It keeps metadata in <head> only for a list of "HTML-limited bots." As of today, that list in the Next.js source includes Bingbot and social preview bots but not GPTBot, ClaudeBot or PerplexityBot. If the page is prerendered, this does not apply: the metadata "is included in the page's initial HTML." Prerender your public pages and you avoid the question. If you cannot, the htmlLimitedBots option lets you widen the list.

Step 6: Add a sitemap and robots.txt

Create app/sitemap.ts. Next.js serves it at /sitemap.xml.

// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllSlugs } from '@/lib/posts'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const base = 'https://paidwick.com'
  const slugs = await getAllSlugs()
  return [
    { url: base, lastModified: new Date() },
    { url: `${base}/pricing`, lastModified: new Date() },
    ...slugs.map((slug) => ({ url: `${base}/blog/${slug}` })),
  ]
}

(Next.js: sitemap.xml)

Put robots.txt at the top of the app directory. Next.js picks it up automatically. Leave out your logged-in app paths if you like, but let the AI search crawlers reach your public pages. Our robots.txt guide has a version you can copy.

Step 7: Redirect every URL that changes

The best redirect is the one you do not need. Keep paths identical where you can. Where a path must change, send a permanent redirect from the old URL straight to the new one.

// next.config.js
module.exports = {
  async redirects() {
    return [
      { source: '/features/:slug', destination: '/product/:slug', permanent: true },
      { source: '/resources/blog/:slug*', destination: '/blog/:slug*', permanent: true },
      { source: '/plans', destination: '/pricing', permanent: true },
    ]
  },
}

With permanent: true, Next.js returns a 308 (Next.js: redirects). Google treats 301 and 308 the same way, as permanent. It says permanent redirects "don't cause a loss in PageRank," asks you to redirect "to the final destination directly," and to keep redirects for "generally at least 1 year" (Google: site moves with URL changes).

Two catches:

  • Static exports do not run redirects(). Redirects are on the list of features output: 'export' does not support (Next.js: static exports). If you export static files, set redirects at your host or CDN instead.
  • Do not send every old URL to the home page. Google warns against redirecting "many old URLs to one irrelevant single URL destination." If a page has no match, let it return a 404.

AI crawlers make missing redirects costly. Vercel found 34.82% of ChatGPT's crawler fetches and 34.16% of Claude's hit 404 pages, against 8.22% for Googlebot (Vercel). They already request a lot of dead URLs. Do not give them more.

Step 8: Verify before and after launch

Run these against your preview deployment, then again against production.

Words in the raw HTML. This should match what a visitor reads, not a few dozen words.

curl -s https://paidwick.com/pricing \
  | sed -e 's/<script[^>]*>.*<\/script>//g' -e 's/<[^>]*>/ /g' \
  | wc -w

Title, description and canonical in the raw HTML.

curl -s https://paidwick.com/pricing \
  | grep -oE '<title>[^<]*</title>|<meta name="description"[^>]*>|<link rel="canonical"[^>]*>'

Status of every old URL. Each line should be 200, or 308/301 followed by the right destination.

while read -r url; do
  printf '%s ' "$url"
  curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "$url"
done < urls.txt

What an AI crawler gets. A 403 here means a firewall is blocking it.

curl -s -o /dev/null -w '%{http_code}\n' \
  -A 'Mozilla/5.0 (compatible; GPTBot/1.0)' https://paidwick.com/

In Search Console, run URL Inspection on your top pages, which Google suggests for testing redirects (Google). Submit the new sitemap. Then watch the Page indexing report and Performance for the next few weeks.

Common mistakes

  • Shipping the client-only stage. The app runs on Next.js but still sends an empty page.
  • 'use client' at the top of every page. It works, but data fetched in useEffect never reaches the HTML.
  • Changing slugs for style. /blog/how-to-run-payroll becoming /blog/payroll-guide costs you a redirect and a reindex for no gain.
  • Trailing slash drift. If old URLs ended in /, set trailingSlash: true so they keep the same form.
  • Leaving the staging noindex on. Google reminds you to "remove any noindex or robots.txt blocks that were only needed for the migration."
  • Soft 404s. A catch-all route that returns 200 with "Page not found" text looks like a real page. Call notFound() so missing pages return 404.

Consider Astro for a content-heavy marketing site

If the public site is mostly text, such as a homepage, docs and a blog, Astro is worth a look. It is "designed for building content-rich websites," renders on the server by default, and ships little JavaScript. It supports React components, so you can reuse parts of your app. You can run the marketing site on Astro and keep the product as a React app on a subdomain. The URL rules above apply either way.

Checklist

Item Done when
URL inventory urls.txt holds every sitemap, router, Search Console and analytics URL
Baseline Titles, descriptions and word counts saved for top pages
Route map Every public path maps to an app/ file with the same path
Server rendering curl word count matches the visible page
Metadata Raw HTML has a unique title, description and canonical per page
Sitemap /sitemap.xml lists only live, canonical URLs
robots.txt AI search crawlers allowed, staging blocks removed
Redirects Every changed URL returns 301 or 308 to its final page
404s Missing pages return 404, not 200
Search Console New sitemap submitted, top pages inspected

If you want to see what AI crawlers get from your site right now, our free check counts the words that arrive without JavaScript and tests each AI crawler's access. For the background on why the HTML matters, read AI crawlers don't run JavaScript. If your migration also changes your domain, see our domain migration checklist.

Want this done for your site?

Roshan, founder of SearchMaxx

Every day we make one change to your website that helps AI engines name you, and a person checks each one. Email me your website and the question your buyers ask AI.

Email Roshan to get started

More from the blog

  1. The SearchMaxx experiment, day 0: growing a new site in public

    Oct 18, 2026 - searchmaxx.link is a new domain. It has no Google traffic and no links. We will grow it with the same work we sell, and publish the numbers every week.

    searchmaxx.link/blog/searchmaxx-experiment-day-0 - Experiment - 2 min read

  2. Case study: 515 to 2,573 Google clicks a day in 12 weeks

    Oct 17, 2026 - Real Google Search Console numbers from an open-source AI company. In the top 1,000 queries, 66% of clicks came from searches without the brand name.

    searchmaxx.link/blog/case-study-5x-google-clicks - Case studies - 2 min read

  3. AI crawlers don't run JavaScript. Here's how to see what they see

    Oct 16, 2026 - ChatGPT's, Claude's and Perplexity's crawlers read the HTML your server sends and skip the JavaScript. If your words only appear after JavaScript runs, those engines see an empty page.

    searchmaxx.link/blog/ai-crawlers-javascript - Guides - 2 min read

  4. Which AI crawlers to allow in robots.txt, and which only collect training data

    Oct 15, 2026 - OpenAI, Anthropic, Perplexity and Google each run several bots. Some put you in AI search answers and some only collect training data. Here is which is which, and a robots.txt you can copy.

    searchmaxx.link/blog/ai-crawlers-robots-txt - Guides - 3 min read