Next.js 16 + React 19 Complete Migration Guide
- Pages Router is removed in Next.js 16 β every route must move to app/. There is no compatibility layer, no fallback, no gradual migration path.
- The Rust compiler replaces Babel entirely β .babelrc files are silently ignored. Custom Babel plugins stop executing with no warning.
- forwardRef is removed in React 19 β ref is now a regular prop. The migration is mechanical but pervasive across your component library.
- Next.js 16 ships React 19 stable, the Rust-based Compiler replaces Babel, and Pages Router is fully removed
- Server Components are now the default β every file in app/ is a Server Component unless you add 'use client'
- Server Actions replace API routes for mutations β form submissions call server functions directly without fetch
- The new compiler replaces next/babel and SWC plugins β Babel configs and custom .babelrc files break on upgrade
- use() hook and
replace useEffect data fetching β waterfall patterns die, streaming is the default - Biggest mistake: treating the migration as a version bump β it is an architectural shift from client-first to server-first rendering
Build fails or produces wrong output
find . -name '.babelrc' -o -name 'babel.config.*' | head -5npx next build --analyze to compare bundle size against pre-migration baselineHydration mismatch errors in console
grep -rn 'useEffect' app/ --include='*.tsx' | grep -v 'use client' to find Server Components using useEffectCheck browser console for specific hydration mismatch messages with component namesPages Router routes return 404 after migration
ls -la pages/ 2>/dev/null && echo 'WARNING: pages/ directory still exists'npx next routes to list all registered routes from the app/ directoryServer Action not found or returns error
grep -rn 'use server' app/ --include='*.ts' --include='*.tsx' to find all Server Actionscurl -X POST http://localhost:3000/api/action-name to test the action endpoint directlyProduction Incident
Production Debug GuideCommon failures when upgrading from Next.js 15 + React 18
use() hook with Suspense.Next.js 16 with React 19 is not a minor version bump β it is an architectural shift. The Pages Router is removed. The compiler replaces Babel entirely. Server Components are the default rendering model. Server Actions replace most API route patterns. If you are running Next.js 15 with React 18, this migration touches routing, data fetching, rendering strategy, build tooling, and testing infrastructure.
The migration breaks in predictable places. Babel configs that worked in Next.js 15 produce build errors in Next.js 16. Pages Router file conventions (pages/api, getServerSideProps) are gone β no fallback, no compatibility layer. React 18 patterns like useEffect for data fetching still work but produce warnings and miss the performance benefits of the new primitives.
This guide covers the breaking changes, the new patterns that replace them, and a testing strategy that validates the migration without a big-bang cutover. Each section includes the failure scenario, the fix, and the decision tree for choosing the right approach.
Breaking Change 1: Pages Router Is Removed β No Compatibility Layer
Next.js 16 removes the Pages Router entirely. There is no compatibility mode, no gradual migration path, no shim. If your project has a pages/ directory, the build either ignores it or fails depending on the configuration. Every route must live in the app/ directory using the App Router conventions.
The migration is a file-by-file conversion. Each pages/ file has a direct equivalent in app/, but the data fetching model changes completely. getServerSideProps becomes an async Server Component. getStaticProps becomes a Server Component with fetch caching. API routes (pages/api/) become Route Handlers (app/api/route.ts).
The critical difference: Pages Router used a request-response model where each page was a function that ran on every request. App Router uses a component model where each page is a React Server Component that can stream, cache, and compose with other components. This is not a rename β it is a different rendering architecture.
Route mapping: - pages/index.tsx -> app/page.tsx - pages/about.tsx -> app/about/page.tsx - pages/blog/[slug].tsx -> app/blog/[slug]/page.tsx - pages/api/users.ts -> app/api/users/route.ts - pages/_app.tsx -> app/layout.tsx (root layout) - pages/_document.tsx -> removed (root layout handles HTML structure) - pages/404.tsx -> app/not-found.tsx - pages/500.tsx -> app/error.tsx
// BEFORE: Next.js 15 Pages Router (pages/users/[id].tsx) // This file pattern is REMOVED in Next.js 16 // export async function getServerSideProps(context) { // const { id } = context.params; // const user = await fetch(`https://api.example.com/users/${id}`); // return { props: { user: await user.json() } }; // } // // export default function UserProfile({ user }) { // return <div><h1>{user.name}</h1></div>; // } // AFTER: Next.js 16 App Router (app/users/[id]/page.tsx) // Server Component by default β no 'use client' needed interface User { id: string; name: string; email: string; } // This is a Server Component β it runs on the server, not the browser // The function is async β data fetching happens at the component level export default async function UserProfile({ params, }: { params: Promise<{ id: string }>; }) { // params is a Promise in Next.js 16 β must await it const { id } = await params; const response = await fetch(`https://api.example.com/users/${id}`); const user: User = await response.json(); return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> </div> ); } // Generate metadata from the same data β no separate getServerSideProps export async function generateMetadata({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const response = await fetch(`https://api.example.com/users/${id}`); const user: User = await response.json(); return { title: `${user.name} - Profile`, description: `Profile page for ${user.name}`, }; }
Breaking Change 2: The Compiler Replaces Babel β Silent Config Ignoring
Next.js 16 ships with a Rust-based compiler that replaces both Babel and the previous SWC integration. This is a build tooling change that silently breaks projects with custom Babel configurations.
The compiler is faster β 5-10x faster than Babel for cold builds, near-instant for incremental builds. But it has different extension points. Babel plugins do not work. The .babelrc file is silently ignored. If your project relied on Babel plugins for CSS-in-JS extraction, import optimization, or custom transforms, those plugins stop executing with no warning.
The migration path: identify every Babel plugin in your configuration and find a native alternative. CSS-in-JS libraries that required Babel plugins (emotion, styled-components) must use their official Next.js integration or be replaced with CSS Modules. Import optimization plugins are unnecessary β the Rust compiler handles tree-shaking natively. Custom Babel transforms must be rewritten as SWC plugins or removed entirely.
The silent failure mode is the real danger. The build succeeds. The application runs. But the output is wrong β CSS is not extracted, tree-shaking does not work, bundle size increases 30-50%. You will not notice until production metrics degrade.
#!/usr/bin/env bash # io.thecodeforge: Pre-migration check for Next.js 15 -> 16 # Run this BEFORE upgrading to catch breaking changes early set -euo pipefail echo "=== Next.js 16 Migration Pre-Check ===" echo "" ERRORS=0 # Check 1: Babel config files β must be removed echo "[1/6] Checking for Babel config files..." BABEL_FILES=$(find . -maxdepth 2 -name '.babelrc' -o -name 'babel.config.*' 2>/dev/null | grep -v node_modules || true) if [ -n "$BABEL_FILES" ]; then echo " FAIL: Babel config found β Next.js 16 ignores these silently" echo "$BABEL_FILES" | sed 's/^/ /' ERRORS=$((ERRORS + 1)) else echo " PASS: No Babel config files found" fi # Check 2: Pages Router directory echo "[2/6] Checking for pages/ directory..." if [ -d "pages" ]; then echo " FAIL: pages/ directory exists β must migrate to app/" echo " Files found:" find pages -name '*.tsx' -o -name '*.ts' | head -10 | sed 's/^/ /' ERRORS=$((ERRORS + 1)) else echo " PASS: No pages/ directory found" fi # Check 3: next.config.js for deprecated options echo "[3/6] Checking next.config.js for deprecated options..." if [ -f "next.config.js" ] || [ -f "next.config.mjs" ]; then CONFIG_FILE=$(ls next.config.* 2>/dev/null | head -1) DEPRECATED=$(grep -E 'experimental.appDir|swcMinify|images.domains' "$CONFIG_FILE" 2>/dev/null || true) if [ -n "$DEPRECATED" ]; then echo " FAIL: Deprecated config options found:" echo "$DEPRECATED" | sed 's/^/ /' ERRORS=$((ERRORS + 1)) else echo " PASS: No deprecated config options found" fi fi # Check 4: React 18 patterns that break in React 19 echo "[4/6] Checking for React 18 breaking patterns..." REACT18_BREAKS=$(grep -rn 'forwardRef\|useContext.*createContext\|ReactDOM.render' app/ src/ --include='*.tsx' --include='*.ts' 2>/dev/null | head -10 || true) if [ -n "$REACT18_BREAKS" ]; then echo " WARN: Patterns that may need updating for React 19:" echo "$REACT18_BREAKS" | sed 's/^/ /' else echo " PASS: No obvious React 18 breaking patterns found" fi # Check 5: CSS-in-JS with Babel dependency echo "[5/6] Checking for CSS-in-JS with Babel dependency..." CSS_IN_JS=$(grep -E 'emotion|styled-components|linaria' package.json 2>/dev/null || true) if [ -n "$CSS_IN_JS" ]; then echo " WARN: CSS-in-JS library detected β verify Next.js 16 compatibility" echo "$CSS_IN_JS" | sed 's/^/ /' else echo " PASS: No Babel-dependent CSS-in-JS libraries found" fi # Check 6: Node.js version echo "[6/6] Checking Node.js version..." NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1) if [ "$NODE_VERSION" -lt 18 ]; then echo " FAIL: Node.js $(node -v) detected β Next.js 16 requires Node 18+" ERRORS=$((ERRORS + 1)) else echo " PASS: Node.js $(node -v) is compatible" fi echo "" if [ $ERRORS -gt 0 ]; then echo "RESULT: $ERRORS blocking issues found β fix before upgrading" exit 1 else echo "RESULT: All checks passed β safe to upgrade" exit 0 fi
Breaking Change 3: React 19 β forwardRef Removed, use() Hook Added
React 19 ships with Next.js 16 and introduces several breaking changes to the component model. The most impactful: forwardRef is removed. Components that used forwardRef to pass refs to DOM elements now accept ref as a regular prop. The ref prop is automatically forwarded to the underlying DOM element without any wrapper.
The use() hook is the second major addition. It replaces the common pattern of useEffect + useState for data fetching and context consumption. use() can unwrap Promises (for data fetching) and read Context values (for state consumption) β both in a way that integrates with Suspense for loading states.
Breaking changes in React 19: - forwardRef is removed β ref is now a regular prop - React.FC no longer implicitly includes children β add children prop explicitly - string refs are fully removed β use useRef or callback refs - Legacy context (contextType, contextTypes) is removed β use useContext - ReactDOM.render is removed β use createRoot - Default props on function components are removed β use default parameter values
The migration for forwardRef is mechanical but pervasive. Every component that wraps forwardRef needs to be updated. The good news: the new pattern is simpler β one less import, one less wrapper function.
import * as React from 'react'; // BEFORE: React 18 β forwardRef wrapper required // import { forwardRef } from 'react'; // // interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { // variant: 'primary' | 'secondary'; // } // // const Button = forwardRef<HTMLButtonElement, ButtonProps>( // ({ variant, children, ...props }, ref) => { // return ( // <button // ref={ref} // className={variant === 'primary' ? 'btn-primary' : 'btn-secondary'} // {...props} // > // {children} // </button> // ); // } // ); // Button.displayName = 'Button'; // AFTER: React 19 β ref is a regular prop, no forwardRef needed interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> { variant: 'primary' | 'secondary'; ref?: React.Ref<HTMLButtonElement>; } function Button({ variant, ref, children, ...props }: ButtonProps) { return ( <button ref={ref} className={variant === 'primary' ? 'btn-primary' : 'btn-secondary'} {...props} > {children} </button> ); } export { Button }; // --- // BEFORE: React 18 β useEffect + useState for data fetching // function UserProfile({ userId }: { userId: string }) { // const [user, setUser] = useState(null); // const [loading, setLoading] = useState(true); // // useEffect(() => { // fetch(`/api/users/${userId}`) // .then(res => res.json()) // .then(data => { setUser(data); setLoading(false); }); // }, [userId]); // // if (loading) return <Spinner />; // return <div>{user.name}</div>; // } // AFTER: React 19 β use() hook with Suspense import { use } from 'react'; function UserProfile({ userPromise }: { userPromise: Promise<User> }) { const user = use(userPromise); // Suspends until resolved return <div>{user.name}</div>; } // Parent wraps in Suspense: // <Suspense fallback={<Spinner />}> // <UserProfile userPromise={fetchUser(userId)} /> // </Suspense>
- Before: forwardRef<HTMLButtonElement, Props>((props, ref) => ...) β two generics, two parameters
- After: function Button({ ref, ...props }: Props) β ref is a regular prop, one parameter
- The migration is mechanical: remove forwardRef wrapper, add ref to the prop interface, use ref directly
- useRef and callback refs still work β only the forwardRef wrapper is removed
- Component libraries (Radix, shadcn/ui) have already migrated β update your dependencies first
use() hook + Suspense β eliminates loading state boilerplate and enables streamingNew Feature: Server Actions Replace API Routes for Mutations
Server Actions are the React 19 + Next.js 16 replacement for most API route patterns. Instead of creating an API route, calling it with fetch from a client component, and handling the response, you define a server function with the 'use server' directive and call it directly from a form or button.
The architecture eliminates the API layer for mutations. The client component calls the server function as if it were a local function. Next.js handles the RPC serialization, network transport, and response deserialization automatically. The developer writes one function instead of a route handler + fetch call + error handling.
Server Actions integrate with React 19's form actions. A form with an action={serverFunction} attribute calls the server function on submit β no onSubmit handler, no fetch, no loading state management. React handles the pending state, error state, and optimistic updates via useActionState and useOptimistic.
The production consideration: Server Actions are not a replacement for all API routes. Read-only endpoints (GET requests), webhooks, and third-party integrations still need Route Handlers. Server Actions are for mutations β create, update, delete operations that originate from your application's UI.
// Server Actions file β all exported functions run on the server 'use server'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import { z } from 'zod'; // Validation schema β always validate server-side, never trust client input const CreatePostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1).max(10000), categoryId: z.string().uuid(), }); export async function createPost(formData: FormData) { // 1. Validate input β Server Actions receive FormData, not JSON const raw = { title: formData.get('title') as string, content: formData.get('content') as string, categoryId: formData.get('categoryId') as string, }; const parsed = CreatePostSchema.safeParse(raw); if (!parsed.success) { return { error: 'Validation failed', fieldErrors: parsed.error.flatten().fieldErrors, }; } // 2. Perform the mutation β this runs on the server const post = await db.post.create({ data: parsed.data, }); // 3. Revalidate the cache β the posts list page updates revalidatePath('/posts'); // 4. Redirect to the new post redirect(`/posts/${post.id}`); } export async function deletePost(postId: string) { await db.post.delete({ where: { id: postId } }); revalidatePath('/posts'); return { success: true }; }
New Feature: Streaming and Suspense as the Default Rendering Model
Next.js 16 with React 19 makes streaming the default rendering model. Instead of waiting for all data to load before sending HTML to the browser, the server sends the page shell immediately and streams each data section as it resolves. A page with three data sources that take 200ms, 1200ms, and 2000ms respectively renders the shell in 200ms β the user sees the layout, navigation, and skeleton placeholders while the slower sections load.
Suspense boundaries control the streaming. Each boundary wraps an async Server Component and provides a fallback (usually a skeleton) that renders while the data is pending. When the data resolves, React replaces the fallback with the real content β no layout shift if the skeleton dimensions match the content dimensions.
The performance impact is dramatic. Time to First Byte (TTFB) drops from 2-5 seconds (blocking SSR) to under 200ms (shell-first streaming). The user sees meaningful content in under 200ms instead of staring at a blank screen. Each section resolves independently β a slow API endpoint does not block a fast one.
The architectural shift: pages are no longer monolithic rendering units. They are compositions of independent Suspense boundaries, each with its own data source, fallback, and resolution timing. This requires rethinking page structure β instead of one data fetch at the top, you split the page into sections that fetch independently.
import { Suspense } from 'react'; import { Skeleton } from '@/components/ui/skeleton'; // Each section fetches its own data // The page shell renders in ~200ms regardless of data latency async function MetricsSection() { // This fetch takes 800ms β but the page shell renders in 200ms const metrics = await fetch('https://api.example.com/metrics', { next: { revalidate: 60 }, // ISR: revalidate every 60 seconds }).then(res => res.json()); return ( <div className="grid grid-cols-4 gap-4"> {metrics.map((m: { label: string; value: string }) => ( <div key={m.label} className="rounded-lg border p-4"> <p className="text-sm text-muted-foreground">{m.label}</p> <p className="text-2xl font-bold">{m.value}</p> </div> ))} </div> ); } async function RecentPostsSection() { // This fetch takes 1200ms β but it does not block MetricsSection const posts = await fetch('https://api.example.com/posts?limit=5', { next: { revalidate: 30 }, }).then(res => res.json()); return ( <div className="space-y-4"> {posts.map((p: { id: string; title: string }) => ( <div key={p.id} className="rounded-lg border p-4"> <h3 className="font-medium">{p.title}</h3> </div> ))} </div> ); } async function ActivityFeedSection() { // This fetch takes 2000ms β but it streams in last, not blocking anything const activity = await fetch('https://api.example.com/activity', { cache: 'no-store', // Always fresh β no caching }).then(res => res.json()); return ( <div className="space-y-2"> {activity.map((a: { id: string; message: string }) => ( <div key={a.id} className="text-sm text-muted-foreground"> {a.message} </div> ))} </div> ); } // The page component orchestrates streaming β shell renders in ~200ms export default function DashboardPage() { return ( <div className="space-y-8 p-8"> <h1 className="text-3xl font-bold">Dashboard</h1> {/* Each Suspense boundary streams independently */} <Suspense fallback={<Skeleton className="h-32 w-full" />}> <MetricsSection /> </Suspense> <Suspense fallback={<Skeleton className="h-64 w-full" />}> <RecentPostsSection /> </Suspense> <Suspense fallback={<Skeleton className="h-96 w-full" />}> <ActivityFeedSection /> </Suspense> </div> ); }
- Without streaming: page waits for ALL data before rendering β slowest fetch blocks everything
- With streaming: page shell renders immediately, each section streams in independently
- Suspense boundaries control the streaming β each boundary has its own fallback and resolution
- TTFB drops from 2-5 seconds to under 200ms because the shell renders before data fetches complete
- CLS is controlled by matching skeleton dimensions to content dimensions β no layout shift on data arrival
Testing Strategy: Validate the Migration Without a Big-Bang Cutover
The migration from Next.js 15 + React 18 to Next.js 16 + React 19 touches routing, rendering, build tooling, and component APIs. A big-bang cutover is high-risk β one missed breaking change blocks the entire migration.
The production strategy: migrate incrementally with a parallel test suite. Run Next.js 15 and Next.js 16 side-by-side during the migration. Each migrated route is validated independently before the old route is removed. The test suite covers four layers: build validation (bundle size, no Babel config), routing validation (all routes resolve, no 404s), rendering validation (no hydration mismatches), and functional validation (forms submit, data loads, navigation works).
The testing pyramid for this migration: unit tests for individual components (forwardRef removal, use() hook), integration tests for page rendering (Server Component data fetching, Suspense boundaries), and end-to-end tests for user flows (form submission with Server Actions, navigation between pages). Each layer catches different classes of migration bugs.
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import { Suspense } from 'react'; // Layer 1: Build validation β catch silent failures before runtime // These run in CI before the migration is merged describe('Migration Build Validation', () => { it('should not have .babelrc in project root', () => { // This test runs as a pre-build check // If .babelrc exists, Babel plugins are silently ignored const fs = require('fs'); const hasBabelRc = fs.existsSync('.babelrc'); const hasBabelConfig = fs.existsSync('babel.config.js') || fs.existsSync('babel.config.mjs'); expect(hasBabelRc || hasBabelConfig).toBe(false); }); it('should not have pages/ directory', () => { const fs = require('fs'); expect(fs.existsSync('pages')).toBe(false); }); it('should not use forwardRef in any component', async () => { // Grep for forwardRef usage β all should be migrated const { execSync } = require('child_process'); const result = execSync( 'grep -rn "forwardRef" app/ src/ --include="*.tsx" --include="*.ts" || true', { encoding: 'utf-8' } ); expect(result.trim()).toBe(''); }); }); // Layer 2: Component validation β verify React 19 patterns describe('React 19 Component Patterns', () => { it('Button component accepts ref as a regular prop', () => { const ref = { current: null }; // This should work without forwardRef in React 19 render(<button ref={ref}>Click me</button>); expect(ref.current).toBeInstanceOf(HTMLButtonElement); }); it('Server Component renders async data without useEffect', async () => { // Mock the fetch global.fetch = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ name: 'Test User', email: 'test@example.com' }), }); // Server Component pattern β async function, no useEffect async function UserProfile() { const user = await fetch('/api/users/1').then(r => r.json()); return <div data-testid="user-name">{user.name}</div>; } render( <Suspense fallback={<div data-testid="loading">Loading...</div>}> <UserProfile /> </Suspense> ); // Fallback renders first expect(screen.getByTestId('loading')).toBeInTheDocument(); // Data resolves, content replaces fallback await waitFor(() => { expect(screen.getByTestId('user-name')).toHaveTextContent('Test User'); }); }); }); // Layer 3: Routing validation β verify all routes resolve describe('App Router Route Validation', () => { it('should not reference pages/ import paths', () => { const { execSync } = require('child_process'); const result = execSync( 'grep -rn "from.*pages/" app/ src/ --include="*.tsx" --include="*.ts" || true', { encoding: 'utf-8' } ); expect(result.trim()).toBe(''); }); it('should not use getServerSideProps or getStaticProps', () => { const { execSync } = require('child_process'); const result = execSync( 'grep -rn "getServerSideProps\|getStaticProps" app/ src/ --include="*.tsx" --include="*.ts" || true', { encoding: 'utf-8' } ); expect(result.trim()).toBe(''); }); });
| Aspect | Next.js 15 + React 18 | Next.js 16 + React 19 |
|---|---|---|
| Router | Pages Router + App Router (both supported) | App Router only β Pages Router removed |
| Compiler | SWC with Babel fallback | Rust compiler only β Babel silently ignored |
| Data fetching | getServerSideProps / getStaticProps / useEffect | Server Components (async) / use() hook / Suspense |
| Mutations | API routes + fetch from client | Server Actions ('use server') β direct function calls |
| Ref forwarding | forwardRef wrapper required | ref is a regular prop β forwardRef removed |
| React.FC | Includes children prop implicitly | No implicit children β add explicitly |
| Rendering model | SSR with client-side hydration | Streaming SSR with Suspense boundaries |
| params/searchParams | Synchronous objects | Promises β must await before accessing |
π― Key Takeaways
- Pages Router is removed in Next.js 16 β every route must move to app/. There is no compatibility layer, no fallback, no gradual migration path.
- The Rust compiler replaces Babel entirely β .babelrc files are silently ignored. Custom Babel plugins stop executing with no warning.
- forwardRef is removed in React 19 β ref is now a regular prop. The migration is mechanical but pervasive across your component library.
- Server Actions replace API routes for mutations β 'use server' directive, direct function calls, no fetch needed. Keep Route Handlers for external integrations.
- Streaming is the default rendering model β split pages into independent Suspense boundaries. The page shell should render in under 200ms regardless of data latency.
- Migrate incrementally with test coverage at every layer β build validation, component patterns, routing, and functional tests in CI.
β Common Mistakes to Avoid
Interview Questions on This Topic
- QA component uses forwardRef in React 18. Walk me through the migration to React 19 and explain what changes architecturally.JuniorReveal
- QYou are migrating a Next.js 15 project with 50 routes to Next.js 16. Describe your testing strategy to validate the migration without a big-bang cutover.SeniorReveal
Frequently Asked Questions
Can I use Next.js 15 and Next.js 16 side-by-side during migration?
No β Next.js 16 is a hard upgrade, not a gradual migration. You cannot run both versions in the same project. The recommended approach: create a migration branch, upgrade to Next.js 16 + React 19, migrate routes one at a time, and merge when all routes are validated. Use feature flags to route specific users to the migrated version if you need a staged rollout.
Do I need to migrate all my pages at once or can I do it incrementally?
You can migrate incrementally within the App Router. Convert one route at a time, test it, and move to the next. However, you cannot mix Pages Router and App Router in Next.js 16 β the Pages Router is completely removed. All routes must be in the app/ directory. The incremental migration happens within the App Router: convert getServerSideProps routes to Server Components first (simplest), then API routes to Route Handlers, then client-side data fetching to use() + Suspense.
What happens to my existing API routes (pages/api/) after migration?
Pages Router API routes are removed. Convert them to Route Handlers in app/api/. Each HTTP method becomes a named export: GET, POST, PUT, DELETE. The request/response API is different β Next.js 16 uses the Web Request/Response API instead of the Node.js req/res objects. Update middleware, CORS configuration, and request parsing accordingly.
How do I handle CSS-in-JS libraries (emotion, styled-components) that relied on Babel plugins?
Next.js 16 does not support Babel-based CSS extraction. Options: (1) Migrate to CSS Modules β supported natively by the Rust compiler, zero runtime overhead. (2) Use the library's official Next.js integration that works without Babel (emotion has @emotion/css for runtime extraction). (3) Use Tailwind CSS for utility-based styling. Option 1 is recommended for new projects β CSS Modules have the best performance characteristics and no runtime cost.
What is the use() hook and how does it differ from useEffect for data fetching?
The use() hook unwraps Promises and reads Context values in a way that integrates with Suspense. For data fetching: use(promise) suspends the component until the promise resolves β no loading state, no useEffect, no conditional rendering. The parent Suspense boundary shows a fallback while the promise is pending. Unlike useEffect (which fetches after mount, causing a waterfall), use() can receive promises created at the server level or in a parent component, enabling parallel data fetching. The key difference: useEffect is a side effect that runs after render. use() is a primitive that integrates with React's concurrent rendering model.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.