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:
- Your sitemap, if you have one.
- Your router config. Every
<Route path>in React Router is a URL. - Search Console. Export the Pages tab of the Performance report for the longest date range it offers. These are the URLs Google actually shows.
- 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
useEffectrenders 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}` })),
]
}
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 featuresoutput: '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 inuseEffectnever reaches the HTML.- Changing slugs for style.
/blog/how-to-run-payrollbecoming/blog/payroll-guidecosts you a redirect and a reindex for no gain. - Trailing slash drift. If old URLs ended in
/, settrailingSlash: trueso they keep the same form. - Leaving the staging
noindexon. 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
200with "Page not found" text looks like a real page. CallnotFound()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?

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.