Why Migrate from CRA?
Create React App was hitting hard limits on a growing platform:
Performance Issues:
- Deploy times: 15+ minutes for production builds
- Bundle size: 3.2MB gzipped with no code splitting
- No SSR/SSG: Poor SEO and slow initial page loads
- Limited optimization: CRA abstracts webpack, limiting control
Developer Experience:
- Slow hot reload in development
- Single build target (can't optimize differently per route)
- Growing complexity with custom webpack configs via CRACO
Business Impact:
- Slower feature deployment → reduced iteration speed
- Poor Core Web Vitals → lower search rankings
- High bounce rates on slow initial loads
Migration Strategy
Phase 1: Analysis & Planning (Week 1-2)
Dependency Audit
# Identified CRA-specific packages to remove
npm ls react-scripts
npm ls craco
Breaking Changes Inventory:
- Routing:
react-router-dom→ Next.js file-based routing - API calls: Client-side only → Hybrid client/server
- Environment variables: Custom names →
NEXT_PUBLIC_prefix - CSS handling: CSS modules → CSS modules + Tailwind optimization
Migration Path Decision: Chose incremental migration over big-bang rewrite:
- Set up Next.js alongside CRA
- Migrate routes one at a time
- Test each route in isolation
- Switch traffic after validation
Phase 2: Next.js Turbo Setup (Week 3)
Initial Setup
npx create-next-app@latest --typescript --tailwind --app
Turbo Configuration
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"outputs": []
}
}
}
Monorepo Structure
apps/
web/ # Next.js app
admin/ # Admin dashboard (also Next.js)
packages/
ui/ # Shared components
utils/ # Shared utilities
config/ # Shared configs (eslint, typescript)
Phase 3: Routing Migration (Week 4-6)
Before: React Router
// CRA routing
import { BrowserRouter, Routes, Route } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/posts/:slug" element={<Post />} />
</Routes>
</BrowserRouter>
);
}
After: Next.js App Router
// app/page.tsx
export default function Home() {
return <HomePage />;
}
// app/dashboard/page.tsx
export default function Dashboard() {
return <DashboardPage />;
}
// app/posts/[slug]/page.tsx
export default function Post({ params }: { params: { slug: string } }) {
return <PostPage slug={params.slug} />;
}
Dynamic Route Handling
// Type-safe params with Next.js 14+
type PageProps = {
params: { slug: string };
searchParams: { [key: string]: string | string[] | undefined };
};
export default async function Page({ params, searchParams }: PageProps) {
// Server-side data fetching
const data = await fetchData(params.slug);
return <Content data={data} />;
}
Phase 4: Component Migration (Week 7-9)
Challenge: CRA components assumed client-side only.
Solution: Separate client and server components.
// Before: All client-side
function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}, []);
return <div>{user?.name}</div>;
}
// After: Server component fetches, client handles interactivity
// app/profile/page.tsx (Server Component)
async function UserProfile() {
const user = await fetchUser(); // Server-side fetch
return <UserProfileClient user={user} />;
}
// components/user-profile-client.tsx
'use client';
function UserProfileClient({ user }: { user: User }) {
const [isEditing, setIsEditing] = useState(false);
// Client-side interactivity only
return (
<div>
{user.name}
<button onClick={()=> setIsEditing(true)}>Edit</button>
</div>
);
}
Pattern for Browser APIs
'use client';
import { useEffect, useState } from 'react';
export function BrowserOnlyComponent() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
// Safe to use window, document, etc.
return <div>{window.innerWidth}</div>;
}
Phase 5: API Routes (Week 10-11)
Before: Separate Express Backend
// Express server
app.get('/api/posts', async (req, res) => {
const posts = await db.query('SELECT * FROM posts');
res.json(posts);
});
After: Next.js API Routes
// app/api/posts/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const posts = await db.query('SELECT * FROM posts');
return NextResponse.json(posts);
}
export async function POST(request: Request) {
const body = await request.json();
const post = await db.insert('posts', body);
return NextResponse.json(post, { status: 201 });
}
Server Actions for Mutations
// app/actions/posts.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title');
const content = formData.get('content');
const post = await db.posts.create({
data: { title, content }
});
revalidatePath('/posts');
return { success: true, post };
}
Phase 6: State Management (Week 12)
Challenge: Redux configured for client-side only.
Solution: Redux with SSR support + Server State separation.
// lib/store.ts
import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
export const makeStore = () => {
const store = configureStore({
reducer: {
// your reducers
},
});
setupListeners(store.dispatch);
return store;
};
export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore['getState']>;
// components/StoreProvider.tsx
'use client';
import { useRef } from 'react';
import { Provider } from 'react-redux';
import { makeStore, AppStore } from '../lib/store';
export default function StoreProvider({
children,
}: {
children: React.ReactNode;
}) {
const storeRef = useRef<AppStore>();
if (!storeRef.current) {
storeRef.current = makeStore();
}
return <Provider store={storeRef.current}>{children}</Provider>;
}
For Server State: Migrated to React Query for data fetching.
// lib/queries.ts
export function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json())
});
}
Phase 7: Environment Variables (Week 13)
CRA Convention
REACT_APP_API_URL=https://api.example.com
REACT_APP_ENV=production
Next.js Convention
# Public (exposed to browser)
NEXT_PUBLIC_API_URL=https://api.example.com
# Private (server-side only)
DATABASE_URL=postgresql://...
SECRET_KEY=...
Migration Script
# Automated rename
sed -i '' 's/REACT_APP_/NEXT_PUBLIC_/g' .env.local
Performance Improvements
Build Times
- Before: 15 minutes average
- After: 6 minutes average
- Improvement: 60% reduction
Bundle Size
- Before: 3.2MB gzipped
- After: 180KB initial bundle (with code splitting)
- Improvement: 94% reduction in initial load
Core Web Vitals
- LCP: 3.8s → 1.2s
- FID: 180ms → 45ms
- CLS: 0.25 → 0.05
Time to Interactive
- Before: 4.5s on 3G
- After: 1.8s on 3G
Common Gotchas & Solutions
1. Hydration Mismatches
Problem: Server renders different HTML than client.
// ❌ Bad: Different on server vs client
function Component() {
return <div>{Date.now()}</div>;
}
// ✅ Good: Consistent or client-only
'use client';
function Component() {
const [time, setTime] = useState<number | null>(null);
useEffect(() => {
setTime(Date.now());
}, []);
return <div>{time || 'Loading...'}</div>;
}
2. CSS Module Ordering
Problem: CSS loads in different order causing style conflicts.
Solution: Use Tailwind with CSS modules scoped to components.
// Use CSS modules for component-specific styles
import styles from './component.module.css';
// Use Tailwind for utilities
<div className={`${styles.container} flex items-center`}>
3. Image Optimization
Before: <img> tags everywhere.
After: Next.js Image component.
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority
placeholder="blur"
blurDataURL="data:image/..."
/>
4. Absolute Imports
Before: CRA's jsconfig.json
{
"compilerOptions": {
"baseUrl": "src"
}
}
After: Next.js tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@/components/*": ["components/*"]
}
}
}
Team Coordination
Managing 5 Engineers Across Timezones:
- Clear ownership: Each engineer owned 2-3 routes
- Daily async updates: Written updates in Slack
- Weekly sync: Video call to unblock issues
- Shared documentation: Notion docs for patterns
- Code review SLA: 24-hour review turnaround
Communication Patterns:
- Migration guide in repo wiki
- Video walkthroughs of complex patterns
- Pair programming for tricky migrations
Rollout Strategy
Week 14-15: Staged Deployment
- Internal testing: 100% of traffic to team
- Beta users: 10% of production traffic
- Gradual rollout: 25% → 50% → 100%
- Rollback plan: Keep CRA deployment ready
Feature Flags for Safety
// lib/features.ts
export const features = {
useNextJsRouting: process.env.NEXT_PUBLIC_USE_NEW_ROUTING === 'true'
};
Lessons Learned
1. Start with App Router
We migrated to Pages Router first, then App Router. Should have gone straight to App Router to avoid double migration.
2. Incremental Migration is Worth It
Rewriting everything at once would have been risky. Route-by-route migration kept the app stable.
3. Server Components Change Everything
Understanding when to use 'use client' took time. Default to server components, opt into client only when needed.
4. TypeScript is Essential
Type safety caught routing bugs before runtime. The migration would have been much harder without TypeScript.
5. Monitoring During Migration
Set up split metrics for CRA vs Next.js routes to compare performance in real-time.
Would I Recommend This Migration?
Yes, if:
- You need better performance and SEO
- Your build times are slowing down deploys
- You want better developer experience
- You're building for scale
No, if:
- Your CRA app is small and performance is fine
- You don't have time for 3-4 month migration
- Your team isn't comfortable with SSR concepts
Bottom line: Next.js with Turbo transformed our deployment velocity and user experience. The 60% reduction in deploy times alone was worth the investment.
