Migrating from Create React App to Next.js 12
TL;DR — CRA → Next.js 12 is a half-day move for most apps. Switch to file-based routing in
pages/. Replace<Route>declarations with file paths.process.env.REACT_APP_*becomesNEXT_PUBLIC_*. Build command changes; deployment changes; mostly mechanical, occasionally educational.
After the React 18 upgrade, the natural next question is whether to also leave Create React App behind. CRA is still maintained in April 2022 but has fallen well behind Vite for dev experience and behind Next.js for production features. Most teams I know are doing the migration this year.
This post is the migration walkthrough for a typical CRA app — react-router, Redux or similar, ~50 components — to Next.js 12.1.
Why migrate at all
CRA was the right tool from 2017 to roughly 2020. By 2022 the deficits are:
- Build perf — webpack 4 baseline, slow incremental
- No SSR / SSG out of the box
- No file-based routing — you maintain
<Route>configs by hand - No built-in image, font, or script optimization
- Slow upgrade cadence — react-scripts 5.0 came out late 2021, still on webpack 5
Next.js 12 fixes all of those + adds:
- SWC compiler (Rust-based) — orders of magnitude faster than Babel
- File-based routing
- API routes (poor man’s BFF)
- next/image with automatic optimization
- Middleware (covered in Apr 22 post)
- First-class Vercel deployment (zero config) + works fine self-hosted
The trade-off: you’re now using a framework with its own opinions. If those opinions clash with yours (uncommon for SPAs), it’s friction.
Step 1: Install Next.js
npm install next@12 react@18 react-dom@18
npm install -D @types/node @types/react@18 @types/react-dom@18 typescript
Add to package.json scripts:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
}
}
Remove react-scripts from dependencies. Delete the CRA-generated public/index.html (Next.js manages the document). Keep public/ for static assets like favicons; they serve from the root.
Step 2: Restructure into pages
CRA layout:
src/
├── App.tsx (BrowserRouter + Routes)
├── pages/
│ ├── Home.tsx
│ ├── About.tsx
│ └── UserProfile.tsx
└── components/
└── ...
Next.js layout:
pages/
├── _app.tsx (App wrapper — equivalent of CRA's App.tsx layout chrome)
├── _document.tsx (optional — for custom <html>/<head>)
├── index.tsx (formerly Home.tsx)
├── about.tsx
└── users/
└── [id].tsx (formerly UserProfile.tsx with route param)
src/
└── components/ (your existing components stay here)
pages/ files become routes automatically. [id].tsx is a dynamic route — accessible as /users/42.
Step 3: Replace React Router
For each <Route>, move the component into a file. Replace:
// CRA
import { useParams, useNavigate, Link } from 'react-router-dom';
function UserProfile() {
const { id } = useParams();
const navigate = useNavigate();
return (
<>
<Link to="/">Home</Link>
<h1>User {id}</h1>
</>
);
}
With:
// Next.js
import { useRouter } from 'next/router';
import Link from 'next/link';
export default function UserProfile() {
const router = useRouter();
const { id } = router.query;
return (
<>
<Link href="/"><a>Home</a></Link>
<h1>User {id}</h1>
</>
);
}
Two notes:
- Next.js’s
<Link href>requires an<a>child in 12.x. (Next.js 13 drops this requirement.) router.queryisstring | string[] | undefined— narrow before use.
Programmatic navigation: router.push('/users/42') (analogous to useNavigate).
Step 4: Layout via _app.tsx
CRA’s <App> typically held the layout chrome (header, navigation, footer). In Next.js this moves to _app.tsx:
// pages/_app.tsx
import type { AppProps } from 'next/app';
import Layout from '../src/components/Layout';
import '../src/styles/global.css';
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
Global CSS imports go here — Next.js disallows global CSS imports anywhere else (you can still use CSS modules per-component).
Step 5: Env vars
CRA: REACT_APP_API_URL exposed to the browser.
Next.js: NEXT_PUBLIC_API_URL exposed to the browser. Vars without the NEXT_PUBLIC_ prefix are server-only (only accessible in getServerSideProps, getStaticProps, API routes).
Mass-rename:
sed -i.bak 's/REACT_APP_/NEXT_PUBLIC_/g' .env .env.local .env.production
Plus rename anywhere they’re read in code:
grep -rl REACT_APP_ src/ | xargs sed -i.bak 's/REACT_APP_/NEXT_PUBLIC_/g'
The server-only vars (DB passwords, etc.) should drop the REACT_APP_ prefix entirely — they shouldn’t have been browser-visible in CRA either.
Step 6: Static assets
CRA: import logo from './logo.png' and use the resulting URL.
Next.js: same works, but for most cases use the /public/ directory and reference as /logo.png. For next/image benefits, use the component:
import Image from 'next/image';
<Image src="/logo.png" alt="logo" width={200} height={50} />
Automatic optimization. More on this in Apr 25’s post.
Step 7: Build + deploy
CRA: npm run build → build/ dir of static files. Deploy to any static host (S3, Netlify, CRA-friendly Vercel template).
Next.js: npm run build → .next/ dir of server + static bundles. Deploy:
- Vercel: zero config;
vercelCLI handles everything - Self-hosted:
npm run startruns a Node server (next/server.js) - Fully static export (no SSR features used):
next exportoutputsout/for any static host
If your app is pure-client, next export keeps the static-deploy story. If you start using SSR/ISR, you need a Node host.
Step 8: Test the migration
Critical paths to validate:
- Routing: every URL that worked in CRA still works in Next.js
- Dynamic routes:
/users/:idstyle URLs render the right page - Browser back/forward
- Refresh on any URL (not just
/) — CRA needed a server rewrite; Next.js doesn’t, but verify - Direct links from email/SMS land on the right page
- Loading states: anything that used route-based code-splitting
What breaks
window references at module top-level. Next.js renders on the server; window is undefined. Wrap in useEffect or check typeof window !== 'undefined'.
Inline <style> tags. Allowed in CRA, restricted by Next.js for hydration safety. Move to CSS modules.
Code that depends on CSS import order. Bundler differences sometimes shift CSS order. Audit cascade-sensitive styles.
@import "./Component.module.css". Use CSS Modules properly (import styles from './Component.module.css').
Custom webpack config. CRA + craco vs Next.js custom webpack are different mechanisms. Migrate via next.config.js’s webpack: (config) => { ... }.
Real timing
For our admin app (~50 components, react-router-dom v6, Redux Toolkit, TS):
- Initial scaffolding + restructure: 2 hours
- Route migration: 1 hour
- Env var rename + audit: 30 min
- Style import refactor: 1 hour
- Testing + fixes: 2 hours
- Total: ~half a day
The biggest time sink was unwinding three components that used window at module load. Standard CRA pattern, broken under SSR.
What you gain in week 1
- 3–5× faster dev rebuilds (SWC vs Babel)
- Production builds with code splitting per route, automatically
- File-based routing — no more route config to maintain
- API routes for the small server-side glue (CSRF tokens, OAuth callbacks)
next/image,next/font,next/scriptfor optimized assets
Common Pitfalls
useRouter returns empty query on first render. Next.js statically generates pages by default; query is hydrated on the client. Don’t trust router.query in the initial render — guard with a check or use getServerSideProps.
Importing CSS in component files. Only _app.tsx can import global CSS. Components use CSS modules.
Forgetting the <a> child in <Link>. Required in Next 12.x. Will warn in dev.
Defaulting to getServerSideProps for everything. Most pages should be getStaticProps + ISR. SSR has a cost. More on Wednesday.
Treating pages/api/* as a real backend. They’re useful for small things. For real backend logic, use a real backend.
Missing getInitialProps migration. It’s deprecated in favor of getServerSideProps / getStaticProps. Migrate any usage at the same time.
Wrapping Up
CRA → Next.js 12 is mechanical for the bulk of the work, educational for the rest. The build speed alone earns the move; the framework features are bonus. Monday: Next.js routing patterns — dynamic, catch-all, nested layouts, and the conventions you’ll use daily.