
Cra To Next Migration
- 586 installs
- 11 repo stars
- Updated January 30, 2026
- vercel-labs/migration-skills
Comprehensive guide for migrating Create React App (CRA) projects to Next.
About
Comprehensive guide for migrating Create React App (CRA) projects to Next.js. Use when migrating a CRA app, converting React Router to file-based routing, or adopting Next.js patterns like Server Components, App Router, or image optimization. Comprehensive migration guide for converting Create React App projects to Next.js, covering routing, data fetching, components, styling, and deployment. Contains 148 rules across 17 categories, prioritized by migration impact. After a successful migration the application should work the same as it did before the migration.
- # CRA to Next.js Migration Guide
- Reference these guidelines when:
- Migrating an existing CRA application to Next.js
- Converting React Router routes to file-based routing
- Adopting Server Components in a client-heavy app
Cra To Next Migration by the numbers
- 586 all-time installs (skills.sh)
- +18 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #595 of 2,184 Testing & QA skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 27, 2026 (Skillselion catalog sync)
cra-to-next-migration capabilities & compatibility
- Capabilities
- # cra to next.js migration guide · reference these guidelines when: · migrating an existing cra application to next.js · converting react router routes to file based rou
- Use cases
- documentation
What cra-to-next-migration says it does
Comprehensive guide for migrating Create React App (CRA) projects to Next.js. Use when migrating a CRA app, converting React Router to file-based routing, or adopting Next.js patterns like Server Comp
npx skills add https://github.com/vercel-labs/migration-skills --skill cra-to-next-migrationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 586 |
|---|---|
| repo stars | ★ 11 |
| Security audit | 2 / 3 scanners passed |
| Last updated | January 30, 2026 |
| Repository | vercel-labs/migration-skills ↗ |
How do I apply cra-to-next-migration using the workflow in its SKILL.md?
Comprehensive guide for migrating Create React App (CRA) projects to Next.js. Use when migrating a CRA app, converting React Router to file-based routing, or adopting Next.js patterns lik...
Who is it for?
Developers following the cra-to-next-migration skill for the tasks it documents.
Skip if: Tasks outside the cra-to-next-migration scope described in SKILL.md.
When should I use this skill?
User mentions cra-to-next-migration or related triggers from the skill description.
What you get
Working cra-to-next-migration setup aligned with the documented patterns and constraints.
- Migrated Next.js project
- Route conversion map
- 148-rule migration checklist
By the numbers
- Contains 148 rules across 17 migration categories
- migration-skills version 1.0.0 under MIT license
- Targets Next.js 16+ with App Router and Server Components
Files
CRA to Next.js Migration Guide
Comprehensive migration guide for converting Create React App projects to Next.js, covering routing, data fetching, components, styling, and deployment. Contains 148 rules across 17 categories, prioritized by migration impact. After a successful migration the application should work the same as it did before the migration.
When to Apply
Reference these guidelines when:
- Migrating an existing CRA application to Next.js
- Converting React Router routes to file-based routing
- Adopting Server Components in a client-heavy app
- Moving from client-side rendering to SSR/SSG
- Updating environment variables for Next.js
- Optimizing images and fonts with Next.js built-ins
Version Policy
Use Next.js 16.x or later. Do NOT use Next.js 14.x or 15.x.
Before starting migration, check the current latest version:
npm info next versionUse the latest version in your package.json with a caret for minor/patch updates. The minimum supported version for this migration guide is ^16.0.0.
Rule Categories by Priority
| Priority | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | Project Setup | CRITICAL | setup- | 6 |
| 2 | Dependencies | CRITICAL | deps- | 1 |
| 3 | Routing | CRITICAL | routing- | 17 |
| 4 | Data Fetching | CRITICAL | data- | 11 |
| 5 | Components | HIGH | components- | 9 |
| 6 | Environment Variables | HIGH | env- | 6 |
| 7 | Styling | HIGH | styling- | 12 |
| 8 | Public Assets | MEDIUM | assets- | 5 |
| 9 | Images | MEDIUM | images- | 8 |
| 10 | Fonts | MEDIUM | fonts- | 6 |
| 11 | SEO & Metadata | MEDIUM | seo- | 9 |
| 12 | API Routes | MEDIUM | api- | 9 |
| 13 | State Management | MEDIUM | state- | 8 |
| 14 | Integrations | MEDIUM | integrations- | 1 |
| 15 | Testing | LOW | testing- | 9 |
| 16 | Build & Deploy | LOW | build- | 7 |
| 17 | Common Gotchas | HIGH | gotchas- | 24 |
Quick Reference
1. Project Setup (CRITICAL)
setup-initial-structure- Convert CRA folder structure to Next.js App Routersetup-package-json- Update dependencies and scriptssetup-next-config- Create and configure next.config.jssetup-typescript- Migrate TypeScript configurationsetup-eslint- Update ESLint for Next.jssetup-gitignore- Update .gitignore for Next.js
2. Dependencies (CRITICAL)
deps-react19-compatibility- Upgrade dependencies for React 19 compatibility
3. Routing (CRITICAL)
routing-basic-pages- Convert components to file-based routesrouting-dynamic-routes- Use [param] syntax for dynamic segmentsrouting-catch-all-routes- Use [...slug] for catch-all routesrouting-optional-catch-all- Use [[...slug]] for optional catch-allrouting-route-groups- Use (group) folders for organizationrouting-parallel-routes- Use @slot for parallel routesrouting-intercepting-routes- Use (..) for intercepting routesrouting-link-component- Replace react-router Link with next/linkrouting-programmatic-navigation- Replace useNavigate with useRouterrouting-use-params- Replace useParams with Next.js paramsrouting-use-search-params- Replace useSearchParams properlyrouting-nested-layouts- Convert nested routes to layoutsrouting-loading-states- Add loading.tsx for suspenserouting-error-boundaries- Add error.tsx for error handlingrouting-not-found- Add not-found.tsx for 404 pagesrouting-hash-based- Handle hash-based routing for client-only appsrouting-protected-routes- Implement protected route patterns
4. Data Fetching (CRITICAL)
data-useeffect-to-rsc- Convert useEffect fetches to Server Componentsdata-useeffect-to-ssr- Convert useEffect to getServerSidePropsdata-useeffect-to-ssg- Convert useEffect to getStaticPropsdata-client-fetch- Keep client fetches with proper patternsdata-server-actions- Use Server Actions for mutationsdata-revalidation- Configure data revalidation strategiesdata-streaming- Use Suspense for streaming datadata-parallel-fetching- Fetch data in parallel on serverdata-sequential-fetching- Handle sequential data dependenciesdata-caching- Configure fetch caching behaviordata-client-library-init- Initialize client-only libraries in useEffect
5. Components (HIGH)
components-use-client- Add 'use client' directive for client componentscomponents-server-default- Understand server components are defaultcomponents-boundary-placement- Place client boundaries strategicallycomponents-composition- Use composition to minimize client JScomponents-interleaving- Interleave server and client componentscomponents-props-serialization- Ensure props are serializablecomponents-children-pattern- Pass server components as childrencomponents-context-providers- Handle Context providers properlycomponents-third-party- Wrap third-party client components
6. Environment Variables (HIGH)
env-prefix-change- Change REACTAPP to NEXTPUBLICenv-server-only- Use non-prefixed vars for server-onlyenv-runtime-config- Use runtime configuration when neededenv-local-files- Understand .env file loading orderenv-build-time- Understand build-time vs runtime env varsenv-validation- Validate required environment variables
7. Styling (HIGH)
styling-global-css- Move global CSS to app/layout.tsxstyling-css-modules- CSS Modules work with minor changesstyling-sass- Configure Sass supportstyling-tailwind- Configure Tailwind CSSstyling-css-in-js- Handle CSS-in-JS librariesstyling-styled-components- Configure styled-components for SSRstyling-emotion- Configure Emotion for SSRstyling-component-styles- Import component styles properlystyling-postcss- Configure PostCSSstyling-scss-global-syntax- Use :global only in CSS Modulesstyling-css-import-order- Control CSS import order in layoutsstyling-dark-mode-hydration- Handle dark mode without hydration mismatch
8. Public Assets (MEDIUM)
assets-public-folder- Public folder works the same wayassets-static-imports- Use static imports for assetsassets-absolute-urls- Reference assets without public prefixassets-favicon- Place favicon in app directoryassets-manifest- Configure web app manifest
9. Images (MEDIUM)
images-next-image- Replace img with next/imageimages-required-dimensions- Provide width and heightimages-fill-prop- Use fill for responsive imagesimages-priority- Use priority for LCP imagesimages-placeholder- Configure blur placeholdersimages-remote-patterns- Configure remote image domainsimages-loader- Configure custom image loadersimages-optimization- Understand automatic optimization
10. Fonts (MEDIUM)
fonts-next-font- Use next/font for optimizationfonts-google-fonts- Load Google Fonts properlyfonts-local-fonts- Load local font filesfonts-variable-fonts- Configure variable fontsfonts-font-display- Configure font-display strategyfonts-preload- Understand automatic font preloading
11. SEO & Metadata (MEDIUM)
seo-metadata-api- Use Metadata API instead of react-helmetseo-dynamic-metadata- Generate dynamic metadataseo-opengraph- Configure Open Graph metadataseo-twitter-cards- Configure Twitter Card metadataseo-json-ld- Add structured data (JSON-LD)seo-canonical- Set canonical URLsseo-robots- Configure robots meta tagsseo-sitemap- Generate sitemap.xmlseo-head-component- Migrate from next/head to Metadata
12. API Routes (MEDIUM)
api-route-handlers- Create Route Handlers in app/apiapi-http-methods- Export named functions for HTTP methodsapi-request-body- Parse request body properlyapi-query-params- Access query parametersapi-headers-cookies- Access headers and cookiesapi-response-types- Return proper response typesapi-middleware- Implement middleware patternsapi-cors- Configure CORS properlyapi-rate-limiting- Implement rate limiting
13. State Management (MEDIUM)
state-context-client- Context requires 'use client'state-zustand- Zustand works with hydration carestate-redux- Configure Redux with Next.jsstate-jotai- Configure Jotai properlystate-recoil- Configure Recoil properlystate-url-state- Use URL for shareable statestate-server-state- Minimize client state with RSCstate-persistence- Handle state persistence
14. Integrations (MEDIUM)
integrations-sentry- Migrate Sentry error monitoring
15. Testing (LOW)
testing-jest-config- Update Jest configurationtesting-react-testing-library- RTL works the sametesting-server-components- Test Server Componentstesting-client-components- Test Client Componentstesting-async-components- Test async componentstesting-mocking- Mock Next.js modulestesting-e2e-cypress- Configure Cypress for Next.jstesting-e2e-playwright- Configure Playwright for Next.jstesting-api-routes- Test API Route Handlers
16. Build & Deployment (LOW)
build-scripts- Update build scriptsbuild-output- Understand build outputbuild-standalone- Configure standalone outputbuild-static-export- Configure static exportbuild-bundle-analysis- Analyze bundle sizebuild-vercel- Deploy to Vercelbuild-docker- Configure Docker deployment
17. Common Gotchas (HIGH)
gotchas-window-undefined- Handle window/document in SSRgotchas-hydration-mismatch- Fix hydration mismatchesgotchas-use-effect-timing- Understand useEffect in Next.jsgotchas-router-ready- Check router.isReady for query paramsgotchas-dynamic-imports- Use next/dynamic properlygotchas-api-routes-edge- Edge vs Node.js runtimegotchas-middleware- Middleware runs on edgegotchas-static-generation- Static vs dynamic renderinggotchas-redirect- Handle redirects properlygotchas-headers- Set response headersgotchas-cookies- Handle cookies in RSCgotchas-turbopack- Handle Turbopack compatibility issuesgotchas-empty-modules- Fix empty module exports for isolatedModulesgotchas-nullish-coalescing- Fix nullish coalescing runtime errorsgotchas-react19-class-components- Fix React 19 class component this bindinggotchas-react19-ref-prop- Handle React 19 ref prop changesgotchas-websocket-optional-deps- Handle WebSocket native dependency bundlinggotchas-auth-race-conditions- Guard against auth/API race conditionsgotchas-auth-state-gating- Wait for auth state before checking rolesgotchas-configuration-idempotency- Ensure configuration idempotency with useRefgotchas-hydration-nested-interactive- Avoid nested interactive elementsgotchas-router-push-timing- Never call router.push during rendergotchas-infinite-rerender- Prevent infinite re-render loopsgotchas-provider-hierarchy- Configure provider hierarchy correctly
Pre-Migration Checklist
Before starting migration, scan the codebase for patterns that need special handling:
# Check for WebSocket libraries (needs webpack fallback config)
grep -E "(socket\.io|\"ws\")" package.json
# Check for SCSS :export syntax (may need --webpack flag)
grep -r ":export" --include="*.scss" src/
# Check for SVG ReactComponent imports (needs SVGR config)
grep -r "ReactComponent" --include="*.ts" --include="*.tsx" src/
# List all REACT_APP_ environment variables
grep -roh "REACT_APP_[A-Z_]*" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" src/ | sort -u
# Check for Redux extraReducers using object notation (must convert to builder pattern for RTK v2)
grep -r "extraReducers:" --include="*.js" --include="*.jsx" --include="*.ts" --include="*.tsx" src/
# Check for /app/ paths that need updating if using (app) route group
grep -rE "(href|to|push|replace|redirect).*['\"]\/app\/" --include="*.js" --include="*.jsx" --include="*.ts" --include="*.tsx" src/Scan Results to Rule Mapping:
| Scan Result | Rules to Read |
|---|---|
| socket.io or ws in package.json | gotchas-websocket-optional-deps, setup-next-config |
:export in SCSS files | gotchas-turbopack |
ReactComponent SVG imports | assets-static-imports |
REACT_APP_ variables found | env-prefix-change |
extraReducers: found | state-redux (RTK v2 builder callback required) |
/app/ paths in navigation | routing-route-groups (update paths for route groups) |
How to Use
Read individual rule files for detailed explanations and code examples:
rules/setup-initial-structure.md
rules/routing-basic-pages.md
rules/data-useeffect-to-rsc.mdEach rule file contains:
- Brief explanation of the migration step
- CRA "before" code example
- Next.js "after" code example
- Additional context and gotchas
Migration Order
For best results, migrate in this order:
1. Setup - Initialize Next.js project structure 2. Routing - Convert React Router to file-based routing 3. Environment Variables - Update env var prefixes 4. Components - Add 'use client' directives where needed 5. Data Fetching - Convert useEffect to server patterns 6. Styling - Move global CSS, configure CSS-in-JS 7. Images & Fonts - Adopt Next.js optimizations 8. SEO - Migrate to Metadata API 9. API Routes - Create Route Handlers 10. Testing - Update test configuration
Post-Migration Verification Checklist
After migration, verify the application works correctly:
Core Functionality:
- [ ]
npm run devstarts Next.js dev server without errors - [ ]
npm run buildcompletes successfully - [ ]
npm startruns the production build - [ ] Main application renders correctly
- [ ] All routes are accessible
Client-Side Features:
- [ ] localStorage/sessionStorage persistence works
- [ ] Dark mode or theme toggles work and persist
- [ ] Client-side interactivity (forms, buttons, modals) works
- [ ] Browser back/forward navigation works correctly
Routing (if applicable):
- [ ] Hash-based routing works (e.g.,
#room=abc,key=xyz) - [ ] Query parameters are read correctly
- [ ] Dynamic routes render with correct params
- [ ] 404 pages show for invalid routes
Real-Time Features (if applicable):
- [ ] WebSocket connections establish successfully
- [ ] Real-time collaboration or updates work
- [ ] Reconnection after disconnect works
Integrations (if applicable):
- [ ] Error monitoring (Sentry) captures errors
- [ ] Analytics tracking fires correctly
- [ ] Third-party auth (OAuth, Firebase) works
- [ ] File uploads work
PWA (if applicable):
- [ ] Service worker registers (production build)
- [ ] App is installable
- [ ] Offline functionality works as expected
Performance:
- [ ] No hydration mismatch warnings in console
- [ ] Images load and are optimized
- [ ] Fonts load without FOUT/FOIT issues
- [ ] No unexpected console errors or warnings
Configure CORS Properly
Configure CORS (Cross-Origin Resource Sharing) for API routes that need to be accessed from different domains.
Express/CRA Backend (before):
const cors = require('cors')
app.use(cors({
origin: 'https://example.com',
methods: ['GET', 'POST'],
credentials: true,
}))Next.js Route Handler (after):
// app/api/data/route.ts
import { NextResponse } from 'next/server'
const corsHeaders = {
'Access-Control-Allow-Origin': 'https://example.com',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
}
// Handle preflight requests
export async function OPTIONS() {
return new NextResponse(null, { headers: corsHeaders })
}
export async function GET() {
const data = await fetchData()
return NextResponse.json(data, { headers: corsHeaders })
}
export async function POST(request: Request) {
const body = await request.json()
const result = await createData(body)
return NextResponse.json(result, { headers: corsHeaders })
}Reusable CORS helper:
// lib/cors.ts
export function corsResponse(data: any, status = 200) {
return NextResponse.json(data, {
status,
headers: {
'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN || '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
})
}
// Usage in route
import { corsResponse } from '@/lib/cors'
export async function GET() {
const data = await fetchData()
return corsResponse(data)
}Using next.config.js headers:
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE' },
],
},
]
},
}Access Headers and Cookies
Access request headers and cookies in Next.js Route Handlers.
Express/CRA Backend (before):
router.get('/profile', async (req, res) => {
const authHeader = req.headers.authorization
const sessionId = req.cookies.session
// ...
})Next.js Route Handler (after):
// app/api/profile/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { cookies, headers } from 'next/headers'
// Method 1: From request object
export async function GET(request: NextRequest) {
// Headers
const authHeader = request.headers.get('authorization')
const contentType = request.headers.get('content-type')
// Cookies from request
const sessionId = request.cookies.get('session')?.value
return NextResponse.json({ /* ... */ })
}
// Method 2: Using next/headers helpers
export async function GET() {
const headersList = headers()
const cookieStore = cookies()
const authHeader = headersList.get('authorization')
const sessionId = cookieStore.get('session')?.value
return NextResponse.json({ /* ... */ })
}Setting cookies in response:
export async function POST(request: NextRequest) {
const response = NextResponse.json({ success: true })
// Set cookie
response.cookies.set('session', 'abc123', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 1 week
})
// Delete cookie
response.cookies.delete('oldCookie')
return response
}Setting headers in response:
export async function GET() {
return new NextResponse(JSON.stringify({ data: 'value' }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-Custom-Header': 'value',
},
})
}See also: gotchas-cookies.md for cookies in Server Components, gotchas-headers.md for setting response headers.
Export Named Functions for HTTP Methods
Export functions named after HTTP methods to handle different request types.
Express/CRA Backend (before):
// server/routes/users.js
const router = express.Router()
router.get('/', async (req, res) => {
const users = await getUsers()
res.json(users)
})
router.post('/', async (req, res) => {
const user = await createUser(req.body)
res.status(201).json(user)
})
router.put('/:id', async (req, res) => {
const user = await updateUser(req.params.id, req.body)
res.json(user)
})
router.delete('/:id', async (req, res) => {
await deleteUser(req.params.id)
res.status(204).end()
})Next.js Route Handler (after):
// app/api/users/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
const users = await getUsers()
return NextResponse.json(users)
}
export async function POST(request: Request) {
const body = await request.json()
const user = await createUser(body)
return NextResponse.json(user, { status: 201 })
}
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const user = await getUser(params.id)
return NextResponse.json(user)
}
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json()
const user = await updateUser(params.id, body)
return NextResponse.json(user)
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await deleteUser(params.id)
return new NextResponse(null, { status: 204 })
}Supported methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
Implement Middleware Patterns
Implement middleware patterns for authentication, logging, and other cross-cutting concerns.
Express/CRA Backend (before):
// Middleware function
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1]
if (!token) return res.status(401).json({ error: 'Unauthorized' })
try {
req.user = verifyToken(token)
next()
} catch {
res.status(401).json({ error: 'Invalid token' })
}
}
router.get('/protected', authMiddleware, (req, res) => {
res.json({ user: req.user })
})Next.js - Route Handler with auth check:
// lib/auth.ts
export async function verifyAuth(request: Request) {
const token = request.headers.get('authorization')?.split(' ')[1]
if (!token) return null
try {
return await verifyToken(token)
} catch {
return null
}
}
// app/api/protected/route.ts
import { verifyAuth } from '@/lib/auth'
export async function GET(request: Request) {
const user = await verifyAuth(request)
if (!user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
return NextResponse.json({ user })
}Next.js Middleware (for multiple routes):
// middleware.ts (at project root)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('token')
// Protect /api/admin routes
if (request.nextUrl.pathname.startsWith('/api/admin')) {
if (!token) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
}
return NextResponse.next()
}
export const config = {
matcher: '/api/:path*',
}Access Query Parameters
Access URL query parameters using the URL API in Next.js Route Handlers.
Express/CRA Backend (before):
// GET /api/users?page=1&limit=10&search=john
router.get('/users', async (req, res) => {
const { page, limit, search } = req.query
const users = await getUsers({ page, limit, search })
res.json(users)
})Next.js Route Handler (after):
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const page = searchParams.get('page') || '1'
const limit = searchParams.get('limit') || '10'
const search = searchParams.get('search') || ''
const users = await getUsers({
page: parseInt(page),
limit: parseInt(limit),
search,
})
return NextResponse.json(users)
}Multiple values for same param:
// GET /api/users?tag=react&tag=typescript
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const tags = searchParams.getAll('tag') // ['react', 'typescript']
// ...
}Check if param exists:
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
if (searchParams.has('featured')) {
// ?featured or ?featured=true
}
// Iterate all params
for (const [key, value] of searchParams) {
console.log(`${key}: ${value}`)
}
}Type-safe parsing:
import { z } from 'zod'
const querySchema = z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(10),
search: z.string().optional(),
})
export async function GET(request: NextRequest) {
const params = Object.fromEntries(request.nextUrl.searchParams)
const { page, limit, search } = querySchema.parse(params)
// ...
}Implement Rate Limiting
Implement rate limiting to protect your API from abuse and ensure fair usage.
Express/CRA Backend (before):
const rateLimit = require('express-rate-limit')
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per window
})
app.use('/api/', limiter)Next.js - Simple in-memory rate limiter:
// lib/rateLimit.ts
const rateLimitMap = new Map<string, { count: number; lastReset: number }>()
export function rateLimit(
ip: string,
limit: number = 10,
windowMs: number = 60000
): { success: boolean; remaining: number } {
const now = Date.now()
const record = rateLimitMap.get(ip)
if (!record || now - record.lastReset > windowMs) {
rateLimitMap.set(ip, { count: 1, lastReset: now })
return { success: true, remaining: limit - 1 }
}
if (record.count >= limit) {
return { success: false, remaining: 0 }
}
record.count++
return { success: true, remaining: limit - record.count }
}
// app/api/data/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { rateLimit } from '@/lib/rateLimit'
export async function GET(request: NextRequest) {
const ip = request.ip || request.headers.get('x-forwarded-for') || 'unknown'
const { success, remaining } = rateLimit(ip, 100, 60000)
if (!success) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: { 'X-RateLimit-Remaining': '0' },
}
)
}
const data = await fetchData()
return NextResponse.json(data, {
headers: { 'X-RateLimit-Remaining': String(remaining) },
})
}Using Upstash for distributed rate limiting:
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'),
})
export async function GET(request: NextRequest) {
const ip = request.ip ?? '127.0.0.1'
const { success } = await ratelimit.limit(ip)
if (!success) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
}
return NextResponse.json({ data: 'success' })
}Parse Request Body Properly
Next.js Route Handlers use the Web Request API for parsing request bodies.
Express/CRA Backend (before):
// With body-parser middleware
app.use(express.json())
router.post('/users', async (req, res) => {
const { name, email } = req.body // Already parsed
// ...
})Next.js Route Handler (after):
// app/api/users/route.ts
// JSON body
export async function POST(request: Request) {
const body = await request.json()
const { name, email } = body
// ...
return NextResponse.json({ success: true })
}
// Form data
export async function POST(request: Request) {
const formData = await request.formData()
const name = formData.get('name')
const file = formData.get('file') as File
// ...
}
// Text body
export async function POST(request: Request) {
const text = await request.text()
// ...
}
// ArrayBuffer (binary)
export async function POST(request: Request) {
const buffer = await request.arrayBuffer()
// ...
}With validation (using Zod):
import { z } from 'zod'
import { NextResponse } from 'next/server'
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
export async function POST(request: Request) {
const body = await request.json()
const result = userSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: result.error.flatten() },
{ status: 400 }
)
}
const user = await createUser(result.data)
return NextResponse.json(user, { status: 201 })
}Return Proper Response Types
Return different response types from Route Handlers based on your needs.
Express/CRA Backend (before):
// JSON response
res.json({ data: 'value' })
// Text response
res.send('Hello')
// File download
res.download('/path/to/file.pdf')
// Redirect
res.redirect('/new-path')
// Status codes
res.status(404).json({ error: 'Not found' })Next.js Route Handler (after):
import { NextResponse } from 'next/server'
// JSON response
export async function GET() {
return NextResponse.json({ data: 'value' })
}
// JSON with status code
export async function GET() {
return NextResponse.json(
{ error: 'Not found' },
{ status: 404 }
)
}
// Text response
export async function GET() {
return new NextResponse('Hello World', {
headers: { 'Content-Type': 'text/plain' },
})
}
// HTML response
export async function GET() {
return new NextResponse('<h1>Hello</h1>', {
headers: { 'Content-Type': 'text/html' },
})
}
// Redirect
export async function GET() {
return NextResponse.redirect(new URL('/new-path', request.url))
}
// File/stream response
export async function GET() {
const file = await fs.readFile('/path/to/file.pdf')
return new NextResponse(file, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="file.pdf"',
},
})
}
// No content
export async function DELETE() {
await deleteResource()
return new NextResponse(null, { status: 204 })
}Create Route Handlers in app/api
CRA typically uses a separate backend or proxied API. Next.js provides built-in API routes.
CRA Pattern (before):
// Separate Express server or proxy setup
// setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware')
module.exports = function(app) {
app.use('/api', createProxyMiddleware({
target: 'http://localhost:5000',
}))
}Next.js Route Handler (after):
// app/api/users/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
const users = await db.users.findMany()
return NextResponse.json(users)
}
export async function POST(request: Request) {
const body = await request.json()
const user = await db.users.create({ data: body })
return NextResponse.json(user, { status: 201 })
}Route structure:
app/
├── api/
│ ├── users/
│ │ ├── route.ts # /api/users
│ │ └── [id]/
│ │ └── route.ts # /api/users/[id]
│ ├── posts/
│ │ └── route.ts # /api/posts
│ └── health/
│ └── route.ts # /api/healthCalling from components:
// Client component
'use client'
async function createUser(data) {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
return res.json()
}Note: For simple data mutations, consider Server Actions instead of API routes.
Reference Assets Without Public Prefix
In Next.js, reference files in the public folder directly without any prefix or environment variable.
CRA Pattern (before):
// Using process.env.PUBLIC_URL or %PUBLIC_URL%
<img src={`${process.env.PUBLIC_URL}/images/logo.png`} />
// In HTML
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />Next.js Pattern (after):
// Just use absolute path from root
<img src="/images/logo.png" />
// In metadata (app/layout.tsx)
export const metadata = {
icons: {
icon: '/favicon.ico',
},
}Migration:
# Find and replace in your code
# %PUBLIC_URL% -> (empty string)
# process.env.PUBLIC_URL -> (empty string)
# Before
src="%PUBLIC_URL%/images/photo.jpg"
src={`${process.env.PUBLIC_URL}/images/photo.jpg`}
# After
src="/images/photo.jpg"Examples:
// Images
<img src="/images/banner.jpg" alt="Banner" />
// Downloads
<a href="/files/document.pdf">Download PDF</a>
// Videos
<video src="/videos/intro.mp4" />
// Audio
<audio src="/audio/notification.mp3" />Note: For images, prefer using next/image component with static imports for optimization benefits.
Place Favicon in App Directory
Next.js App Router supports placing favicon files directly in the app directory for automatic handling.
CRA Pattern (before):
public/
└── favicon.ico
<!-- public/index.html -->
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />Next.js Pattern (after):
app/
├── favicon.ico # Automatically used
├── icon.png # Alternative format
├── apple-icon.png # Apple touch icon
└── layout.tsxNo configuration needed - Next.js detects and uses these automatically.
Supported favicon files:
| File | Purpose |
|---|---|
favicon.ico | Default favicon |
icon.png | Modern browsers |
icon.svg | SVG favicon |
apple-icon.png | Apple devices |
Multiple sizes (icon.tsx):
// app/icon.tsx
import { ImageResponse } from 'next/og'
export const size = { width: 32, height: 32 }
export const contentType = 'image/png'
export default function Icon() {
return new ImageResponse(
<div
style={{
fontSize: 24,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'black',
color: 'white',
}}
>
A
</div>,
{ ...size }
)
}Or via metadata:
// app/layout.tsx
export const metadata = {
icons: {
icon: '/favicon.ico',
apple: '/apple-icon.png',
},
}Configure Web App Manifest
Next.js App Router can generate the web app manifest dynamically or use a static file.
CRA Pattern (before):
// public/manifest.json
{
"short_name": "My App",
"name": "My Application",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}<!-- public/index.html -->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />Next.js - Static file:
// public/manifest.json (same content)// app/layout.tsx
export const metadata = {
manifest: '/manifest.json',
}Next.js - Dynamic generation (recommended):
// app/manifest.ts
import { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'My Application',
short_name: 'My App',
description: 'My awesome application',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icon-512.png',
sizes: '512x512',
type: 'image/png',
},
],
}
}This generates /manifest.webmanifest automatically with proper headers.
Full PWA Setup with next-pwa
For complete PWA functionality including service workers, offline support, and caching, use the next-pwa package.
Installation:
npm install next-pwaConfiguration:
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
register: true,
skipWaiting: true,
})
/** @type {import('next').NextConfig} */
const nextConfig = {
// Your other config options
}
module.exports = withPWA(nextConfig)TypeScript configuration:
// next.config.ts
import type { NextConfig } from 'next'
import withPWAInit from 'next-pwa'
const withPWA = withPWAInit({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
register: true,
skipWaiting: true,
})
const nextConfig: NextConfig = {
// Your other config options
}
export default withPWA(nextConfig)Required manifest and icons:
Ensure you have the manifest (via app/manifest.ts or public/manifest.json) and PWA icons in the public/ directory:
public/
├── icon-192.png
├── icon-512.png
└── manifest.json (optional if using app/manifest.ts)CRA with vite-plugin-pwa migration:
If migrating from CRA with vite-plugin-pwa or workbox:
| vite-plugin-pwa | next-pwa |
|---|---|
registerType: 'autoUpdate' | skipWaiting: true |
devOptions.enabled | disable: process.env.NODE_ENV === 'development' |
Custom service worker in src/ | Custom service worker via sw option |
Verification:
After building for production, verify PWA installation:
- Build:
npm run build && npm start - Open in Chrome, check DevTools > Application > Service Workers
- Test install prompt appears in browser
Public Folder Works the Same Way
The public/ folder works identically in Next.js and CRA. Files are served from the root URL.
CRA Pattern (before):
public/
├── favicon.ico
├── logo.png
├── robots.txt
└── manifest.json// Referencing public assets
<img src="/logo.png" alt="Logo" />
<link rel="icon" href="/favicon.ico" />Next.js Pattern (after):
public/
├── favicon.ico
├── logo.png
├── robots.txt
└── manifest.json// Same referencing - no changes needed
<img src="/logo.png" alt="Logo" />Key points:
- Files in
public/are served at root path - No build processing (use as-is)
/publicis NOT included in the URL pathpublic/image.png→ accessible at/image.png
Differences from CRA:
- No
%PUBLIC_URL%variable needed - Use absolute paths directly:
/file.ext
What to put in public/:
- Favicons
- robots.txt
- sitemap.xml
- Static documents (PDFs)
- Files that need exact URLs
What NOT to put in public/:
- Images that should be optimized (use
next/image) - CSS files (import them)
- JavaScript files (import them)
Use Static Imports for Assets
Import images and other assets directly for automatic optimization and type safety.
CRA Pattern (before):
// src/components/Logo.tsx
import logo from './logo.png'
export function Logo() {
return <img src={logo} alt="Logo" />
}Next.js Pattern (after):
// components/Logo.tsx
import Image from 'next/image'
import logo from './logo.png'
export function Logo() {
return (
<Image
src={logo} // StaticImageData - includes dimensions
alt="Logo"
// width and height inferred from import
/>
)
}Benefits of static imports in Next.js:
- Automatic width/height detection
- Prevents Cumulative Layout Shift
- Enables blur placeholder
- Optimized at build time
With blur placeholder:
import Image from 'next/image'
import heroImage from './hero.jpg'
export function Hero() {
return (
<Image
src={heroImage}
alt="Hero"
placeholder="blur" // Automatic blur placeholder from import
/>
)
}Importing other assets:
// JSON data
import data from './data.json'
// Fonts (prefer next/font instead)
import fontFile from './font.woff2'SVG Migration from CRA
CRA supports importing SVGs as React components using a special syntax that does not work in Next.js.
CRA SVG pattern (does NOT work in Next.js):
// CRA allows this - Next.js does NOT
import { ReactComponent as Logo } from './logo.svg'
import { ReactComponent as Icon } from './icon.svg'
function Header() {
return (
<header>
<Logo className="logo" />
<Icon width={24} height={24} />
</header>
)
}Option 1: Use next/image for SVGs (Recommended for most cases)
import Image from 'next/image'
import logoSvg from './logo.svg'
function Header() {
return (
<header>
<Image
src={logoSvg}
alt="Logo"
width={100}
height={40}
/>
</header>
)
}Note: With next/image, you must provide explicit width and height props for SVGs - they are not automatically inferred like with raster images. If the original code uses CSS for sizing (e.g., className="logo" with CSS rules), you'll need to extract those dimensions.
For responsive SVGs with CSS-based sizing, use the fill prop with a sized container:
<div style={{ position: 'relative', width: '100px', height: '40px' }}>
<Image src={logoSvg} alt="Logo" fill />
</div>Option 2: Configure @svgr/webpack for component imports
If you need SVGs as React components (for styling, animations, or dynamic manipulation):
npm install --save-dev @svgr/webpack// next.config.js
module.exports = {
webpack(config) {
// Find the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
rule.test?.test?.('.svg'),
)
config.module.rules.push(
// Reapply the existing rule, but only for svg imports ending in ?url
{
...fileLoaderRule,
test: /\.svg$/i,
resourceQuery: /url/, // *.svg?url
},
// Convert all other *.svg imports to React components
{
test: /\.svg$/i,
issuer: fileLoaderRule.issuer,
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] },
use: ['@svgr/webpack'],
},
)
// Modify the file loader rule to ignore *.svg
fileLoaderRule.exclude = /\.svg$/i
return config
},
}Then use SVGs as components:
import Logo from './logo.svg'
import Image from 'next/image'
import iconUrl from './icon.svg?url'
function Header() {
return (
<header>
<Logo className="logo" /> {/* As component */}
<Image src={iconUrl} alt="Icon" /> {/* As image */}
</header>
)
}SVG Migration checklist:
1. Search for { ReactComponent as imports in your codebase 2. Decide per-SVG: use next/image or convert to SVGR component 3. For next/image, add explicit width and height props (check CSS for dimensions if needed) 4. For SVGs needing manipulation, install and configure @svgr/webpack 5. Update all import statements accordingly
Search for CRA SVG imports:
grep -r "ReactComponent as" --include="*.tsx" --include="*.ts" --include="*.jsx" --include="*.js"Analyze Bundle Size
Analyze your bundle to identify large dependencies and optimization opportunities.
CRA bundle analysis:
npm run build
# Uses source-map-explorer or similarNext.js bundle analysis:
npm install -D @next/bundle-analyzer// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
/** @type {import('next').NextConfig} */
const nextConfig = {
// your config
}
module.exports = withBundleAnalyzer(nextConfig)// package.json
{
"scripts": {
"analyze": "ANALYZE=true next build"
}
}# Run analysis
npm run analyze
# Opens browser with bundle visualizationReading the analysis:
- client.html - Code sent to browser
- server.html - Server-side code
- Look for large chunks and duplicate dependencies
Common issues and fixes:
// BAD: Importing entire library
import _ from 'lodash'
_.debounce(...)
// GOOD: Import specific function
import debounce from 'lodash/debounce'
debounce(...)// BAD: Static import of heavy component
import HeavyChart from './HeavyChart'
// GOOD: Dynamic import
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <Skeleton />,
})Build output size check:
npm run build
# Check the size output
# First Load JS shared by all: 84 kB
# Keep shared JS under 100kB for good performanceConfigure Docker Deployment
Deploy Next.js in Docker containers for Kubernetes, AWS ECS, or other container platforms.
Basic Dockerfile:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]Optimized multi-stage Dockerfile:
# Stage 1: Dependencies
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Builder
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: Runner
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]Requires standalone output:
// next.config.js
module.exports = {
output: 'standalone',
}.dockerignore:
node_modules
.next
.git
*.md
.env*.localBuild and run:
docker build -t my-next-app .
docker run -p 3000:3000 my-next-appDocker Compose:
# docker-compose.yml
version: '3.8'
services:
web:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://db:5432/app
depends_on:
- db
db:
image: postgres:15
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:Understand Build Output
CRA and Next.js produce different build outputs with different deployment requirements.
CRA build output:
build/
├── static/
│ ├── css/
│ │ └── main.abc123.css
│ ├── js/
│ │ ├── main.abc123.js
│ │ └── runtime.abc123.js
│ └── media/
│ └── logo.abc123.png
├── index.html
├── asset-manifest.json
└── favicon.ico- Static files only
- Deploy to any static host (S3, Netlify, Vercel)
- Single
index.htmlfor all routes
Next.js build output:
.next/
├── cache/ # Build cache
├── server/ # Server-side code
│ ├── app/
│ │ ├── page.js
│ │ └── api/
│ ├── chunks/
│ └── pages/
├── static/ # Static assets
│ ├── chunks/
│ └── css/
└── BUILD_ID
public/ # Copied as-is- Server code + static assets
- Requires Node.js server (or serverless)
- Each route can be static or dynamic
Checking build output:
# Build and see output
npm run build
# Output shows:
# - Route types (Static/Dynamic)
# - Bundle sizes
# - Build timeBuild output information:
Route (app) Size First Load JS
┌ ○ / 5.2 kB 89.1 kB
├ ○ /about 1.3 kB 85.2 kB
├ λ /api/users 0 B 0 B
└ λ /blog/[slug] 2.1 kB 86.0 kB
○ (Static) prerendered as static content
λ (Dynamic) server-rendered on demandUpdate Build Scripts
Replace CRA's react-scripts with Next.js build commands.
CRA package.json (before):
{
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
}
}Next.js package.json (after):
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "jest",
"test:watch": "jest --watch"
}
}Script mapping:
| CRA | Next.js | Purpose |
|---|---|---|
npm start | npm run dev | Development server |
npm run build | npm run build | Production build |
| (N/A) | npm start | Production server |
npm test | npm test | Run tests |
npm run eject | (N/A) | Not needed |
Additional useful scripts:
{
"scripts": {
"dev": "next dev",
"dev:turbo": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"type-check": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:ci": "jest --ci",
"e2e": "playwright test",
"analyze": "ANALYZE=true next build"
}
}Environment-specific builds:
{
"scripts": {
"build:staging": "NEXT_PUBLIC_ENV=staging next build",
"build:prod": "NEXT_PUBLIC_ENV=production next build"
}
}Next.js 16+ Turbopack Default
Next.js 16 and later use Turbopack as the default bundler for development. This improves dev server startup and refresh times but may cause issues with some webpack-specific features.
If you encounter Turbopack compatibility issues:
{
"scripts": {
"dev": "next dev --webpack",
"dev:turbo": "next dev",
"build": "next build"
}
}Common features that require --webpack flag:
- SCSS
:exportsyntax for sharing variables with JavaScript - Custom webpack loaders
- Some webpack plugins
Note: The build command always uses webpack, so --webpack only affects development.
Build Output Directory Mapping
CRA and Next.js use different output directory structures. Update any custom scripts that reference build paths.
Directory mapping:
| Purpose | CRA Path | Next.js Path |
|---|---|---|
| Production build | build/ | .next/ |
| Static assets | build/static/ | .next/static/ |
| Public files | public/ (copied to build/) | public/ (served directly) |
| Generated HTML | build/index.html | .next/server/app/ |
| Static export | N/A | out/ |
Update custom build scripts:
# CRA (before)
cp -r build/static/* /deploy/assets/
aws s3 sync build/ s3://bucket/
# Next.js (after)
cp -r .next/static/* /deploy/assets/
aws s3 sync out/ s3://bucket/ # For static exportIf you have post-build scripts that reference `build/`:
{
"scripts": {
"build": "next build",
"postbuild": "node scripts/post-build.js"
}
}Update the script to use .next/ or out/ (for static export) instead of build/.
Configure Standalone Output
Use standalone output for smaller, self-contained deployments (Docker, custom servers).
Standard Next.js deployment:
node_modules/ # All dependencies
.next/ # Build output
public/ # Static files
package.jsonStandalone output (optimized):
// next.config.js
module.exports = {
output: 'standalone',
}.next/standalone/
├── server.js # Minimal server
├── node_modules/ # Only production deps
├── .next/
│ └── static/ # Copy this to serve static files
└── public/ # Copy this for public filesRunning standalone:
# Build
npm run build
# Copy static files
cp -r .next/static .next/standalone/.next/
cp -r public .next/standalone/
# Run
cd .next/standalone
node server.jsDocker with standalone:
# Dockerfile
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]Benefits:
- Smaller image size (~100MB vs ~500MB+)
- Only production dependencies
- Self-contained server
- No need for
npm installat runtime
Configure Static Export
Export your Next.js app as a static site, similar to CRA's build output.
When to use static export:
- No server-side features needed
- Deploying to static hosts (S3, GitHub Pages)
- Simple static websites
- CRA-like deployment model
Configuration:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
// Optional: Add trailing slashes
trailingSlash: true,
// Optional: Disable image optimization (not available in static)
images: {
unoptimized: true,
},
}
module.exports = nextConfigBuild output:
npm run build
# Outputs to 'out/' directory
out/
├── index.html
├── about/
│ └── index.html
├── blog/
│ ├── index.html
│ └── post-1/
│ └── index.html
├── _next/
│ └── static/
└── favicon.icoLimitations (not supported in static export):
- Server-side rendering (SSR)
- API routes
- Middleware
- Image optimization
- Internationalized routing
- Dynamic routes without
generateStaticParams
Dynamic routes with static export:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetchAllPosts()
return posts.map((post) => ({
slug: post.slug,
}))
}
export default function BlogPost({ params }) {
// ...
}Deploying:
# Deploy 'out/' to any static host
npx serve out
# or
aws s3 sync out/ s3://my-bucketDeploy to Vercel
Vercel is the recommended platform for deploying Next.js applications.
Initial deployment:
# Install Vercel CLI
npm i -g vercel
# Deploy (first time will prompt for setup)
vercel
# Deploy to production
vercel --prodVia GitHub integration (recommended):
1. Push code to GitHub 2. Import project at vercel.com/new 3. Vercel auto-detects Next.js 4. Deploys on every push
vercel.json configuration (optional):
{
"buildCommand": "npm run build",
"devCommand": "npm run dev",
"installCommand": "npm install",
"framework": "nextjs",
"regions": ["iad1"],
"env": {
"DATABASE_URL": "@database-url"
},
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "no-store" }
]
}
]
}Environment variables: 1. Go to Project Settings > Environment Variables 2. Add variables for Production/Preview/Development 3. Redeploy to apply changes
Preview deployments:
- Every PR gets a unique preview URL
- Share for review before merging
- Automatic cleanup after merge
Key features:
- Edge Functions for API routes
- Automatic HTTPS
- Global CDN
- Analytics integration
- Serverless functions
- Image optimization
Domain setup: 1. Go to Project Settings > Domains 2. Add your domain 3. Configure DNS as instructed
Place Client Boundaries Strategically
Push 'use client' boundaries as low as possible in the component tree to minimize client JavaScript.
CRA Pattern - Everything is client (before):
// src/pages/ProductPage.tsx
// Entire page is client-rendered
export default function ProductPage() {
const [quantity, setQuantity] = useState(1)
const product = useProduct()
return (
<div>
<ProductHeader product={product} />
<ProductDescription description={product.description} />
<ProductSpecs specs={product.specs} />
<QuantitySelector value={quantity} onChange={setQuantity} />
<AddToCartButton product={product} quantity={quantity} />
</div>
)
}Next.js - Client boundary at leaf (after):
// app/products/[id]/page.tsx - Server Component
import { QuantitySelector } from './QuantitySelector'
import { AddToCartButton } from './AddToCartButton'
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.id)
return (
<div>
{/* These are Server Components - zero JS */}
<ProductHeader product={product} />
<ProductDescription description={product.description} />
<ProductSpecs specs={product.specs} />
{/* Only interactive parts are Client Components */}
<QuantitySelector productId={product.id} />
<AddToCartButton product={product} />
</div>
)
}
// components/QuantitySelector.tsx
'use client'
export function QuantitySelector({ productId }) {
const [quantity, setQuantity] = useState(1)
// Only this small component ships JS to client
return (...)
}Bad - Boundary too high:
// DON'T: Making entire page client-side
'use client'
export default function ProductPage() {
// Now EVERYTHING is client-rendered
}Good - Boundary at interactive leaf:
// DO: Only the interactive button is client
// components/LikeButton.tsx
'use client'
export function LikeButton({ postId }) {
const [liked, setLiked] = useState(false)
return <button onClick={() => setLiked(!liked)}>Like</button>
}Pass Server Components as Children
Use the children prop to pass Server Components into Client Components, preserving their server-rendered benefits.
CRA Pattern (before):
// Everything is client - no distinction needed
function Modal({ children }) {
const [open, setOpen] = useState(false)
return open ? <div className="modal">{children}</div> : null
}Next.js Pattern (after):
// components/Modal.tsx
'use client'
import { useState } from 'react'
export function Modal({ children, trigger }) {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>{trigger}</button>
{open && (
<div className="modal">
<button onClick={() => setOpen(false)}>Close</button>
{children} {/* Server Components remain server-rendered */}
</div>
)}
</>
)
}
// app/page.tsx (Server Component)
import { Modal } from '@/components/Modal'
export default async function Page() {
const data = await fetchData() // Server-side fetch
return (
<Modal trigger="Open Details">
{/* This content is server-rendered, not client JS */}
<h2>{data.title}</h2>
<p>{data.description}</p>
<DataTable rows={data.rows} />
</Modal>
)
}Why this matters:
// BAD: All content becomes client-side
'use client'
export function Modal() {
const data = useData() // Client fetch, more JS
return <div>{/* ... */}</div>
}
// GOOD: Server content passed as children
export function Modal({ children }) {
return <div>{children}</div> // children already rendered on server
}Also works with render props pattern:
// page.tsx (Server Component)
const serverContent = <ServerRenderedContent data={data} />
return (
<ClientTabs
tabs={[
{ label: 'Tab 1', content: serverContent },
{ label: 'Tab 2', content: <OtherServerContent /> },
]}
/>
)Use Composition to Minimize Client JS
Use component composition to keep Server Components for static content while Client Components handle interactivity.
CRA Pattern (before):
// src/components/Sidebar.tsx
import { useState } from 'react'
export default function Sidebar({ children }) {
const [isOpen, setIsOpen] = useState(true)
return (
<aside className={isOpen ? 'open' : 'closed'}>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
<nav>
<a href="/home">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
{children}
</aside>
)
}Next.js - Composition Pattern (after):
// components/SidebarWrapper.tsx
'use client'
import { useState } from 'react'
export function SidebarWrapper({ children }) {
const [isOpen, setIsOpen] = useState(true)
return (
<aside className={isOpen ? 'open' : 'closed'}>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{children} {/* Server Components passed as children */}
</aside>
)
}
// components/SidebarNav.tsx - Server Component (no 'use client')
export function SidebarNav() {
return (
<nav>
<a href="/home">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
)
}
// app/layout.tsx - Server Component
import { SidebarWrapper } from '@/components/SidebarWrapper'
import { SidebarNav } from '@/components/SidebarNav'
export default function Layout({ children }) {
return (
<div>
<SidebarWrapper>
<SidebarNav /> {/* This stays a Server Component! */}
</SidebarWrapper>
<main>{children}</main>
</div>
)
}Key insight: When you pass Server Components as children to a Client Component, they remain Server Components. Only the wrapper has client-side JavaScript.
This pattern is especially useful for:
- Modals with static content
- Collapsible sections
- Tab panels
- Any interactive container with static children
Handle Context Providers Properly
React Context uses useContext which requires 'use client'. Wrap providers at the right level in your app.
CRA Pattern (before):
// src/App.tsx
import { ThemeProvider } from './ThemeContext'
import { AuthProvider } from './AuthContext'
function App() {
return (
<AuthProvider>
<ThemeProvider>
<Router>
<Routes>{/* ... */}</Routes>
</Router>
</ThemeProvider>
</AuthProvider>
)
}Next.js Pattern (after):
// providers/Providers.tsx
'use client'
import { ThemeProvider } from './ThemeContext'
import { AuthProvider } from './AuthContext'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
)
}
// app/layout.tsx (Server Component)
import { Providers } from '@/providers/Providers'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}Creating a context:
// contexts/ThemeContext.tsx
'use client'
import { createContext, useContext, useState } from 'react'
const ThemeContext = createContext<{
theme: string
setTheme: (theme: string) => void
} | null>(null)
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within ThemeProvider')
return context
}Using context (requires 'use client'):
// components/ThemeToggle.tsx
'use client'
import { useTheme } from '@/contexts/ThemeContext'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>Toggle</button>
}Interleave Server and Client Components
Server and Client Components can be mixed throughout your component tree. Understand the rendering rules.
CRA Mental Model (before):
// Flat - everything is client
<App>
<Header />
<Sidebar />
<Content />
<Footer />
</App>Next.js Interleaving (after):
// app/page.tsx (Server Component)
import { ClientCounter } from './ClientCounter'
export default async function Page() {
const data = await fetchData() // Server
return (
<div>
<ServerHeader data={data} /> {/* Server */}
<ClientCounter /> {/* Client */}
<ServerContent data={data}> {/* Server */}
<ClientInteractive /> {/* Client */}
<ServerNested> {/* Server (via children) */}
<ClientButton /> {/* Client */}
</ServerNested>
</ServerContent>
</div>
)
}Rules for interleaving:
1. Server → Client: Server Components can import Client Components 2. Client → Server: Client Components can accept Server Components as children or props 3. Client cannot import Server: Can't import ServerComponent from './Server' in a Client Component
Pattern: Server in Client via children:
// ClientModal.tsx
'use client'
export function ClientModal({ children, isOpen }) {
if (!isOpen) return null
return <div className="modal">{children}</div>
}
// page.tsx (Server Component)
import { ClientModal } from './ClientModal'
import { ServerContent } from './ServerContent'
export default function Page() {
return (
<ClientModal isOpen={true}>
<ServerContent /> {/* Works! Passed as children */}
</ClientModal>
)
}This won't work:
// ClientComponent.tsx
'use client'
import ServerComponent from './ServerComponent' // Error!
export function ClientComponent() {
return <ServerComponent /> // Can't import Server in Client
}Ensure Props are Serializable
Props passed from Server Components to Client Components must be serializable (convertible to JSON).
CRA Pattern (before):
// Can pass anything - all client-side
<UserCard
user={user}
onClick={() => console.log('clicked')} // Function - fine in CRA
renderHeader={(u) => <h1>{u.name}</h1>} // Render prop - fine in CRA
/>Next.js - Must be serializable (after):
// app/page.tsx (Server Component)
import { UserCard } from './UserCard'
export default async function Page() {
const user = await fetchUser()
return (
<UserCard
user={user} // ✅ Object - serializable
userId={user.id} // ✅ Primitive - serializable
tags={['admin', 'active']} // ✅ Array - serializable
// onClick={() => {}} // ❌ Function - NOT serializable
// renderHeader={(u) => <h1>{u}</h1>} // ❌ Function - NOT serializable
// connection={dbConnection} // ❌ Class instance - NOT serializable
/>
)
}Serializable types:
- Primitives: string, number, boolean, null, undefined
- Plain objects and arrays (with serializable values)
- Date (serialized as string)
- Map, Set (converted to array)
- React elements (JSX)
Not serializable:
- Functions, callbacks, event handlers
- Class instances
- Symbols
- Circular references
Solution - Move logic to Client Component:
// page.tsx (Server Component)
export default async function Page() {
const user = await fetchUser()
return <UserCard user={user} /> // Just pass data
}
// UserCard.tsx (Client Component)
'use client'
export function UserCard({ user }) {
// Define handlers in the Client Component
const handleClick = () => {
console.log('clicked', user.id)
}
return (
<div onClick={handleClick}>
<h1>{user.name}</h1>
</div>
)
}Server Components are Default
In Next.js App Router, all components are Server Components by default. This is a key paradigm shift from CRA.
CRA Mental Model (before):
// Everything runs in the browser
// All components are "client components"
export default function ProductList() {
// This code runs in the browser
const products = useProducts() // Client-side fetch
return <Grid items={products} />
}Next.js Mental Model (after):
// app/products/page.tsx
// This is a SERVER Component by default
export default async function ProductList() {
// This code runs on the SERVER
const products = await db.products.findMany() // Direct DB access!
return <Grid items={products} />
}Server Component benefits:
- Direct database/filesystem access
- Keep sensitive data on server (API keys, tokens)
- Zero client-side JavaScript for static content
- Better performance (no hydration needed)
- Automatic code splitting
Server Component limitations:
- No
useState,useEffect,useReducer - No event handlers (
onClick,onChange) - No browser APIs
- No
useContext - No custom hooks that use the above
Practical implication:
// app/page.tsx - Server Component
import ClientButton from './ClientButton'
export default async function Page() {
const data = await fetchData() // Runs on server
return (
<div>
<h1>{data.title}</h1>
{/* Interactive parts use Client Components */}
<ClientButton />
</div>
)
}Think: "What needs interactivity?" Only those parts need 'use client'.
Wrap Third-Party Client Components
Many third-party libraries export Client Components that need 'use client'. Create wrapper components to use them in Server Components.
CRA Pattern (before):
// Direct import works - everything is client
import { Carousel } from 'some-carousel-library'
import { DatePicker } from 'some-datepicker'
export default function Page() {
return (
<div>
<Carousel items={items} />
<DatePicker onChange={handleChange} />
</div>
)
}Next.js Pattern (after):
// components/Carousel.tsx
'use client'
// Re-export with 'use client' directive
export { Carousel } from 'some-carousel-library'
// Or create a wrapper with custom props
import { Carousel as BaseCarousel } from 'some-carousel-library'
export function Carousel(props) {
return <BaseCarousel {...props} />
}// app/page.tsx (Server Component)
import { Carousel } from '@/components/Carousel'
export default async function Page() {
const items = await fetchItems()
return (
<div>
<Carousel items={items} />
</div>
)
}Pattern for multiple components:
// components/ui/index.tsx
'use client'
// Re-export all client components from one file
export { Carousel } from 'some-carousel-library'
export { DatePicker } from 'some-datepicker'
export { Select, Dropdown } from 'some-ui-library'Check if library supports Server Components:
Some modern libraries export separate client/server entry points:
// Some libraries have RSC support
import { ServerSafeComponent } from 'modern-library' // Works in Server Components
import { ClientComponent } from 'modern-library/client' // Needs 'use client'Always check the library's documentation for Next.js App Router compatibility.
React 19 Compatibility
Next.js 16+ uses React 19, which changes how refs are forwarded to components. Many third-party libraries may show warnings or errors until they're updated.
Common warnings:
Warning: Function components cannot be given refs. Attempts to access
this ref will fail. Did you mean to use React.forwardRef()?Accessing element.ref was removed in React 19. ref is now a regular prop.
It will be removed from the JSX Element type in a future release.The second error occurs when library code internally accesses element.ref using React's deprecated API. This is common with Radix UI components like Popover.Trigger, Select.Trigger, etc. See gotchas-react19-ref-prop.md for detailed solutions.
Affected libraries (check for updates):
- Radix UI primitives
- Headless UI
- Reach UI
- React Aria
- Downshift
- React Select
- Older versions of Material UI
Solution 1: Update to React 19 compatible versions
# Check for updates
npm outdated
# Update specific packages
npm update @radix-ui/react-dialog @radix-ui/react-dropdown-menuMost actively maintained libraries have released React 19 compatible versions.
Solution 2: Temporary wrapper to suppress warnings
If updates aren't available yet, create a wrapper component:
'use client'
import { forwardRef } from 'react'
import { Dialog as RadixDialog } from '@radix-ui/react-dialog'
// Wrapper that properly forwards refs
export const Dialog = forwardRef<HTMLDivElement, React.ComponentProps<typeof RadixDialog>>(
(props, ref) => {
return <RadixDialog {...props} ref={ref} />
}
)
Dialog.displayName = 'Dialog'Solution 3: Use legacy ref pattern
For components that don't support ref forwarding:
'use client'
import { useRef, useEffect } from 'react'
import { SomeComponent } from 'legacy-library'
function Wrapper() {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// Access the DOM through the container if needed
const element = containerRef.current?.querySelector('.target-element')
}, [])
return (
<div ref={containerRef}>
<SomeComponent />
</div>
)
}Check library compatibility:
Before starting migration, audit your dependencies:
# List all dependencies
npm ls --depth=0
# Check each UI library's GitHub issues/releases for React 19 supportMost popular libraries have React 19 compatibility in their latest versions. Prioritize updating these dependencies early in the migration process.
See also: gotchas-dynamic-imports.md for code splitting with next/dynamic.
Add 'use client' Directive for Client Components
In Next.js App Router, components are Server Components by default. Add 'use client' for components that need browser APIs or React hooks.
CRA Pattern (before):
// All components are client components by default
// src/components/Counter.tsx
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
)
}Next.js App Router (after):
// components/Counter.tsx
'use client' // Required! Must be at the top of the file
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
)
}When you need 'use client':
| Feature | Needs 'use client' |
|---|---|
useState, useReducer | Yes |
useEffect, useLayoutEffect | Yes |
onClick, onChange, etc. | Yes |
useContext | Yes |
| Browser APIs (window, document) | Yes |
| Third-party hooks | Usually yes |
| Just rendering props | No |
| Async data fetching | No (prefer Server) |
Common mistake - unnecessary 'use client':
// DON'T add 'use client' just for props
// This can be a Server Component
export default function UserCard({ user }) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}See also: gotchas-window-undefined.md for handling browser APIs in SSR.
Configure Fetch Caching Behavior
Next.js extends the native fetch API with caching options. Understand the defaults and how to override them.
CRA Pattern (before):
// No automatic caching - relies on browser cache or manual implementation
fetch('/api/data')Next.js Caching Options (after):
// Force cache (static data) - cached until manually revalidated
const staticData = await fetch('https://api.example.com/static', {
cache: 'force-cache' // Default in App Router
})
// No cache (dynamic data) - fresh on every request
const dynamicData = await fetch('https://api.example.com/dynamic', {
cache: 'no-store'
})
// Time-based revalidation - cached, refreshed periodically
const timedData = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // Refresh every hour
})
// Tag-based - for on-demand revalidation
const taggedData = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})Page-level cache configuration:
// app/posts/page.tsx
// Make entire page dynamic
export const dynamic = 'force-dynamic'
// Or configure revalidation for all fetches in page
export const revalidate = 60
// Or make it fully static
export const dynamic = 'force-static'Opting out of caching for specific routes:
// app/api/realtime/route.ts
export const dynamic = 'force-dynamic'
export const revalidate = 0
export async function GET() {
const data = await getRealTimeData()
return Response.json(data)
}Note: In development, caching is disabled by default for easier debugging. Test caching behavior with next build && next start.
Keep Client Fetches with Proper Patterns
Some data fetching must remain client-side (user interactions, real-time updates). Use proper patterns like SWR or React Query.
CRA Pattern (before):
// src/components/SearchResults.tsx
import { useState, useEffect } from 'react'
export default function SearchResults({ query }) {
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!query) return
setLoading(true)
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults)
.finally(() => setLoading(false))
}, [query])
return loading ? <Spinner /> : <Results data={results} />
}Next.js with SWR (after):
// components/SearchResults.tsx
'use client'
import useSWR from 'swr'
const fetcher = (url: string) => fetch(url).then(res => res.json())
export default function SearchResults({ query }) {
const { data, error, isLoading } = useSWR(
query ? `/api/search?q=${query}` : null,
fetcher
)
if (isLoading) return <Spinner />
if (error) return <Error />
return <Results data={data} />
}Next.js with React Query (after):
'use client'
import { useQuery } from '@tanstack/react-query'
export default function SearchResults({ query }) {
const { data, isLoading, error } = useQuery({
queryKey: ['search', query],
queryFn: () => fetch(`/api/search?q=${query}`).then(res => res.json()),
enabled: !!query,
})
if (isLoading) return <Spinner />
if (error) return <Error />
return <Results data={data} />
}When to use client-side fetching:
- User-triggered searches/filters
- Real-time data (polling, WebSockets)
- Infinite scroll/pagination
- Data that changes based on client state
- After user interactions
Remember to add 'use client' directive for components using these hooks.
Initialize Client-Only Libraries in useEffect
Module-level initialization of client-only libraries runs on the server where browser APIs may not be available. Initialize these libraries in a client component's useEffect instead.
Problem: Module-level initialization
// BAD - Runs on server during import
import i18n from 'i18next';
i18n.init({
lng: localStorage.getItem('lang') || 'en', // localStorage undefined on server!
});
export default i18n;// This import triggers initialization on server
import '@/lib/i18n'; // Crashes: localStorage is not definedSolution: Initialize in useEffect with guard
// providers/ClientLibraryProvider.tsx
'use client';
import { useEffect, useState } from 'react';
export function ClientLibraryProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
useEffect(() => {
const init = async () => {
// Safe to access browser APIs here
await initializeLibrary();
setReady(true);
};
init();
}, []);
if (!ready) {
return null; // Or a loading skeleton
}
return <>{children}</>;
}Example: i18next
// lib/i18n.ts - Configuration only, no init call
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
i18n.use(initReactI18next);
export const i18nConfig = {
resources: { /* ... */ },
fallbackLng: 'en',
interpolation: { escapeValue: false },
};
export default i18n;// providers/I18nProvider.tsx
'use client';
import { I18nextProvider } from 'react-i18next';
import { useEffect, useState } from 'react';
import i18n, { i18nConfig } from '@/lib/i18n';
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(i18n.isInitialized);
useEffect(() => {
if (!i18n.isInitialized) {
i18n.init({
...i18nConfig,
lng: localStorage.getItem('lang') || 'en',
}).then(() => setReady(true));
}
}, []);
if (!ready) return null;
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}Example: Analytics (Segment, Mixpanel, etc.)
// providers/AnalyticsProvider.tsx
'use client';
import { useEffect, useRef } from 'react';
import { AnalyticsBrowser } from '@segment/analytics-next';
export const analytics = AnalyticsBrowser.load({ writeKey: '' }); // Lazy, doesn't run yet
export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
const initialized = useRef(false);
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
// Now safe to initialize with browser context
analytics.load({ writeKey: process.env.NEXT_PUBLIC_SEGMENT_KEY! });
}, []);
return <>{children}</>;
}Example: Feature flags (LaunchDarkly)
// providers/FeatureFlagProvider.tsx
'use client';
import { LDProvider } from 'launchdarkly-react-client-sdk';
import { useEffect, useState } from 'react';
export function FeatureFlagProvider({ children }: { children: React.ReactNode }) {
const [clientId, setClientId] = useState<string | null>(null);
useEffect(() => {
// Can read from localStorage, cookies, or other browser APIs
const userId = localStorage.getItem('userId') || 'anonymous';
setClientId(userId);
}, []);
if (!clientId) return null;
return (
<LDProvider
clientSideID={process.env.NEXT_PUBLIC_LD_CLIENT_ID!}
context={{ kind: 'user', key: clientId }}
>
{children}
</LDProvider>
);
}Pattern summary:
1. Don't call initialization at module level - It runs on the server 2. Use `isInitialized` checks - Many libraries provide this 3. Initialize in useEffect - Browser APIs are available 4. Show loading state - Return null or skeleton until ready 5. Use refs for idempotency - Prevent double-init in StrictMode
Libraries that commonly need this pattern:
- i18next / react-i18next
- Analytics (Segment, Mixpanel, Amplitude)
- Feature flags (LaunchDarkly, Split)
- Error tracking (Sentry browser SDK)
- Real-time services (Pusher, Ably)
- Any library that reads localStorage/cookies on init
Fetch Data in Parallel on Server
When fetching multiple independent pieces of data, fetch them in parallel to avoid waterfalls.
CRA Pattern - Sequential (before):
useEffect(() => {
async function fetchData() {
const user = await fetch('/api/user').then(r => r.json())
const posts = await fetch('/api/posts').then(r => r.json())
const comments = await fetch('/api/comments').then(r => r.json())
// Total time: user + posts + comments
}
fetchData()
}, [])Next.js - Parallel Fetching (after):
// app/dashboard/page.tsx
export default async function Dashboard() {
// Start all fetches simultaneously
const [user, posts, comments] = await Promise.all([
fetch('https://api.example.com/user').then(r => r.json()),
fetch('https://api.example.com/posts').then(r => r.json()),
fetch('https://api.example.com/comments').then(r => r.json()),
])
// Total time: max(user, posts, comments)
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
<Comments comments={comments} />
</div>
)
}With separate async components (parallel by default):
// app/dashboard/page.tsx
import { Suspense } from 'react'
export default function Dashboard() {
// These components fetch in parallel automatically
return (
<div>
<Suspense fallback={<Skeleton />}>
<UserProfile /> {/* Fetches user */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<PostList /> {/* Fetches posts */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<Comments /> {/* Fetches comments */}
</Suspense>
</div>
)
}
async function UserProfile() {
const user = await fetchUser()
return <Profile user={user} />
}Key insight: Sibling async Server Components automatically fetch in parallel when wrapped in separate Suspense boundaries.
Configure Data Revalidation Strategies
Next.js provides multiple revalidation strategies to control when cached data is refreshed.
CRA Pattern (before):
// No built-in caching - every fetch is fresh
useEffect(() => {
fetch('/api/posts').then(res => res.json()).then(setPosts)
}, [])Next.js App Router - Time-based Revalidation:
// app/posts/page.tsx
export default async function Posts() {
// Revalidate every 60 seconds
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }
})
const posts = await res.json()
return <PostList posts={posts} />
}Next.js App Router - On-demand Revalidation:
// app/posts/page.tsx
export default async function Posts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
const posts = await res.json()
return <PostList posts={posts} />
}
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
export async function POST() {
revalidateTag('posts')
return Response.json({ revalidated: true })
}Revalidation options:
// No caching - always fresh
fetch(url, { cache: 'no-store' })
// Cache forever (default for static)
fetch(url, { cache: 'force-cache' })
// Time-based revalidation
fetch(url, { next: { revalidate: 3600 } })
// Tag-based for on-demand revalidation
fetch(url, { next: { tags: ['collection'] } })Page-level configuration:
// app/posts/page.tsx
export const revalidate = 60 // Revalidate every 60 seconds
export const dynamic = 'force-dynamic' // Always SSR
export default async function Posts() {
// ...
}Revalidation timeline (stale-while-revalidate):
1. Build: Page rendered, cached 2. Request at 0s: Serve cached page 3. Request at 30s: Serve cached page (< 60s) 4. Request at 61s: Serve cached page, trigger background revalidation 5. Request at 62s: Serve NEW cached page (if revalidation succeeded)
Key insight: Revalidation is "stale-while-revalidate" - the NEXT request gets fresh data, not the current one.
Testing revalidation:
# Build and start production server
npm run build && npm start
# Revalidation doesn't work in development!
# Always test with production buildHandle Sequential Data Dependencies
When one fetch depends on another's result, handle the sequence properly while still optimizing where possible.
CRA Pattern (before):
useEffect(() => {
async function fetchData() {
const user = await fetch('/api/user').then(r => r.json())
// Posts depend on user.id
const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json())
setData({ user, posts })
}
fetchData()
}, [])Next.js Server Component (after):
// app/profile/page.tsx
export default async function Profile() {
// First fetch - required for subsequent fetches
const user = await fetchUser()
// Dependent fetch - needs user.id
const posts = await fetchPosts(user.id)
return (
<div>
<UserInfo user={user} />
<PostList posts={posts} />
</div>
)
}Optimized with parallel where possible:
// app/profile/page.tsx
export default async function Profile() {
const user = await fetchUser()
// These don't depend on each other, fetch in parallel
const [posts, followers, settings] = await Promise.all([
fetchPosts(user.id),
fetchFollowers(user.id),
fetchSettings(user.id),
])
return (
<div>
<UserInfo user={user} />
<PostList posts={posts} />
<Followers followers={followers} />
<Settings settings={settings} />
</div>
)
}With Suspense for progressive loading:
// app/profile/page.tsx
import { Suspense } from 'react'
export default async function Profile() {
const user = await fetchUser()
return (
<div>
<UserInfo user={user} />
{/* Posts can load after user is shown */}
<Suspense fallback={<PostsSkeleton />}>
<UserPosts userId={user.id} />
</Suspense>
</div>
)
}
async function UserPosts({ userId }) {
const posts = await fetchPosts(userId)
return <PostList posts={posts} />
}Use Server Actions for Mutations
CRA handles form submissions with client-side fetch. Next.js Server Actions provide a simpler pattern for mutations.
CRA Pattern (before):
// src/components/ContactForm.tsx
import { useState } from 'react'
export default function ContactForm() {
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
const formData = new FormData(e.target)
const res = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(formData)),
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) {
alert('Message sent!')
}
setLoading(false)
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
<button disabled={loading}>Send</button>
</form>
)
}Next.js Server Action (after):
// app/contact/page.tsx
async function submitContact(formData: FormData) {
'use server'
const email = formData.get('email')
const message = formData.get('message')
await db.contacts.create({ email, message })
// Optionally revalidate cache
revalidatePath('/contacts')
}
export default function ContactPage() {
return (
<form action={submitContact}>
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
)
}With client-side pending state:
'use client'
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? 'Sending...' : 'Send'}</button>
}
export default function ContactForm({ action }) {
return (
<form action={action}>
<input name="email" type="email" required />
<SubmitButton />
</form>
)
}Benefits:
- No API route needed for simple mutations
- Progressive enhancement (works without JS)
- Type-safe with TypeScript
- Automatic revalidation integration
Use Suspense for Streaming Data
Next.js supports streaming HTML with Suspense, allowing parts of the page to load progressively.
CRA Pattern (before):
// src/pages/Dashboard.tsx
import { useState, useEffect } from 'react'
export default function Dashboard() {
const [user, setUser] = useState(null)
const [stats, setStats] = useState(null)
const [activity, setActivity] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/stats').then(r => r.json()),
fetch('/api/activity').then(r => r.json()),
]).then(([u, s, a]) => {
setUser(u)
setStats(s)
setActivity(a)
setLoading(false)
})
}, [])
if (loading) return <FullPageLoader />
// All data or nothing
}Next.js with Streaming (after):
// app/dashboard/page.tsx
import { Suspense } from 'react'
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* User info loads first */}
<Suspense fallback={<UserSkeleton />}>
<UserInfo />
</Suspense>
{/* Stats stream in independently */}
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
{/* Activity streams in independently */}
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</div>
)
}
// Each component fetches its own data
async function UserInfo() {
const user = await fetchUser() // Slow? Only this section waits
return <UserCard user={user} />
}
async function Stats() {
const stats = await fetchStats()
return <StatsDisplay stats={stats} />
}
async function ActivityFeed() {
const activity = await fetchActivity()
return <Activity items={activity} />
}Benefits:
- Page shell appears immediately
- Each section loads independently
- No waterfall - all fetches start in parallel
- Better perceived performance
- Progressive enhancement
Convert useEffect Fetches to Server Components
CRA fetches data in useEffect on the client. Next.js Server Components can fetch data directly during render on the server.
CRA Pattern (before):
// src/pages/Users.tsx
import { useState, useEffect } from 'react'
export default function Users() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => setUsers(data))
.catch(err => setError(err))
.finally(() => setLoading(false))
}, [])
if (loading) return <Spinner />
if (error) return <Error message={error.message} />
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}Next.js Server Component (after):
// app/users/page.tsx
// No 'use client' - this is a Server Component by default
export default async function Users() {
const res = await fetch('https://api.example.com/users')
const users = await res.json()
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}Benefits:
- No loading/error state management (use
loading.tsxanderror.tsx) - No client-side JavaScript for data fetching
- Data fetched at request time on server
- Better SEO - content in initial HTML
- No useEffect waterfall - data fetched before render
With error handling:
// app/users/page.tsx
export default async function Users() {
const res = await fetch('https://api.example.com/users')
if (!res.ok) {
throw new Error('Failed to fetch users')
}
const users = await res.json()
return <UserList users={users} />
}Convert useEffect to getStaticProps (Pages Router)
For static content that doesn't change often, use getStaticProps to fetch data at build time.
CRA Pattern (before):
// src/pages/Blog.tsx
import { useState, useEffect } from 'react'
export default function Blog() {
const [posts, setPosts] = useState([])
useEffect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(setPosts)
}, [])
return <PostList posts={posts} />
}Next.js Pages Router (after):
// pages/blog.tsx
import { GetStaticProps } from 'next'
interface Props {
posts: Post[]
}
export default function Blog({ posts }: Props) {
return <PostList posts={posts} />
}
export const getStaticProps: GetStaticProps<Props> = async () => {
const res = await fetch('https://api.example.com/posts')
const posts = await res.json()
return {
props: {
posts,
},
revalidate: 3600, // Regenerate every hour (ISR)
}
}With dynamic routes:
// pages/blog/[slug].tsx
import { GetStaticProps, GetStaticPaths } from 'next'
export const getStaticPaths: GetStaticPaths = async () => {
const res = await fetch('https://api.example.com/posts')
const posts = await res.json()
return {
paths: posts.map(post => ({ params: { slug: post.slug } })),
fallback: 'blocking', // or false, or true
}
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
const res = await fetch(`https://api.example.com/posts/${params.slug}`)
const post = await res.json()
return {
props: { post },
revalidate: 60,
}
}When to use:
- Content doesn't change frequently
- Same content for all users
- SEO is important
- Performance is critical
For App Router, use fetch() with caching options in Server Components instead.
Convert useEffect to getServerSideProps (Pages Router)
For Pages Router migrations, use getServerSideProps for server-side data fetching on every request.
CRA Pattern (before):
// src/pages/Dashboard.tsx
import { useState, useEffect } from 'react'
export default function Dashboard() {
const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/stats')
.then(res => res.json())
.then(setStats)
.finally(() => setLoading(false))
}, [])
if (loading) return <Loading />
return <StatsDisplay stats={stats} />
}Next.js Pages Router (after):
// pages/dashboard.tsx
import { GetServerSideProps } from 'next'
interface Props {
stats: Stats
}
export default function Dashboard({ stats }: Props) {
// No loading state - data is already available
return <StatsDisplay stats={stats} />
}
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
const res = await fetch('https://api.example.com/stats')
const stats = await res.json()
return {
props: {
stats,
},
}
}When to use:
- Data must be fresh on every request
- Data depends on request (cookies, headers, query params)
- Page requires authentication check
With request context:
export const getServerSideProps: GetServerSideProps = async ({ req, query }) => {
const token = req.cookies.token
const page = query.page || '1'
const res = await fetch(`https://api.example.com/data?page=${page}`, {
headers: { Authorization: `Bearer ${token}` }
})
return { props: { data: await res.json() } }
}Note: For App Router, prefer Server Components over getServerSideProps.
Upgrade Dependencies for React 19 Compatibility
Next.js 14+ with React 19 requires major version upgrades for several common packages. These packages will fail or show warnings if not upgraded.
Required dependency upgrades:
| Package | CRA Version | Next.js (React 19) Version |
|---|---|---|
react-redux | ^8.x | ^9.0.0 |
@reduxjs/toolkit | ^1.x | ^2.0.0 |
react-router-dom | ^6.x | Remove (use Next.js routing) |
react-tailwindcss-datepicker | ^1.x | ^2.0.0 |
eslint | ^8.x | ^9.0.0 |
Check for React 19 compatibility before migrating:
# Check for packages that may need updates
npm ls react
npm outdatedCommon compatibility issues:
1. Redux Toolkit v2 - Removes object notation for extraReducers (see state-redux rule) 2. react-redux v9 - Requires RTK v2, new hook typing patterns 3. ESLint v9 - Flat config format, many plugins need updates 4. Date pickers - Most React date picker libraries needed React 19 updates
Upgrade strategy:
# Upgrade Redux ecosystem together
npm install @reduxjs/toolkit@^2.0.0 react-redux@^9.0.0
# Check peer dependency warnings
npm install 2>&1 | grep "peer dep"Finding React 19 compatible versions:
Check the package's GitHub releases or npm page for React 19 support. Look for:
- Release notes mentioning "React 19"
peerDependenciesallowingreact: "^19.0.0"
Understand Build-Time vs Runtime Env Vars
Environment variables in Next.js are inlined at build time, not read at runtime by default.
CRA Behavior:
// Variables are inlined during `npm run build`
const url = process.env.REACT_APP_API_URL
// After build: const url = "https://api.example.com"Next.js - Same behavior by default:
// NEXT_PUBLIC_ vars are inlined at build time
const url = process.env.NEXT_PUBLIC_API_URL
// After build, this becomes a string literalImplication for deployments:
# Wrong expectation
NEXT_PUBLIC_API_URL=https://staging.api.com npm run build
# Deploy to staging...
NEXT_PUBLIC_API_URL=https://prod.api.com
# Deploy same build to prod - STILL uses staging URL!Solutions:
1. Build per environment:
# Staging build
NEXT_PUBLIC_API_URL=https://staging.api.com npm run build
# Deploy to staging
# Production build (separate)
NEXT_PUBLIC_API_URL=https://prod.api.com npm run build
# Deploy to production2. Use server-side env vars:
// app/api/config/route.ts
export async function GET() {
return Response.json({
apiUrl: process.env.API_URL // Read at runtime on server
})
}3. Use runtime configuration:
// next.config.js
module.exports = {
publicRuntimeConfig: {
apiUrl: process.env.API_URL,
},
}Best practice: Use server-side env vars for sensitive/dynamic values, NEXT_PUBLIC_ only for truly static public values.
Using .env files:
# .env.production
NEXT_PUBLIC_API_URL=https://api.com
# .env.staging (custom)
NEXT_PUBLIC_API_URL=https://staging-api.com
# Build with custom env
npx dotenv -e .env.staging -- next buildCI/CD configuration:
# GitHub Actions example
jobs:
build:
steps:
- name: Build
env:
NEXT_PUBLIC_API_URL: ${{ vars.API_URL }}
NEXT_PUBLIC_GA_ID: ${{ vars.GA_ID }}
run: npm run buildUnderstand .env File Loading Order
Next.js loads environment files in a specific order, which differs slightly from CRA.
CRA Loading Order:
.env.local (highest priority)
.env.development / .env.production / .env.test
.env (lowest priority)Next.js Loading Order:
process.env (highest - system/CLI vars)
.env.$(NODE_ENV).local (e.g., .env.development.local)
.env.local (NOT loaded in test environment)
.env.$(NODE_ENV) (e.g., .env.development)
.env (lowest priority)File purposes:
| File | Committed | Purpose |
|---|---|---|
.env | Yes | Default values for all environments |
.env.local | No | Local overrides (secrets) |
.env.development | Yes | Development defaults |
.env.development.local | No | Local dev overrides |
.env.production | Yes | Production defaults |
.env.production.local | No | Local prod testing |
.env.test | Yes | Test environment |
Example setup:
# .env (committed)
NEXT_PUBLIC_APP_NAME=MyApp
# .env.local (NOT committed - add to .gitignore)
DATABASE_URL=postgres://localhost/mydb
API_SECRET=local_secret
# .env.production (committed)
NEXT_PUBLIC_API_URL=https://api.myapp.com
# .env.development (committed)
NEXT_PUBLIC_API_URL=http://localhost:3001Accessing in code:
// Same as CRA
const appName = process.env.NEXT_PUBLIC_APP_NAMEChange REACT_APP_ to NEXT_PUBLIC_
CRA uses REACT_APP_ prefix for browser-exposed variables. Next.js uses NEXT_PUBLIC_.
CRA Pattern (before):
# .env
REACT_APP_API_URL=https://api.example.com
REACT_APP_GOOGLE_ANALYTICS_ID=UA-123456
REACT_APP_FEATURE_FLAG=true// src/api.ts
const apiUrl = process.env.REACT_APP_API_URLNext.js Pattern (after):
# .env
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=UA-123456
NEXT_PUBLIC_FEATURE_FLAG=true// lib/api.ts
const apiUrl = process.env.NEXT_PUBLIC_API_URLMigration script:
# Rename all REACT_APP_ to NEXT_PUBLIC_ in .env files
sed -i '' 's/REACT_APP_/NEXT_PUBLIC_/g' .env .env.local .env.development .env.production
# Find and replace in code
find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" \) \
-exec sed -i '' 's/REACT_APP_/NEXT_PUBLIC_/g' {} +Key differences:
| CRA | Next.js | Exposed to Browser |
|---|---|---|
REACT_APP_* | NEXT_PUBLIC_* | Yes |
| Other vars | Other vars | No (server only) |
Important: Only variables starting with NEXT_PUBLIC_ are exposed to the browser. All other variables are server-only for security.
Discovery Strategy:
Before migrating, find all environment variable references to ensure none are missed:
# Find all REACT_APP_ references in source files
grep -r "REACT_APP_" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" src/
# Find all REACT_APP_ references in env files
grep "REACT_APP_" .env* 2>/dev/null
# List unique environment variables used
grep -roh "REACT_APP_[A-Z_]*" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" src/ | sort -uVerification Checklist:
After migration, verify all references are updated:
# Should return no results if migration is complete
grep -r "REACT_APP_" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" .
# Verify new variables are set
grep "NEXT_PUBLIC_" .env* 2>/dev/nullCommonly Missed Locations:
Don't forget to update environment variables in:
.env.exampleor.env.templatefiles- CI/CD configuration (GitHub Actions, CircleCI, etc.)
- Docker files and docker-compose.yml
- Deployment platform settings (Vercel, Netlify, AWS)
- Documentation and README files
- Shell scripts that set environment variables
- Test configuration files (jest.config.js, cypress.config.js)
Use Runtime Configuration When Needed
For values that need to change without rebuilding, use runtime configuration instead of build-time env vars.
CRA Pattern (before):
// All env vars are baked in at build time
const apiUrl = process.env.REACT_APP_API_URL
// Must rebuild to changeNext.js - Build-time (default):
// next.config.js
module.exports = {
env: {
API_URL: process.env.API_URL, // Baked in at build
},
}Next.js - Runtime configuration:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Runtime env vars (server-side)
serverRuntimeConfig: {
apiSecret: process.env.API_SECRET,
},
// Runtime env vars (both server and client)
publicRuntimeConfig: {
apiUrl: process.env.API_URL,
},
}
module.exports = nextConfig// Using runtime config
import getConfig from 'next/config'
const { serverRuntimeConfig, publicRuntimeConfig } = getConfig()
// Server-side only
const secret = serverRuntimeConfig.apiSecret
// Client and server
const apiUrl = publicRuntimeConfig.apiUrlModern alternative - Edge Config (Vercel):
import { get } from '@vercel/edge-config'
export default async function Page() {
const featureFlag = await get('newFeatureEnabled')
// Can change without redeploy
}When to use runtime config:
- Feature flags that change frequently
- Multi-tenant applications
- A/B testing configurations
- Environment-specific URLs that change between deploys
Use Non-Prefixed Vars for Server-Only
Variables without NEXT_PUBLIC_ prefix are only available on the server, keeping secrets secure.
CRA Pattern (before):
# .env - All accessible (but only REACT_APP_ in browser)
REACT_APP_API_URL=https://api.example.com
DATABASE_URL=postgres://... # Not accessible in browser
API_SECRET=secret123 # Not accessible in browser// Had to use backend/API routes for secrets
// or risk exposing them in client bundleNext.js Pattern (after):
# .env
# Browser-accessible (client + server)
NEXT_PUBLIC_API_URL=https://api.example.com
# Server-only (NEVER sent to browser)
DATABASE_URL=postgres://user:pass@localhost/db
API_SECRET=super_secret_key
STRIPE_SECRET_KEY=sk_live_xxx// app/api/checkout/route.ts
// Server-only - safe to use secrets
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) // Safe!
export async function POST(request: Request) {
// Direct access to secrets in Route Handlers
}// app/users/page.tsx (Server Component)
// Also safe - Server Components run on server
export default async function Users() {
const users = await db.query({
connectionString: process.env.DATABASE_URL // Safe!
})
return <UserList users={users} />
}Security benefit:
// Client Component - secrets NOT accessible
'use client'
export function ClientComponent() {
// process.env.DATABASE_URL is undefined here
// process.env.NEXT_PUBLIC_API_URL works
}Validate Required Environment Variables
Validate environment variables at build/startup time to catch configuration errors early.
CRA Pattern (before):
// Often no validation - runtime errors
const apiUrl = process.env.REACT_APP_API_URL
// If undefined, might cause cryptic errors laterNext.js Pattern (after):
// lib/env.ts
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().url(),
API_SECRET: z.string().min(1),
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_APP_ENV: z.enum(['development', 'staging', 'production']),
})
// Validate at module load time
const env = envSchema.parse({
DATABASE_URL: process.env.DATABASE_URL,
API_SECRET: process.env.API_SECRET,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
})
export default envUsing validated env:
// app/api/users/route.ts
import env from '@/lib/env'
export async function GET() {
const db = connect(env.DATABASE_URL) // Type-safe!
}Type-safe without Zod:
// env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string
API_SECRET: string
NEXT_PUBLIC_API_URL: string
}
}
// lib/env.ts
function getEnvVar(key: string): string {
const value = process.env[key]
if (!value) {
throw new Error(`Missing environment variable: ${key}`)
}
return value
}
export const env = {
databaseUrl: getEnvVar('DATABASE_URL'),
apiSecret: getEnvVar('API_SECRET'),
}Using @t3-oss/env-nextjs:
// env.mjs
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
},
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
})Configure font-display Strategy
Configure how fonts are displayed while loading with the display option.
CRA Pattern (before):
@font-face {
font-family: 'CustomFont';
src: url('./font.woff2');
font-display: swap; /* Show fallback, swap when loaded */
}<!-- Or in Google Fonts URL -->
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap">Next.js Pattern (after):
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Same behavior
})Display options:
| Value | Behavior |
|---|---|
auto | Browser default |
block | Hide text until font loads (up to 3s) |
swap | Show fallback immediately, swap when ready |
fallback | Short block period, then fallback |
optional | Font may not be used if slow connection |
Recommendations:
// For body text - use swap
const bodyFont = Inter({
subsets: ['latin'],
display: 'swap',
})
// For icons/symbols - use block
const iconFont = localFont({
src: '../fonts/icons.woff2',
display: 'block', // Prevent icon flash
})
// For non-critical decorative fonts
const decorativeFont = Pacifico({
subsets: ['latin'],
display: 'optional', // Skip if slow connection
})Default behavior:
// next/font defaults to 'swap' which is recommended
const font = Inter({ subsets: ['latin'] })
// display: 'swap' is applied automaticallyLoad Google Fonts Properly
Use next/font/google to load Google Fonts with automatic optimization.
CRA Pattern (before):
<!-- public/index.html -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">Next.js Pattern (after):
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
)
}Variable fonts (recommended):
import { Inter } from 'next/font/google'
// Variable font - all weights included
const inter = Inter({
subsets: ['latin'],
})
// Use any weight in CSS
// font-weight: 100 to 900Static fonts (specific weights):
import { Roboto } from 'next/font/google'
const roboto = Roboto({
weight: ['400', '700'], // Only these weights
style: ['normal', 'italic'],
subsets: ['latin'],
})Multiple fonts:
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
})
const robotoMono = Roboto_Mono({
subsets: ['latin'],
variable: '--font-mono',
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
)
}Load Local Font Files
Use next/font/local to load custom font files with the same optimization benefits.
CRA Pattern (before):
/* src/fonts.css */
@font-face {
font-family: 'CustomFont';
src: url('./fonts/CustomFont-Regular.woff2') format('woff2'),
url('./fonts/CustomFont-Regular.woff') format('woff');
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: 'CustomFont';
src: url('./fonts/CustomFont-Bold.woff2') format('woff2');
font-weight: 700;
font-display: swap;
}
body {
font-family: 'CustomFont', sans-serif;
}Next.js Pattern (after):
// app/layout.tsx
import localFont from 'next/font/local'
const customFont = localFont({
src: [
{
path: '../fonts/CustomFont-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../fonts/CustomFont-Bold.woff2',
weight: '700',
style: 'normal',
},
],
variable: '--font-custom',
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={customFont.variable}>
<body>{children}</body>
</html>
)
}Single font file:
import localFont from 'next/font/local'
const myFont = localFont({
src: '../fonts/MyFont.woff2',
})
export default function Layout({ children }) {
return <main className={myFont.className}>{children}</main>
}Variable font file:
import localFont from 'next/font/local'
const myVariableFont = localFont({
src: '../fonts/MyVariableFont.woff2',
variable: '--font-my-variable',
})Font files should be placed in your project (e.g., fonts/ directory).
Use next/font for Optimization
Replace CSS font imports with next/font for automatic optimization and zero layout shift.
CRA Pattern (before):
/* src/index.css */
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap');
body {
font-family: 'Roboto', sans-serif;
}Next.js Pattern (after):
// app/layout.tsx
import { Roboto } from 'next/font/google'
const roboto = Roboto({
weight: ['400', '500', '700'],
subsets: ['latin'],
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={roboto.className}>
<body>{children}</body>
</html>
)
}Benefits of next/font:
- Self-hosted: Fonts downloaded at build time, served from your domain
- Zero layout shift: CSS
size-adjustprevents CLS - No external requests: No runtime calls to Google Fonts
- Automatic subset: Only loads needed characters
- Privacy: No data sent to Google at runtime
Using CSS variable:
const roboto = Roboto({
weight: ['400', '500', '700'],
subsets: ['latin'],
variable: '--font-roboto',
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={roboto.variable}>
<body>{children}</body>
</html>
)
}/* Use in CSS */
body {
font-family: var(--font-roboto);
}Multiple fonts:
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
})
const robotoMono = Roboto_Mono({
subsets: ['latin'],
variable: '--font-roboto-mono',
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
)
}With Tailwind:
// tailwind.config.js
module.exports = {
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)'],
mono: ['var(--font-roboto-mono)'],
},
},
},
}Understand Automatic Font Preloading
Next.js automatically preloads fonts configured with next/font. No manual preload links needed.
CRA Pattern (before):
<!-- Manual preload in index.html -->
<link
rel="preload"
href="/fonts/inter.woff2"
as="font"
type="font/woff2"
crossorigin
>Next.js Pattern (after):
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
// Font is automatically:
// 1. Downloaded at build time
// 2. Self-hosted from your domain
// 3. Preloaded in the HTML head
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
)
}What Next.js does automatically: 1. Downloads font files at build time 2. Generates optimized CSS with size-adjust 3. Injects preload links in HTML head 4. Serves fonts from your domain (no external requests)
Controlling preload:
// Disable preload for non-critical fonts
const decorativeFont = Pacifico({
subsets: ['latin'],
preload: false, // Don't preload this font
})Generated HTML:
<!-- Next.js generates this automatically -->
<link
rel="preload"
href="/_next/static/media/inter.woff2"
as="font"
type="font/woff2"
crossorigin
>
<style>
/* CSS with size-adjust for zero CLS */
</style>Summary: Just use next/font and preloading is handled automatically. No manual <link rel="preload"> needed.
Configure Variable Fonts
Variable fonts provide all weights in a single file, improving performance.
CRA Pattern (before):
/* Multiple font files for different weights */
@font-face {
font-family: 'Inter';
src: url('./fonts/Inter-Regular.woff2');
font-weight: 400;
}
@font-face {
font-family: 'Inter';
src: url('./fonts/Inter-Medium.woff2');
font-weight: 500;
}
@font-face {
font-family: 'Inter';
src: url('./fonts/Inter-Bold.woff2');
font-weight: 700;
}Next.js - Google variable font:
// app/layout.tsx
import { Inter } from 'next/font/google'
// Inter is a variable font - one file, all weights
const inter = Inter({
subsets: ['latin'],
// No 'weight' prop needed - includes all weights
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
)
}Next.js - Local variable font:
import localFont from 'next/font/local'
const interVariable = localFont({
src: '../fonts/Inter-VariableFont.woff2',
variable: '--font-inter',
})Using in CSS:
/* Can use any weight from 100-900 */
.heading {
font-weight: 700;
}
.body {
font-weight: 400;
}
.light {
font-weight: 300;
}Benefits:
- Single file for all weights
- Smaller total download
- Smooth weight transitions possible
- Better caching
Fix Empty Module Exports
TypeScript's isolatedModules option (enabled by default in Next.js) requires every file to have at least one import or export statement.
Error message:
'file.ts' cannot be compiled under '--isolatedModules' because it is
considered a global script file. Add an import, export, or an empty
'export {}' statement to make it a module.Common files affected:
pwa.ts- Progressive Web App registrationsw.ts- Service worker filesregister-sw.ts- Service worker registrationreportWebVitals.ts- Web vitals reporting (if empty/commented)- Type declaration files without exports
CRA (works without exports):
// src/pwa.ts
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
}
// No export needed in CRANext.js (requires export):
// pwa.ts
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
}
export {} // Add empty export to make it a moduleAlternative: Export the logic as a function
// pwa.ts
export function registerServiceWorker() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
}
}
// Call from your app
import { registerServiceWorker } from './pwa'
registerServiceWorker()Files to check during migration:
# Find TypeScript files without exports
grep -L "export" src/**/*.tsQuick fix pattern:
Add export {} at the end of any standalone TypeScript file that doesn't have imports or exports but contains executable code.
Related skills
How it compares
Pick cra-to-next-migration for exhaustive CRA-to-Next rule coverage instead of piecemeal React Router rewrites without impact-prioritized checklists.
FAQ
What does cra-to-next-migration do?
Comprehensive guide for migrating Create React App (CRA) projects to Next.js. Use when migrating a CRA app, converting React Router to file-based routing, or adopting Next.js patterns lik...
When should I use cra-to-next-migration?
Invoke when Comprehensive guide for migrating Create React App (CRA) projects to Next.js. Use when migrating a CRA app, converting React Router to file-.
Is cra-to-next-migration safe to install?
Review the Security Audits panel on this page before installing in production.