Skip to content
Homeβ€Ί JavaScriptβ€Ί Next.js 16 + React 19 Complete Migration Guide

Next.js 16 + React 19 Complete Migration Guide

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: React.js β†’ Topic 40 of 47
Step-by-step migration guide from Next.
πŸ”₯ Advanced β€” solid JavaScript foundation required
In this tutorial, you'll learn
Step-by-step migration guide from Next.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • 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
🚨 START HERE
Next.js 16 Migration Debug Cheat Sheet
Fast diagnostics for build failures, hydration errors, and compatibility issues during Next.js 16 migration
🟑Build fails or produces wrong output
Immediate ActionCheck for .babelrc β€” Next.js 16 ignores it silently
Commands
find . -name '.babelrc' -o -name 'babel.config.*' | head -5
npx next build --analyze to compare bundle size against pre-migration baseline
Fix NowRemove all Babel config files and replace custom Babel plugins with native Next.js 16 compiler alternatives
🟑Hydration mismatch errors in console
Immediate ActionIdentify components that fetch data in useEffect β€” they produce different server/client HTML
Commands
grep -rn 'useEffect' app/ --include='*.tsx' | grep -v 'use client' to find Server Components using useEffect
Check browser console for specific hydration mismatch messages with component names
Fix NowMove data fetching to Server Components or wrap in Suspense with the use() hook
🟑Pages Router routes return 404 after migration
Immediate ActionVerify no pages/ directory exists β€” Next.js 16 only reads from app/
Commands
ls -la pages/ 2>/dev/null && echo 'WARNING: pages/ directory still exists'
npx next routes to list all registered routes from the app/ directory
Fix NowMove pages/ files to app/ and convert getServerSideProps to Server Component async functions
🟑Server Action not found or returns error
Immediate ActionVerify 'use server' directive exists at the top of the function or file
Commands
grep -rn 'use server' app/ --include='*.ts' --include='*.tsx' to find all Server Actions
curl -X POST http://localhost:3000/api/action-name to test the action endpoint directly
Fix NowAdd 'use server' directive and ensure the function is exported from a Server Component file
Production IncidentBabel config from Next.js 15 silently breaks the Next.js 16 build pipelineA team upgraded from Next.js 15 to 16 without removing their .babelrc file. The build succeeded but produced incorrect output β€” CSS modules were not scoped, tree-shaking failed, and the bundle was 40% larger than expected. The team did not notice until the Lighthouse score dropped from 95 to 62 in staging.
SymptomBundle size increased from 180KB to 252KB gzipped. CSS modules rendered with global class names instead of scoped names, causing style collisions across components. Dead code elimination stopped working β€” unused exports from lodash appeared in the production bundle. No build errors, no warnings in the terminal.
AssumptionThe team assumed that because the build completed without errors, the Next.js 16 compiler was correctly using their Babel configuration. They did not know that Next.js 16 ignores .babelrc files entirely and uses its built-in Rust compiler. The Babel config was a no-op, but the team's custom Babel plugins (emotion CSS extraction, lodash tree-shaking) were no longer executing.
Root causeNext.js 16 removes Babel support completely. The .babelrc file is silently ignored β€” no warning, no error, no migration message. Any Babel plugin that was providing functionality (CSS-in-JS extraction, import optimization, custom transforms) stops working. The team's emotion CSS extraction plugin, which was critical for production CSS output, was silently disabled. Without it, emotion fell back to runtime CSS injection, adding 40KB to the bundle and causing a flash of unstyled content.
FixRemoved .babelrc entirely. Replaced emotion with CSS Modules (supported natively by the Next.js 16 Rust compiler). Replaced lodash full imports with named imports (import { debounce } from 'lodash/debounce') for native tree-shaking. Ran next build --analyze to compare bundle size against pre-migration baseline β€” confirmed the fix reduced bundle from 252KB back to 178KB.
Key Lesson
Remove all Babel config files (.babelrc, babel.config.js) before upgrading β€” Next.js 16 ignores them silentlyAny functionality provided by Babel plugins must be replaced before upgrading β€” CSS extraction, import optimization, custom transformsVerify bundle size before and after migration β€” a 40% increase means something is broken, not just differentThe Next.js 16 compiler is not a drop-in Babel replacement β€” it has different capabilities and different extension points
Production Debug GuideCommon failures when upgrading from Next.js 15 + React 18
Build fails with 'Module not found' for pages/ directory imports→Pages Router is removed in Next.js 16. Move all files from pages/ to app/ and convert getServerSideProps/getStaticProps to Server Components or Route Handlers.
Build succeeds but bundle size increased 30-50% with no code changes→Check for .babelrc or babel.config.js — Next.js 16 ignores Babel configs silently. Custom Babel plugins (emotion extraction, lodash tree-shaking) are not executing. Remove Babel config and replace plugins with native alternatives.
useEffect data fetching causes hydration mismatch errors→React 19 strictures hydration — useEffect that fetches data on mount produces different server and client HTML. Move data fetching to Server Components or use the use() hook with Suspense.
Server Actions return 'Failed to load action' in production→Verify the function has 'use server' directive at the top. Check that the function is exported from a Server Component or a dedicated actions file — not from a Client Component.
CSS-in-JS library (styled-components, emotion) produces flash of unstyled content→Next.js 16 compiler does not support Babel-based CSS extraction. Migrate to CSS Modules, Tailwind CSS, or use the library's official Next.js integration that works without Babel.
Third-party components throw 'useContext is not supported in Server Components'β†’The component uses React context internally but is imported into a Server Component. Wrap it in a Client Component boundary β€” create a thin wrapper with 'use client' that imports and renders the third-party component.

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

io/thecodeforge/nextjs-migration/pages-to-app/user-profile.tsx Β· TSX
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// 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}`,
  };
}
β–Ά Output
Pages Router getServerSideProps replaced by async Server Component β€” data fetching is co-located with rendering
⚠ params and searchParams Are Promises in Next.js 16
In Next.js 16, params and searchParams in page components are Promises, not plain objects. You must await them before accessing properties. Forgetting the await produces a runtime error: 'params is a Promise, not an object.' This is a breaking change from Next.js 15 where params was a synchronous object.
πŸ“Š Production Insight
Pages Router removal is not a gradual migration β€” it is a hard cutover with no compatibility layer.
getServerSideProps becomes an async Server Component β€” the data fetching model changes completely.
Rule: migrate one route at a time, test each route before moving to the next β€” do not attempt a big-bang migration.
🎯 Key Takeaway
Pages Router is gone in Next.js 16 β€” no compatibility layer, no fallback, no gradual migration.
Every route must move to app/ β€” getServerSideProps becomes async Server Components, API routes become Route Handlers.
Punchline: if your project has a pages/ directory, the build either ignores it or fails β€” there is no in-between.
Pages Router Migration Decisions
IfStatic page with no data fetching
β†’
UseDirect conversion: pages/about.tsx -> app/about/page.tsx β€” simplest migration path
IfPage with getServerSideProps
β†’
UseConvert to async Server Component β€” fetch data directly in the component function
IfPage with getStaticProps + ISR
β†’
UseUse fetch with next.revalidate in the Server Component β€” ISR is now a fetch option, not a page option
IfAPI route (pages/api/)
β†’
UseConvert to Route Handler (app/api/route.ts) β€” export named functions for each HTTP method (GET, POST, etc.)
IfCustom _app.tsx with providers
β†’
UseConvert to root layout (app/layout.tsx) β€” providers that need client state go in a 'use client' wrapper

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.

io/thecodeforge/nextjs-migration/migration-check.sh Β· BASH
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
#!/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
β–Ά Output
Pre-migration check script: validates Babel config, Pages Router, deprecated options, React 18 patterns, CSS-in-JS, and Node.js version
⚠ The Build Succeeds but the Output Is Wrong
Next.js 16 silently ignores .babelrc files. The build completes without errors. But any functionality provided by Babel plugins (CSS extraction, import optimization, custom transforms) stops working. The output is wrong β€” CSS is not scoped, tree-shaking fails, bundle size increases 30-50%. Run the pre-migration check script before upgrading to catch these silent failures.
πŸ“Š Production Insight
Next.js 16 silently ignores .babelrc β€” no warning, no error, no migration message.
Babel plugins for CSS extraction, tree-shaking, and custom transforms stop executing silently.
Rule: remove all Babel config files and verify bundle size before and after migration β€” a 30% increase means something broke.
🎯 Key Takeaway
The Next.js 16 compiler replaces Babel entirely β€” .babelrc files are silently ignored.
Custom Babel plugins stop executing with no warning β€” CSS extraction, tree-shaking, and transforms break silently.
Punchline: if your build succeeds but bundle size increased 30-50%, your Babel plugins are not running β€” remove the config and find native alternatives.
Compiler Migration Decisions
IfProject has .babelrc with custom plugins
β†’
UseRemove .babelrc β€” find native alternatives for each plugin or rewrite as SWC plugins
IfUsing emotion or styled-components with Babel extraction
β†’
UseMigrate to CSS Modules or use the library's official Next.js integration without Babel
IfBabel plugin for lodash tree-shaking
β†’
UseUse direct named imports (import { debounce } from 'lodash/debounce') β€” the Rust compiler handles tree-shaking natively
IfCustom Babel transform for code generation
β†’
UseRewrite as an SWC plugin or a build-time code generation step outside of Next.js
IfNo Babel config β€” default Next.js setup
β†’
UseNo migration needed β€” the Rust compiler is a drop-in replacement for default configurations

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.

io/thecodeforge/nextjs-migration/react19-forwardref.tsx Β· TSX
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
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>
β–Ά Output
React 19: forwardRef removed (ref is a regular prop), use() hook replaces useEffect data fetching with Suspense
Mental Model
forwardRef Removal Mental Model
Think of forwardRef as a wrapper that said 'please pass this ref through me to my child.' React 19 removes the wrapper β€” ref is now a regular prop that every component receives automatically. No wrapper, no special API, no import needed.
  • 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
πŸ“Š Production Insight
forwardRef removal is mechanical but pervasive β€” every wrapped component needs updating.
React.FC no longer includes children β€” add children prop explicitly or use ComponentPropsWithoutRef.
Rule: run a grep for forwardRef in your codebase before upgrading β€” count the components and estimate the migration effort.
🎯 Key Takeaway
forwardRef is removed in React 19 β€” ref is now a regular prop on every component.
use() hook replaces useEffect data fetching β€” Suspense handles loading states, no manual loading flags.
Punchline: grep your codebase for forwardRef before upgrading β€” the migration is mechanical but pervasive.
React 19 Migration Decisions
IfComponent uses forwardRef
β†’
UseRemove forwardRef wrapper, add ref to prop interface, use ref directly β€” mechanical migration
IfComponent uses useEffect for data fetching
β†’
UseReplace with use() hook + Suspense β€” eliminates loading state boilerplate and enables streaming
IfComponent uses React.FC type
β†’
UseReplace with explicit function signature β€” React.FC no longer includes children in React 19
IfComponent uses string refs
β†’
UseReplace with useRef β€” string refs are fully removed in React 19
IfComponent uses legacy context API
β†’
UseReplace with useContext β€” legacy context (contextType, contextTypes) is removed

New 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.

io/thecodeforge/nextjs-migration/server-actions/actions.ts Β· TSX
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// 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 };
}
β–Ά Output
Server Actions: 'use server' directive, FormData validation with zod, revalidatePath for cache invalidation
πŸ’‘Pro Tip: Server Actions Are Not a Replacement for All API Routes
Server Actions handle mutations (create, update, delete). Read-only endpoints, webhooks, third-party integrations, and mobile API consumers still need Route Handlers. The rule: if the endpoint is called from your UI for a mutation, use a Server Action. If it is called from external systems or needs a specific HTTP method/response format, use a Route Handler.
πŸ“Š Production Insight
Server Actions eliminate the API layer for mutations β€” one function instead of route handler + fetch + error handling.
Always validate input server-side with zod β€” Server Actions receive FormData, never trust client input.
Rule: use Server Actions for mutations from your UI, Route Handlers for read-only endpoints and external integrations.
🎯 Key Takeaway
Server Actions replace API routes for mutations β€” 'use server' directive, direct function calls, no fetch needed.
Always validate input server-side β€” Server Actions receive FormData, never trust client input.
Punchline: if the mutation comes from your UI, use a Server Action β€” if it comes from outside, use a Route Handler.
Server Actions vs Route Handlers
IfForm submission from your UI (create, update, delete)
β†’
UseUse Server Actions β€” direct function call, no fetch, no API route needed
IfRead-only data endpoint for your UI
β†’
UseUse a Server Component that fetches data directly β€” no API route or Server Action needed
IfWebhook from a third-party service
β†’
UseUse a Route Handler (app/api/webhook/route.ts) β€” external services need an HTTP endpoint
IfAPI consumed by a mobile app or external client
β†’
UseUse a Route Handler β€” Server Actions are for in-app use only
IfOptimistic UI update on form submission
β†’
UseUse Server Action with useOptimistic β€” React handles the optimistic state before the server responds

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.

io/thecodeforge/nextjs-migration/streaming/dashboard-page.tsx Β· TSX
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
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>
  );
}
β–Ά Output
Streaming dashboard: three independent Suspense boundaries, each streams data as it resolves β€” shell renders in 200ms
Mental Model
Streaming Mental Model
Think of streaming like a restaurant with multiple chefs. The appetizer chef (fast fetch) sends out the appetizer first. The main course chef (slow fetch) sends out the main course when it is ready. The dessert chef (slowest fetch) sends out dessert last. The diner (user) starts eating immediately instead of waiting for all three courses to be ready.
  • 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
πŸ“Š Production Insight
Streaming drops TTFB from 2-5 seconds to under 200ms β€” the page shell renders before data fetches complete.
Each Suspense boundary streams independently β€” slow fetches don't block fast ones.
Rule: split pages into independent Suspense boundaries β€” the page shell should render in under 200ms regardless of data latency.
🎯 Key Takeaway
Streaming is the default rendering model β€” page shell renders in 200ms, data streams in independently.
Each Suspense boundary controls one section β€” slow fetches don't block fast ones.
Punchline: split every page into independent Suspense boundaries β€” the shell should render in under 200ms regardless of data latency.
Streaming Implementation Decisions
IfPage with multiple independent data sources
β†’
UseWrap each data source in its own Suspense boundary β€” streams independently
IfPage with one slow data source and fast navigation
β†’
UseSuspense boundary with skeleton fallback β€” navigation renders instantly, data streams in
IfPage that must show all data or nothing
β†’
UseSingle Suspense boundary wrapping all data sources β€” streams as a unit
IfReal-time data that updates frequently
β†’
UseSuspense boundary + client component with WebSocket/SSE β€” server renders initial state, client updates in real-time

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.

io/thecodeforge/nextjs-migration/tests/migration-validation.test.tsx Β· TSX
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
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('');
  });
});
β–Ά Output
Three-layer migration test suite: build validation, component patterns, and routing validation
πŸ’‘Pro Tip: Run Migration Tests in CI Before Merging
πŸ“Š Production Insight
Big-bang migration is high-risk β€” one missed breaking change blocks the entire migration.
Migrate incrementally with parallel test coverage: build, routing, rendering, and functional validation.
Rule: add migration validation tests to CI β€” catch silent failures (Babel config, pages/ directory) before they reach production.
🎯 Key Takeaway
Migrate incrementally with parallel test coverage β€” build, routing, rendering, and functional validation.
Add migration validation tests to CI β€” catch silent failures before they reach production.
Punchline: a big-bang migration is a big-bang failure waiting to happen β€” migrate one route at a time with test coverage for each.
Testing Strategy Decisions
IfValidating build output after migration
β†’
UseCompare bundle size before/after β€” 30%+ increase means Babel plugins are not running
IfValidating component patterns
β†’
UseGrep for forwardRef, useEffect data fetching, React.FC β€” count remaining instances
IfValidating routing after Pages Router removal
β†’
UseEnd-to-end tests that visit every route and verify no 404s β€” use Playwright or Cypress
IfValidating Server Actions in production
β†’
UseIntegration tests that submit forms and verify database mutations β€” test both success and validation error paths
πŸ—‚ Next.js 15 vs Next.js 16 + React 19
Key differences that affect migration effort
AspectNext.js 15 + React 18Next.js 16 + React 19
RouterPages Router + App Router (both supported)App Router only β€” Pages Router removed
CompilerSWC with Babel fallbackRust compiler only β€” Babel silently ignored
Data fetchinggetServerSideProps / getStaticProps / useEffectServer Components (async) / use() hook / Suspense
MutationsAPI routes + fetch from clientServer Actions ('use server') β€” direct function calls
Ref forwardingforwardRef wrapper requiredref is a regular prop β€” forwardRef removed
React.FCIncludes children prop implicitlyNo implicit children β€” add explicitly
Rendering modelSSR with client-side hydrationStreaming SSR with Suspense boundaries
params/searchParamsSynchronous objectsPromises β€” 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

    βœ•Leaving .babelrc in the project after upgrading to Next.js 16
    Symptom

    Build succeeds but CSS-in-JS extraction stops working, tree-shaking fails, and bundle size increases 30-50%. No build errors, no warnings β€” the Babel config is silently ignored.

    Fix

    Remove .babelrc and babel.config.js from the project root. Replace each Babel plugin with a native alternative: CSS Modules for CSS extraction, named imports for tree-shaking, SWC plugins for custom transforms. Verify bundle size with next build --analyze before and after.

    βœ•Not awaiting params and searchParams in page components
    Symptom

    Runtime error: 'params is a Promise, not an object.' The page crashes on every request because the code tries to access params.id without awaiting the Promise first.

    Fix

    Add async to the page component and await params before accessing properties: const { id } = await params. This is a breaking change from Next.js 15 where params was synchronous.

    βœ•Trying to use React context in a Server Component
    Symptom

    Error: 'useContext is not supported in Server Components.' Third-party components that use context internally (form libraries, state managers, UI libraries) fail when imported into a Server Component.

    Fix

    Create a thin Client Component wrapper with 'use client' that imports and renders the third-party component. The wrapper passes server data as props. Pattern: export function ClientWrapper(props) { return <ThirdPartyComponent {...props} /> } with 'use client' at the top of the file.

    βœ•Using forwardRef after upgrading to React 19
    Symptom

    Build warning or runtime error: 'forwardRef is not exported from react.' Components that wrap forwardRef fail to compile or render correctly.

    Fix

    Remove the forwardRef wrapper. Add ref to the component's prop interface as an optional prop. Update the function signature to accept ref directly: function Button({ ref, ...props }) { ... }.

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
    React 19 removes the forwardRef wrapper entirely. The ref prop is now a regular prop that every component receives automatically. Migration steps: (1) Remove the forwardRef import and wrapper function. (2) Add ref to the component's prop interface as an optional prop with type React.Ref<ElementType>. (3) Use ref directly in the JSX β€” pass it to the DOM element as before. (4) Remove the displayName assignment β€” it is no longer needed. Architecturally, forwardRef was a special API that said 'please pass this ref through me to my child.' React 19 makes this the default behavior β€” every component can receive and forward refs without a wrapper. This simplifies the component model: one less import, one less function wrapper, one less concept to explain.
  • 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
    Four-layer testing strategy. Layer 1 β€” Build validation: pre-build checks that fail if .babelrc exists, pages/ directory exists, or bundle size increases more than 10% from baseline. Run in CI on every PR during migration. Layer 2 β€” Component validation: grep for forwardRef, useEffect data fetching, and React.FC patterns β€” count remaining instances and track migration progress. Layer 3 β€” Routing validation: end-to-end tests (Playwright/Cypress) that visit every route and verify no 404s, correct page titles, and expected content renders. Layer 4 β€” Functional validation: integration tests for form submissions (Server Actions), data loading (Server Components + Suspense), and navigation between pages. Migrate one route at a time: convert the route, run all four test layers, merge when green, move to the next route.

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.

πŸ”₯
Naren Founder & Author

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.

← PreviousHow to Fix Next.js 16 Hydration Errors Once and For AllNext β†’Advanced Error Handling & Logging in Next.js 16 Applications
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged