Technical guide · React to Next.js migration
The main risk in a React migration is not the component code. It sits at the boundaries: URLs, sessions, API calls, caching, analytics and the release process. For public pages, the team also needs to inspect what browsers and crawlers receive before any JavaScript runs.
Instead of estimating a full rewrite, start with one public page where Next.js can deliver useful HTML, reliable metadata and potentially less client-side JavaScript. The reverse proxy must also be able to send that URL back to the SPA if the migrated version regresses in production.
Starting point
A production SPA while product delivery continues
A common starting point is a B2B platform with a public catalogue and an authenticated product area. The React frontend uses Vite and React Router against a stable Node.js API. Services run in Docker, and product delivery continues throughout the migration.
Replacing Vite is not an objective in itself. The public-page response currently contains little more than an empty shell. Titles, content, links and sometimes the canonical URL appear only after the application has loaded and several API requests have completed. Those requests may form waterfalls, while routing, session handling and global state remain tightly coupled to the SPA. A long-lived rewrite branch would merely defer the integration risk until cutover.
Constraints
Migrate without a redesign, an API rewrite or a pause in product delivery.
- Preserve existing URLs and sessions.
- Cut over and observe one route at a time.
- Return public content and metadata in the server response.
- Keep the rollback rule in the reverse proxy, independent of the Next.js build.
Why Next.js
Make content crawlable without turning the whole page into client-side JavaScript
Google can run JavaScript, but crawling, rendering and indexing are still separate stages. When a SPA returns an empty container, a crawler must download the application, start React and wait for data before it can see the content. Not every crawler has that capability.
In the App Router, pages and layouts are React Server Components by default. They can load data close to its source, render content on the server and contribute to the pre-rendered HTML. Their code is not added to the browser bundle. Only filters, buttons and other interactive UI need to be Client Components.
| Area | React SPA | Next.js with RSC |
|---|---|---|
| Main content | Added after the JavaScript bundle loads and API calls complete. | Included in the pre-rendered HTML produced from Server Components. |
| Data loading | Started in the browser, often after the initial render. | Completed on the server before the relevant part of the page is rendered. |
| Browser JavaScript | Owns routing, data fetching and page rendering. | Comes from the React/Next runtime and required Client Components; its size still needs to be measured. |
| Metadata | Managed globally or updated at client runtime. | Generated per page through the Metadata API. |
| Crawling | Relies more heavily on the crawler being able to run JavaScript. | Exposes content and internal links in the HTML response. |
Next.js does not receive a special ranking signal. The SEO benefit comes from controlling the response: a meaningful HTTP status, title, description, canonical URL, main content and internal links can all be present before client-side JavaScript runs.
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { cache } from 'react'
import { readResource } from '@/lib/resources'
import { ShareButton } from './share-button'
type Props = { params: Promise<{ slug: string }> }
const getResource = cache(readResource)
export async function generateMetadata({
params,
}: Props): Promise<Metadata> {
const resource = await getResource((await params).slug)
if (!resource) return {}
return {
title: resource.seoTitle,
description: resource.excerpt,
alternates: {
canonical: `/resources/${resource.slug}`,
},
openGraph: {
title: resource.seoTitle,
description: resource.excerpt,
},
}
}
export default async function Page({ params }: Props) {
const resource = await getResource((await params).slug)
if (!resource) notFound()
return (
<article>
<h1>{resource.title}</h1>
<div>{resource.content}</div>
<ShareButton />
</article>
)
}The Metadata API covers page metadata and social sharing. The sitemap.ts and robots.ts file conventions and Open Graph images keep this configuration close to the route. JSON-LD remains explicit and must describe the content users can actually see.
SEO acceptance criteria for a migrated page
Inspect the response itself, not only the DOM after hydration.
- With JavaScript disabled, the HTML still contains the H1, useful content and internal links.
- The HTTP status, canonical URL and redirects match the expected page behaviour.
- The page has its own title, description and social cards.
- JSON-LD describes visible content and passes the relevant validators.
- The sitemap points to the final URL without duplicating the legacy route.
Assessment
Choose a pilot journey, not the most visible component
The assessment starts with routes. For each one, the team records traffic, interactivity, browser dependencies, SEO value and the product cost of a regression. It then checks the HTML received without JavaScript, status codes, metadata, internal links, structured data, cookies, deep links, VITE_* variables, analytics, the service worker and global styles.
| Route | Profile | Coupling | Decision |
|---|---|---|---|
| /resources/[slug] | Public editorial content with SEO value | Low | Next.js + RSC pilot |
| /catalog | Public with interactive filters | Medium | Second migration wave |
| /app/dashboard | Authenticated with dynamic data | High | Keep in the SPA |
The baseline covers error rate, LCP, INP, CLS, TTFB, JavaScript volume and network calls. For public pages, it also includes impressions, discovered URLs, crawl errors and the HTML reported by Search Console. These signals detect regressions; they do not promise an uplift before the first route ships.
Core architecture decision
Run both frontends behind the same URLs
Next.js serves migrated routes while the SPA continues to serve everything else. The Node.js API remains the source of truth for business behaviour. A Next.js fallback forwards routes that Next.js does not yet serve. To restore an already migrated route without rebuilding the application, the reverse proxy must be able to route that URL back to the SPA.
Next.js
Migrated routes · HTML and RSC
SPA React / Vite
Remaining routes · client-side rendering
import type { NextConfig } from 'next'
const legacyOrigin = process.env.LEGACY_FRONTEND_ORIGIN
if (!legacyOrigin) {
throw new Error('LEGACY_FRONTEND_ORIGIN is required')
}
const nextConfig = {
output: 'standalone',
async rewrites() {
return {
fallback: [{
source: '/:path*',
destination: legacyOrigin + '/:path*',
}],
}
},
} satisfies NextConfig
export default nextConfigFallback rewrites run after Next.js routes, so this configuration supports incremental adoption but cannot restore a route that already exists in Next.js. The rollback rule belongs in the ingress or reverse proxy, and the team tests it with query strings, Vite assets, deep links and error pages.
Delivery
Five stages that keep migration inside the delivery flow
- 1
Write down the invariants
URLs, cookies, API contracts, analytics events and redirects form one Definition of Done for both applications. - 2
Deploy the shell
The Next.js service starts in Docker, connects to the API, exposes a health check and emits logs before it receives user traffic. - 3
Migrate one vertical slice
Migrate one public page end to end, including data, initial HTML, metadata, JSON-LD, internal links, errors, analytics, tests and rollback. - 4
Keep client-side JavaScript for interactive UI only
Pages and layouts stay on the server by default. The “use client” directive covers only search, filters or buttons that need state and browser APIs. - 5
Cut over and observe
The route goes live with monitoring, a tested proxy rule and a named rollback owner. If it regresses, the proxy sends only that route back to the SPA.
React Server Components do not make every page static. Editorial content may be pre-rendered and revalidated, while fresh data can still be rendered on demand. In both cases, the initial response needs an appropriate status code, metadata and main content.
The output: 'standalone' mode produces the deployment directory used by the Docker image. The public and .next/static directories must also be copied. With multiple replicas, caching and revalidation need an explicit coordination strategy.
Team and handover
The migration ends when the legacy SPA is gone
The consultant pairs with the team on the first slice, and short ADRs capture the decisions that affect later routes. Each review asks four questions: where does this code run, what data is cached, what HTML and JavaScript reach the browser, and how would we route traffic back to the legacy SPA?
After cutover, the team inspects the response in Search Console and compares error rate, field Core Web Vitals, latency, transferred JavaScript and API-call volume. Impressions and indexing need a longer observation window. A single Lighthouse score does not validate the migration, and rollback is tested before the legacy route is removed.
Once the SPA no longer serves user traffic, the team removes fallback rules and Vite assets, unregisters the service worker and clears its caches, then deletes the old dependencies and Docker image. Coexistence is a migration stage, not the target architecture.
Next.js does not guarantee search rankings. It restores control over the HTML, metadata and JavaScript sent to the browser. An incremental migration validates that benefit one route at a time without betting the whole production system.
Sources and reference documentation
- Migrating from Vite. Next.js documentation. The official path for adopting Next.js incrementally from an existing SPA.
- Rewrites. Next.js documentation. Routing rules for forwarding routes that have not yet moved to Next.js.
- Server and Client Components. Next.js documentation. How to place the server/client boundary in the App Router.
- Metadata and OG images. Next.js documentation. Static and dynamic metadata APIs available from Server Components.
- Metadata Files. Next.js documentation. File conventions for robots.txt, sitemap.xml, icons and social images.
- JavaScript SEO basics. Google Search Central. The crawling, rendering and indexing stages, and why server rendering helps both users and crawlers.
- How to self-host your Next.js application. Next.js documentation. Reverse proxy, caching and operational requirements for self-hosted deployments.
- Containerize a Next.js application. Docker documentation. A maintained multi-stage build and container runtime example for Next.js.