All guides
Engineering15 min read

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.

NC

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:

  1. Route inventory: List every page/route. Note which ones are static, which fetch data server-side, which are auth-gated.

  2. Data fetching patterns: REST APIs, GraphQL, direct DB calls, file reads?

  3. State management: Redux, Zustand, Context, Jotai? Server Components can't use most state management libraries.

  4. Dependencies: Which npm packages are browser-only? They'll need 'use client' wrappers or alternatives.

  5. CSS approach: CSS Modules, CSS-in-JS, Tailwind, styled-components? CSS-in-JS libraries (styled-components, Emotion) require extra configuration for RSC.

  6. 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

code
npm install next react react-dom
npm install -D @types/node

Step 2: Create the Next.js configuration

code
// 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:

code
// 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.

code
// 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):

code
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):

code
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:

code
'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:

code
// 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):

code
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):

code
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

code
// 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 pluginNext.js equivalent
gatsby-plugin-imagenext/image
gatsby-plugin-react-helmetexport const metadata
gatsby-plugin-sitemapapp/sitemap.ts
gatsby-plugin-robots-txtapp/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.

code
// 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.