Migrating to Next.js: From CRA, Gatsby, and Vite
A practical migration guide for teams moving from Create React App, Gatsby, or Vite to Next.js App Router — with a phased approach that keeps the existing app running.
Nextcraft Agency
Why Migrate to Next.js
The most common migration sources are:
Create React App (CRA): Officially deprecated by the React team. No SSR, slow builds, no code splitting beyond route-level.
Gatsby: SSG-focused with a complex plugin system. GraphQL data layer adds overhead for apps that don't need it. Build times at scale became painful.
Vite + React: Excellent DX for pure SPAs, but no SSR, no API routes, SEO requires bolt-ons. As apps grow, the absence of server-side features becomes limiting.
Pages Router Next.js → App Router: Not a migration from outside the ecosystem, but a significant upgrade path with many of the same patterns.
Migration Strategy
Don't rewrite the app. Migrate it incrementally.
The correct approach: install Next.js alongside the existing app, move pages one at a time, and run both in parallel until the migration is complete.
Phase 0: Assessment (1 Week)
Before writing code, audit:
-
Route inventory: List every page/route. Note which ones are static, which fetch data server-side, which are auth-gated.
-
Data fetching patterns: REST APIs, GraphQL, direct DB calls, file reads?
-
State management: Redux, Zustand, Context, Jotai? Server Components can't use most state management libraries.
-
Dependencies: Which npm packages are browser-only? They'll need
'use client'wrappers or alternatives. -
CSS approach: CSS Modules, CSS-in-JS, Tailwind, styled-components? CSS-in-JS libraries (styled-components, Emotion) require extra configuration for RSC.
-
Auth: Session storage method? Cookie-based sessions work naturally with Next.js middleware.
Produce a migration map: which pages are easy (static, no client state), which are hard (heavy client-side state, browser-only APIs).
Migrating from Create React App
CRA apps are SPAs — all rendering happens client-side. The migration unlocks SSR and eliminates the blank-page-on-first-load problem.
Step 1: Install Next.js
npm install next react react-dom
npm install -D @types/node
Step 2: Create the Next.js configuration
// next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
// If your CRA app was at a sub-path:
// basePath: '/app',
}
export default config
Step 3: Move the entry point
CRA's src/index.tsx is replaced by app/layout.tsx:
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}
Step 4: Move pages one at a time
Start with the simplest page — the marketing homepage or a static about page.
// app/page.tsx — replaces src/pages/Home.tsx
export default function HomePage() {
return <main>...</main>
}
For pages with data fetching, convert from useEffect + useState to async Server Components:
Before (CRA pattern):
function ProductsPage() {
const [products, setProducts] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/products')
.then(r => r.json())
.then(data => { setProducts(data); setLoading(false) })
}, [])
if (loading) return <Spinner />
return <ProductList products={products} />
}
After (Next.js Server Component):
async function ProductsPage() {
const products = await db.query.products.findMany()
return <ProductList products={products} />
}
No loading state needed — data is fetched server-side and streamed. Add a loading.tsx file if you want a skeleton UI during navigation.
Step 5: Handle client-only code
Components that use browser APIs (window, document, localStorage) or hooks that depend on browser state must be Client Components:
'use client'
import { useLocalStorage } from '@/hooks/useLocalStorage'
export function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light')
// ...
}
Wrap third-party libraries that aren't RSC-compatible:
// components/AnalyticsProvider.tsx
'use client'
import { PostHogProvider } from 'posthog-js/react'
export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
return <PostHogProvider client={posthog}>{children}</PostHogProvider>
}
Migrating from Gatsby
Gatsby migrations have one key complexity: the GraphQL data layer. If your content comes from a CMS (Contentful, Sanity, Strapi), the Next.js version will query the CMS API directly instead of through Gatsby's GraphQL.
Replace Gatsby's graphql with direct fetches
Before (Gatsby):
export const query = graphql`
query BlogPost($slug: String!) {
markdownRemark(fields: { slug: { eq: $slug } }) {
frontmatter { title, date }
html
}
}
`
export default function BlogPost({ data }) {
return <article dangerouslySetInnerHTML={{ __html: data.markdownRemark.html }} />
}
After (Next.js):
import { getPostBySlug } from '@/lib/content'
export default async function BlogPost({ params }) {
const post = await getPostBySlug(params.slug)
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
)
}
Replace Gatsby's gatsby-image with next/image
// Before
import { GatsbyImage, getImage } from 'gatsby-plugin-image'
<GatsbyImage image={getImage(data.file)} alt="..." />
// After
import Image from 'next/image'
<Image src="/path/to/image.jpg" alt="..." width={800} height={600} />
Replace Gatsby plugins with Next.js equivalents
| Gatsby plugin | Next.js equivalent |
|---|---|
gatsby-plugin-image | next/image |
gatsby-plugin-react-helmet | export const metadata |
gatsby-plugin-sitemap | app/sitemap.ts |
gatsby-plugin-robots-txt | app/robots.ts |
gatsby-plugin-mdx | @next/mdx or next-mdx-remote |
Migrating from Vite
Vite migrations are similar to CRA — the main work is converting from SPA patterns to server-side rendering.
Replace Vite's main.tsx entry with app/layout.tsx. Move pages from the router config to file-based routing. Convert data fetching from client-side effects to Server Components.
One Vite-specific consideration: Vite's import.meta.env environment variables. Replace them with process.env and add NEXT_PUBLIC_ prefix for client-exposed vars.
// Before (Vite)
const apiUrl = import.meta.env.VITE_API_URL
// After (Next.js)
const apiUrl = process.env.NEXT_PUBLIC_API_URL
Common Migration Pitfalls
1. Making everything a Client Component. The temptation is to add 'use client' to everything that touches state. Push client components as deep as possible to keep the tree server-rendered.
2. Forgetting to handle hydration mismatches. Code that reads browser-only values (window.innerWidth, navigator.language) at render time will mismatch between server and client. Use useEffect or dynamic imports with ssr: false for browser-only content.
3. Breaking SEO during the migration. Test your pages in Google's Rich Results Test during migration. A page that moved from static to fully client-side rendered will lose rankings.
4. Migrating all at once. The phased approach works. The big-bang rewrite approach almost always takes 2x longer than estimated and ships bugs.
A well-run CRA → Next.js migration for a medium-size application takes 4–8 weeks with a senior engineer. The performance and SEO dividends pay back within the first month of organic traffic data.
Deepen your knowledge
Master your stack.
Explore more technical guides or start a direct conversation with our team.