Migrate from Create React App to Next.js 16: A Practical Migration Playbook (2026)

A practical playbook for migrating Create React App to Next.js 16 in 2-5 days: route conversion, env vars, testing, and the gotchas that break builds.

CRA to Next.js 16 Migration Guide (2026)

Updated: July 11, 2026

Migrating from Create React App (CRA) to Next.js 16 typically takes 2–5 engineering days for a small-to-medium app, with most of that time spent rewriting React Router routes into the App Router file structure and swapping REACT_APP_ environment variables for NEXT_PUBLIC_. Since the React team officially sunsetted CRA in February 2025, this migration isn't really optional anymore for teams that want ongoing React 19 support, modern bundling, and a maintained toolchain. This playbook walks through the exact steps I use on real client migrations.

  • Create React App was officially deprecated by the React team on February 14, 2025. The recommended replacements are Next.js, React Router (formerly Remix), or a Vite SPA.
  • A typical CRA to Next.js 16 migration takes 2–5 days for a 20–50 component app; large apps with heavy React Router usage can stretch to 2–3 weeks.
  • You don't need to rewrite every component as a Server Component. Add 'use client' to the top of files that use hooks, event handlers, or browser APIs and ship.
  • Rename every REACT_APP_FOO variable to NEXT_PUBLIC_FOO; server-only secrets should drop the prefix entirely so they never reach the client bundle.
  • React Router v6 routes map to the App Router directly: <Route path="/users/:id"> becomes app/users/[id]/page.tsx.
  • Next.js 16 ships Turbopack as the default bundler (replacing Webpack), which speeds up dev startup by roughly 5–10× for medium apps.

Why was Create React App deprecated?

The React team published the official "Sunsetting Create React App" blog post on February 14, 2025, marking CRA as deprecated and steering new projects toward frameworks like Next.js and React Router, or toward a Vite-based SPA setup. The reasoning was blunt: CRA hadn't received a meaningful release since 2022, its Webpack 5 configuration was frozen in a pre-Server-Components world, and every mid-sized React team was already ejecting or replacing react-scripts with something else.

The practical consequences show up quickly. react-scripts pins old versions of PostCSS, ESLint, and Babel that conflict with the modern npm ecosystem. It doesn't understand React 19's React Compiler, has no first-class TypeScript project references support, and produces bundles that are 20–40% larger than what Turbopack or Vite emit on the same source. If your team keeps trying to upgrade dependencies and something breaks weekly, CRA is the reason. Next.js 16 solves this by owning the entire toolchain (compiler, bundler, dev server, and production runtime), so you upgrade one dependency and everything moves together.

How long does the migration take?

In my experience, the migration effort scales predictably with three variables: number of routes, amount of client-side data fetching, and how deeply React Router is wired into the app. Here are the ballpark ranges I quote clients before starting.

App sizeEffort (engineer-days)Main risk
Tiny (under 10 routes, no auth)1–2 daysCSS Module edge cases
Small (10–30 routes, simple auth)2–5 daysRoute params and query strings
Medium (30–80 routes, dashboards)1–2 weeksReact Router loaders and nested layouts
Large (80+ routes, heavy Redux)2–4 weeksState hydration and SSR compatibility
Enterprise (custom Webpack, microfrontends)4–8 weeksCustom loaders, module federation

These estimates assume one senior engineer working full-time. Split the work across a team and you'll pay roughly 30% overhead for coordination and code review, especially around routing decisions. My rule of thumb: budget one afternoon per twenty React Router routes for the mechanical route rewrite, plus one full day per external integration (Auth0, Stripe, Sentry, feature flags) that needs its SDK swapped from a browser-only build to a Next.js-compatible one. If you're already familiar with the Pages Router to App Router migration playbook, the mental model transfers cleanly. CRA maps closer to the App Router than to the Pages Router.

Prep work: audit your CRA app first

Don't start deleting react-scripts on day one. Honestly, this is the step teams skip most often, and I've seen it cost them a week of surprise debugging. Spend two hours running through this checklist and writing down what you find. Every hour of audit saves roughly four hours of firefighting later.

  1. Node version. Next.js 16 requires Node.js 20.9 or newer. Bump your CI and local .nvmrc before touching code.
  2. React version. If you're on React 17 or older, upgrade to React 18 first while still on CRA. Don't combine React major upgrades with the framework migration; you can't debug two moving targets at once.
  3. Environment variables. Grep for REACT_APP_ across the repo and note which are truly client-safe (public keys, feature flags) versus which were leaked to the client accidentally. This is a good time to audit for secrets in the bundle.
  4. Direct DOM access. Search for window., document., localStorage, and navigator.. Every hit becomes a client component or a useEffect block after migration.
  5. React Router version. React Router v5 needs to be upgraded to v6 first, then converted to App Router. Skipping the v5 to v6 step and going straight to Next.js triples the confusion.
  6. CSS strategy. Are you using CSS Modules, styled-components, Emotion, or Tailwind? Each has its own migration path. Tailwind is easiest; styled-components needs a Next.js-specific SSR registry.
  7. Custom Webpack config. If you've ejected or use craco / react-app-rewired, list every custom loader. Turbopack doesn't support arbitrary Webpack plugins, so each one needs a replacement or removal decision.

The step-by-step migration playbook

So, let's dive in. This is the exact sequence I run on client engagements. Each step is small enough to commit independently, so you can pause and ship at any point without leaving the codebase broken. The playbook assumes you've already audited the app and started a fresh Next.js 16 project as the migration target.

Step 1: Scaffold a Next.js 16 target project

npx create-next-app@latest my-app-next \
  --typescript \
  --app \
  --tailwind=false \
  --src-dir=false \
  --import-alias="@/*"

cd my-app-next
npm install

Answer yes to the App Router prompt and Turbopack. You now have a working Next.js 16 skeleton at my-app-next/. Copy your CRA package.json dependencies into this new project one section at a time. Do not paste the whole file. Skip react-scripts, @testing-library/jest-environment-jsdom, and anything Webpack-specific.

Step 2: Migrate environment variables

CRA exposes any variable prefixed with REACT_APP_ to the client via process.env. Next.js does the same with NEXT_PUBLIC_. Run this find-and-replace across your source tree, then move server-only secrets to unprefixed variables:

// Before (CRA)
const apiUrl = process.env.REACT_APP_API_URL;
const stripeKey = process.env.REACT_APP_STRIPE_PUBLIC_KEY;
const dbSecret = process.env.REACT_APP_DB_PASSWORD; // BUG: leaked to client!

// After (Next.js 16)
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
const stripeKey = process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY;
const dbSecret = process.env.DB_PASSWORD; // server-only, safe

For deeper type safety on this, see the type-safe environment variables guide using @t3-oss/env-nextjs and Zod. It catches the exact class of bugs that CRA's runtime-only process.env ships to production.

Step 3: Convert React Router routes to file-based routing

This is usually the longest step. Every <Route> becomes a folder under app/, and every useParams hook becomes a params prop. Here's a direct mapping:

// Before (React Router v6, src/App.tsx)
import { Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import UserProfile from './pages/UserProfile';

export default function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/users/:id" element={<UserProfile />} />
    </Routes>
  );
}
// After (Next.js 16, App Router)
// app/page.tsx
export default function HomePage() {
  return <Home />;
}

// app/users/[id]/page.tsx
export default async function UserProfilePage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return <UserProfile userId={id} />;
}

Two things to note. First, params is now a Promise, which was a breaking change in Next.js 15 that carries into 16. I hit this exact bug shipping a client dashboard last spring — the runtime error is easy to fix but only shows up on first request, so tests missed it. Second, you no longer need <BrowserRouter>, <Link> from React Router, or the useNavigate hook. Import Link from next/link and useRouter from next/navigation instead.

Step 4: Mark client components with 'use client'

Every component in Next.js 16 is a Server Component by default. Add the 'use client' directive at the top of any file that uses useState, useEffect, event handlers, or browser APIs:

'use client';

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Step 5: Migrate global styles and CSS Modules

Global CSS imports work in app/layout.tsx the same way they worked in index.tsx. CSS Modules also work identically; the .module.css filename convention is preserved. The main change is where you import global styles:

// app/layout.tsx
import './globals.css';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

If you use styled-components or Emotion, you need to set up the SSR registry pattern documented in the Next.js docs. Tailwind users have the simplest path since the create-next-app flag sets everything up.

Step 6: Rework the testing setup

CRA shipped with Jest and React Testing Library preconfigured through react-scripts test. Next.js doesn't preconfigure a test runner, so you pick one. Vitest is the current default choice because it's 3–5× faster and works with the same TypeScript config Next.js already produces. The full setup is covered in the Testing Next.js App Router guide with Vitest and Playwright.

Step 7: Update image and asset imports

CRA lets you import logo from './logo.svg' anywhere. Next.js does too, but you should replace <img> tags with the next/image component for automatic optimization. Move everything from public/ into the Next.js public/ directory. The URL paths stay the same.

Should you migrate to Next.js or Vite?

This is the question I get on every kickoff call. Both are valid CRA replacements, and the React team explicitly recommends both. Here's the honest comparison. I have no incentive to talk you out of Vite if that's the better fit.

CriterionNext.js 16Vite + React
Dev server startup~1s (Turbopack)~0.3s (esbuild + Rollup)
SSR / SSG out of the boxYesNo (need Vike or custom setup)
File-based routingBuilt-in (App Router)Requires TanStack Router or React Router
Server ComponentsYesExperimental
Deployment targetsVercel, self-host, Docker, CloudflareAny static host + separate API
Migration effort from CRA2–5 days (medium)1–3 days (medium)
Best forFull-stack, SEO-heavy, marketing + appInternal dashboards, pure SPAs, embedded apps

My default recommendation: if your app has any public-facing pages, has SEO requirements, or fetches data on the server, go to Next.js. If your app is a pure authenticated dashboard behind a login wall with no SEO needs, Vite is genuinely simpler and faster to boot. The Next.js vs Astro comparison covers a third option for content-heavy sites, and the Pages Router to App Router migration playbook is worth reading if you already have any Next.js code on the older router.

Common gotchas that break the build

These are the failures I see most often mid-migration. Fixing them at the audit stage prevents 80% of the "wait, why doesn't this work" moments on day three. See the official Next.js upgrade documentation for the full compatibility matrix.

window is not defined

Server Components render on the server, where window does not exist. If a library assumes browser globals at import time (many charting libraries, some UI kits, and older analytics SDKs), the build fails immediately. Fix by dynamic-importing with ssr: false:

'use client';
import dynamic from 'next/dynamic';

const Chart = dynamic(() => import('./HeavyChart'), { ssr: false });

Hydration mismatches

If your app rendered different HTML on server vs client (timestamps, random IDs, feature flags evaluated per user), you get a hydration error on first load. The fix is to defer that content to a client-only useEffect or use the suppressHydrationWarning attribute on the specific element.

process.env is undefined at build time

CRA inlines every REACT_APP_ variable at build. Next.js does the same for NEXT_PUBLIC_ but only for values available at build time. If you build in Docker without passing env vars into the build stage, you get undefined in the client bundle. Set them at build time, not just runtime.

useRouter from react-router-dom is imported

The auto-import in your IDE will happily pull the wrong useRouter. After you uninstall react-router-dom, delete it from your imports and re-import from next/navigation. Run a repo-wide grep for from 'react-router-dom' and delete every hit.

Deployment and cleanup

Once tests pass locally, deploy the new Next.js app to a preview URL (Vercel, Netlify, or your own infrastructure) and compare it side-by-side with the CRA production build. Watch for these regressions:

  • SEO metadata. CRA relied on react-helmet-async for per-route title and meta tags. In Next.js, use the built-in Metadata API. The Next.js SEO guide covers the full pattern.
  • Bundle size. Turbopack produces smaller bundles than Webpack 5, but you can verify with the Next.js Bundle Analyzer to confirm no library got double-imported during the migration.
  • Analytics events. Client-side route changes fire differently in Next.js. If you tracked page views with a global route listener, wrap the logic in a small client component that uses usePathname from next/navigation.
  • Old service worker. CRA registered a service worker by default. If yours ever went to production, users may have a cached SW that intercepts requests. Ship an unregister script in the new app for the first week.

Once the Next.js app is stable, delete react-scripts, craco, or any custom Webpack config from your repo. Uninstall react-router-dom, @types/react-router-dom, and CRA-specific ESLint plugins. Your node_modules will shrink by 100–200 MB, and CI installs will speed up noticeably.

Frequently Asked Questions

Is Create React App officially deprecated?

Yes. The React team published "Sunsetting Create React App" on February 14, 2025, marking CRA as deprecated. It still runs, but receives no security updates, cannot be used with modern React 19 features, and is no longer recommended for new projects by the React team or the wider ecosystem.

Can I migrate from CRA to Next.js without changing my components?

Mostly, yes. Component code that uses standard React hooks and JSX runs unchanged after you add the 'use client' directive to files that need it. The changes are concentrated in routing, environment variables, and any custom Webpack setup, not in the components themselves.

Do I need to rewrite Redux or Zustand to migrate?

No. Both Redux Toolkit and Zustand work with Next.js 16 as long as you mount the provider inside a client component. Wrap your <Provider> in a small 'use client' wrapper and place it in your root layout. Data fetching stays inside client components initially; you can migrate to Server Components later.

Is Next.js faster than Create React App in production?

Almost always, yes. Next.js 16 produces 20–40% smaller JavaScript bundles than CRA thanks to Turbopack and automatic code splitting per route. Server Components eliminate additional client JavaScript for non-interactive pages, and Server-Side Rendering improves Largest Contentful Paint on cold loads by 30–50% for content-heavy pages.

How do I handle React Router loaders during migration?

React Router v6.4+ loaders map to async Server Components in the App Router. A loader that fetches data before render becomes an async function at the top of your page.tsx. If you use client-side data fetching with React Query or SWR, keep the loading logic as-is inside a client component and skip Server Components for that route.

Should I upgrade React Router first, or migrate straight to Next.js?

If you're on React Router v5, upgrade to v6 first while still on CRA. The API differences between v5 and v6 are large enough that combining them with a framework migration creates confusing bugs. If you're already on v6, you can go straight to Next.js.

Jasmine Patel
About the Author Jasmine Patel

Web framework specialist comparing Next.js to everything else so you don't have to. Migrates teams off legacy stacks for fun.