Back to Articles
32 min read

Next.js at Scale: Performance Optimization, Testing & Deployment Strategies

The complete guide to shipping production-ready Next.js applications. Discover techniques for reducing bundle size, implementing rigorous testing pipelines (Unit & E2E), handling migrations from Pages to App Router, and executing seamless deployments on any infrastructure.

Performance Optimization

Automatic Code Splitting

Next.js automatically splits your JavaScript bundles at the page level, meaning each page only loads the code it needs. When you navigate to /about, only the JavaScript for that page is downloaded, not the entire application bundle—this happens automatically without any configuration.

Bundle Structure:
├── _app.js (shared)          → 50KB
├── pages/index.js            → 30KB  ← Only loaded on /
├── pages/about.js            → 25KB  ← Only loaded on /about
└── pages/dashboard.js        → 45KB  ← Only loaded on /dashboard

Route-based Code Splitting

Each route in your Next.js application becomes its own JavaScript chunk, loaded on-demand when users navigate to that route. This is the foundation of Next.js performance—users never download code for pages they don't visit.

User visits /dashboard:
┌─────────────────────────────────────┐
│  Download: framework.js (shared)    │
│  Download: dashboard-[hash].js      │
│  Skip: home-[hash].js              │
│  Skip: settings-[hash].js          │
└─────────────────────────────────────┘

Component-based Code Splitting

Beyond route-level splitting, you can split individual components using dynamic imports, loading heavy components only when needed (e.g., a rich text editor that loads only when users click "Edit").

// Heavy chart component loaded only when rendered import dynamic from 'next/dynamic'; const HeavyChart = dynamic(() => import('../components/Chart'), { loading: () => <p>Loading chart...</p>, ssr: false // Skip server-side rendering if needed }); export default function Dashboard() { const [showChart, setShowChart] = useState(false); return showChart ? <HeavyChart /> : <button onClick={() => setShowChart(true)}>Show</button>; }

Dynamic Imports

Dynamic imports let you load modules lazily at runtime using next/dynamic or native import(), reducing initial bundle size by deferring non-critical code until it's actually needed.

// Named export dynamic import const Modal = dynamic(() => import('../components/Modal').then(mod => mod.Modal)); // With custom loading and error handling const Editor = dynamic( () => import('../components/Editor'), { loading: () => <Skeleton />, ssr: false // Client-only component (e.g., uses window) } );

Bundle Analyzer

The @next/bundle-analyzer plugin generates visual treemaps of your bundles, helping identify oversized dependencies and optimization opportunities—essential for understanding what's actually shipped to users.

// next.config.js const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); module.exports = withBundleAnalyzer({ /* config */ }); // Run: ANALYZE=true npm run build
┌────────────────────────────────────────┐
│  Bundle Visualization                  │
│  ┌──────────────┐ ┌──────┐ ┌────────┐ │
│  │   lodash     │ │react │ │ your   │ │
│  │   150KB 😱   │ │ 40KB │ │ code   │ │
│  └──────────────┘ └──────┘ └────────┘ │
└────────────────────────────────────────┘

Lighthouse Optimization

Next.js is built with Lighthouse metrics in mind; use next/image, next/font, and next/script to automatically optimize for Performance, Accessibility, Best Practices, and SEO scores—aim for 90+ in production.

Lighthouse Scores Target:
┌─────────────────────────────────────┐
│  Performance:    🟢 90+             │
│  Accessibility:  🟢 90+             │
│  Best Practices: 🟢 90+             │
│  SEO:            🟢 90+             │
└─────────────────────────────────────┘
Key optimizations: next/image, next/font, proper meta tags

Core Web Vitals

Next.js provides built-in support for measuring LCP (Largest Contentful Paint), FID (First Input Delay), and CLS (Cumulative Layout Shift)—Google's ranking factors that measure real user experience.

// pages/_app.js - Report Core Web Vitals export function reportWebVitals(metric) { switch (metric.name) { case 'LCP': // Largest Contentful Paint < 2.5s case 'FID': // First Input Delay < 100ms case 'CLS': // Cumulative Layout Shift < 0.1 analytics.send(metric); } }
Core Web Vitals Thresholds:
         Good      Needs Work    Poor
LCP:   ≤2.5s      2.5s-4s       >4s
FID:   ≤100ms     100ms-300ms   >300ms
CLS:   ≤0.1       0.1-0.25      >0.25

Image Optimization

The next/image component automatically optimizes images with lazy loading, responsive sizing, WebP/AVIF conversion, and prevents CLS by requiring dimensions—it's a significant performance win with zero effort.

import Image from 'next/image'; // Automatically: lazy-loaded, responsive, WebP, prevents CLS <Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority // For above-the-fold images (LCP) placeholder="blur" // Show blur while loading blurDataURL="..." />

Font Optimization

next/font automatically self-hosts Google Fonts (or local fonts) with zero layout shift by using CSS size-adjust, eliminating render-blocking font requests and FOUT/FOIT issues.

// app/layout.js import { Inter } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], display: 'swap', // Prevent FOIT }); export default function Layout({ children }) { return <html className={inter.className}>{children}</html>; }

Script Optimization

The next/script component controls third-party script loading strategies—beforeInteractive, afterInteractive, or lazyOnload—preventing analytics and ads from blocking your critical rendering path.

import Script from 'next/script'; // Load after page is interactive (default - good for analytics) <Script src="https://analytics.com/script.js" strategy="afterInteractive" /> // Load during idle time (best for non-critical) <Script src="https://widget.com/chat.js" strategy="lazyOnload" /> // Load before hydration (rare - auth scripts) <Script src="/critical.js" strategy="beforeInteractive" />

Preloading

Preloading hints the browser to fetch critical resources early; Next.js automatically preloads CSS and fonts, but you can manually preload critical images or data using <link rel="preload"> or React's preload APIs.

// app/layout.js - Preload critical assets import { preload } from 'react-dom'; preload('/critical-image.webp', { as: 'image' }); preload('/api/critical-data', { as: 'fetch' }); // Or in head <link rel="preload" href="/hero.webp" as="image" />

Prefetching

Next.js automatically prefetches linked pages in the viewport during idle time, so navigation feels instant; this happens by default for <Link> components and can be disabled with prefetch={false}.

import Link from 'next/link'; // ✅ Auto-prefetches when link enters viewport <Link href="/dashboard">Dashboard</Link> // ❌ Disable for rarely-visited pages <Link href="/admin" prefetch={false}>Admin</Link>
Page Load Timeline:
[Initial Load] ──→ [Idle] ──→ [Prefetch /dashboard]
                                    │
User clicks ──────────────────────→ [Instant navigation!]

The <Link> component prefetches the JavaScript and data for linked pages when they enter the viewport (production only), making subsequent navigations nearly instantaneous.

// Prefetching behavior <Link href="/about" /> // Prefetch when visible <Link href="/about" prefetch /> // Explicit prefetch <Link href="/about" prefetch={false} /> // No prefetch // Programmatic prefetch import { useRouter } from 'next/router'; const router = useRouter(); router.prefetch('/dashboard');

Runtime Configuration

Runtime configuration allows values that can only be determined at request time (not build time); use publicRuntimeConfig for client-safe values and serverRuntimeConfig for secrets—though environment variables are now preferred.

// next.config.js (legacy approach - env vars preferred) module.exports = { serverRuntimeConfig: { apiSecret: process.env.API_SECRET, // Server only }, publicRuntimeConfig: { apiUrl: process.env.API_URL, // Available on client }, }; // Usage import getConfig from 'next/config'; const { serverRuntimeConfig, publicRuntimeConfig } = getConfig();

Analyzing Bundle Size

Regularly analyze your bundle using @next/bundle-analyzer and the build output; look for duplicate dependencies, tree-shaking failures, and unexpectedly large modules—a 10KB savings can mean real performance gains for users.

# Build with analysis ANALYZE=true npm run build # Check build output Route (app) Size First Load JS ┌ ○ / 5.2 kB 89 kB ├ ○ /about 2.1 kB 85 kB └ λ /api/users 0 B 0 B ○ Static λ Dynamic First Load JS shared by all: 84 kB

Measuring Performance

Use Next.js built-in analytics, web-vitals library, or the useReportWebVitals hook to collect real user metrics (RUM) and send them to your analytics platform for production monitoring.

// app/components/WebVitals.jsx 'use client'; import { useReportWebVitals } from 'next/web-vitals'; export function WebVitals() { useReportWebVitals((metric) => { console.log(metric); // { name: 'LCP', value: 1234, ... } fetch('/api/vitals', { method: 'POST', body: JSON.stringify(metric) }); }); }

Deployment

Vercel Deployment

Vercel is the zero-configuration deployment platform built by the Next.js team; connect your Git repo and get automatic deployments, preview URLs, edge functions, and analytics—it's the path of least resistance.

# Option 1: Git integration (recommended) # Push to GitHub → Vercel auto-deploys # Option 2: CLI npm i -g vercel vercel # Preview deployment vercel --prod # Production deployment
Git Push → Vercel Pipeline:
┌─────────┐    ┌───────┐    ┌──────────┐    ┌────────┐
│  Push   │ →  │ Build │ →  │ Preview  │ →  │  Prod  │
│  main   │    │       │    │  URL     │    │  URL   │
└─────────┘    └───────┘    └──────────┘    └────────┘

Build Output

next build generates a .next folder containing optimized production assets; it shows a summary of each route's rendering strategy (Static, SSR, ISR) and bundle sizes—review this output to catch issues before deployment.

$ next build Route (app) Size First Load JS ┌ ○ / 5.2 kB 89 kB ├ ○ /about 142 B 84 kB ├ λ /api/users 0 B 0 B ├ ● /blog/[slug] 1.2 kB 85 kB (ISR: 60s) └ ƒ /dashboard 3.4 kB 87 kB ○ Static ● ISR λ SSR ƒ Dynamic

Standalone Mode

Standalone mode creates a minimal, self-contained build that includes only the necessary dependencies, reducing deployment size from hundreds of MBs to ~50MB—essential for Docker and serverless deployments.

// next.config.js module.exports = { output: 'standalone', }; // Output structure: .next/standalone/ ├── node_modules/ (minimal - only production deps) ├── server.js (entry point) └── .next/ (build output) // Run with: node .next/standalone/server.js

Static Exports

output: 'export' generates a fully static site (HTML/CSS/JS) that can be hosted anywhere (S3, GitHub Pages, CDN)—no Node.js server required, but you lose SSR, API routes, and dynamic features.

// next.config.js module.exports = { output: 'export', // Optional: trailing slashes for static hosting trailingSlash: true, }; // Build: next build // Output: /out directory with static files // Limitations: // ❌ No API routes, SSR, ISR, middleware // ✅ Works: Static pages, client-side data fetching

Self-hosting

Self-hosting Next.js means running next start on your own infrastructure (VPS, bare metal, cloud VMs); you're responsible for Node.js, process management (PM2), reverse proxy (nginx), and scaling.

# Production self-hosting npm run build npm run start # Starts on port 3000 # With PM2 for process management pm2 start npm --name "nextjs" -- start pm2 save pm2 startup
Self-hosting Stack:
┌─────────────────────────────────┐
│         Load Balancer           │
├─────────────────────────────────┤
│     nginx (reverse proxy)       │
├────────────┬────────────────────┤
│  Next.js   │    Next.js         │
│  :3000     │    :3001           │
└────────────┴────────────────────┘

Docker Deployment

Docker containerizes your Next.js app for consistent deployments; use multi-stage builds with standalone output to create slim production images (~100MB instead of 1GB+).

# Dockerfile (multi-stage, optimized) 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"]

Edge Deployment

Edge deployment runs your code on CDN edge nodes worldwide (Vercel Edge, Cloudflare Workers), providing <50ms latency globally; use the Edge Runtime for middleware, API routes, or entire pages.

// app/api/geo/route.js - Edge API Route export const runtime = 'edge'; export async function GET(request) { const country = request.geo?.country || 'Unknown'; return Response.json({ message: `Hello from ${country}!`, latency: '<50ms anywhere in the world' }); }
Edge Network:
User (Tokyo) ──→ Edge Node (Tokyo) ──→ Response
     └── 20ms round trip

vs Traditional:
User (Tokyo) ──→ Origin (US-East) ──→ Response
     └── 200ms+ round trip

Environment Variables in Deployment

Use .env.local for local development, .env.production for production defaults, and platform-specific env vars (Vercel dashboard, Docker -e flags) for secrets; prefix with NEXT_PUBLIC_ to expose to the browser.

# .env.local (git-ignored, local dev) DATABASE_URL=postgres://localhost/dev NEXT_PUBLIC_API_URL=http://localhost:3000 # Vercel/Platform: Set in dashboard for production # DATABASE_URL=postgres://prod-db/main # Access in code: # Server: process.env.DATABASE_URL # Client: process.env.NEXT_PUBLIC_API_URL

Preview Deployments

Every pull request gets a unique preview URL (on Vercel and similar platforms), allowing stakeholders to review changes before merging—preview deployments use the same build process as production.

PR Workflow with Preview Deployments:
┌────────────┐    ┌───────────────────────────────┐
│  Open PR   │ →  │  Preview: feature-xyz.vercel  │
│            │    │  ✅ Build successful          │
└────────────┘    └───────────────────────────────┘
       ↓
┌────────────┐    ┌───────────────────────────────┐
│  Merge PR  │ →  │  Production: myapp.com        │
└────────────┘    └───────────────────────────────┘

Production Deployments

Production deployments are triggered by merging to your main branch; ensure you have proper environment variables, caching strategies, and monitoring in place—always test in preview before promoting to production.

# Production checklist: ✅ Environment variables configured ✅ Database migrations run ✅ Preview deployment tested ✅ Lighthouse scores verified ✅ Error tracking enabled (Sentry) ✅ Analytics configured ✅ Cache headers set # Deploy to production git push origin main # Triggers production build # Or: vercel --prod

Custom Server (Discouraged)

A custom server (server.js) gives you full control over the Node.js server but disables important optimizations like automatic static optimization and serverless functions—only use it when absolutely necessary (WebSockets, custom proxy logic).

// server.js (use only if absolutely necessary) const { createServer } = require('http'); const { parse } = require('url'); const next = require('next'); const app = next({ dev: process.env.NODE_ENV !== 'production' }); const handle = app.getRequestHandler(); app.prepare().then(() => { createServer((req, res) => { const parsedUrl = parse(req.url, true); // Custom logic here (WebSockets, etc.) handle(req, res, parsedUrl); }).listen(3000); }); // ⚠️ Loses: Automatic static optimization, serverless, edge

Incremental Adoption

You don't need to rewrite your entire app; Next.js can be adopted incrementally—start by adding it as a subdirectory proxy, migrate pages one by one, or use rewrites to proxy legacy routes to your existing app.

// next.config.js - Proxy legacy routes module.exports = { async rewrites() { return { fallback: [ // Unmatched routes go to legacy app { source: '/:path*', destination: 'https://legacy-app.com/:path*', }, ], }; }, }; // Migration path: // 1. Deploy Next.js alongside legacy // 2. Migrate routes one-by-one // 3. Remove fallback when complete

Advanced Features

Draft Mode (App Router)

Draft Mode lets you bypass static generation to preview unpublished content from your CMS; it sets a cookie that switches the page to dynamic rendering until you exit draft mode.

// app/api/draft/route.js import { draftMode } from 'next/headers'; export async function GET(request) { draftMode().enable(); return new Response('Draft mode enabled'); } // app/posts/[slug]/page.js import { draftMode } from 'next/headers'; export default async function Post({ params }) { const { isEnabled } = draftMode(); const post = await getPost(params.slug, { preview: isEnabled }); return <Article post={post} />; }

Preview Mode (Pages Router)

Preview Mode is the Pages Router equivalent of Draft Mode; call res.setPreviewData() in an API route to enable preview cookies, then check context.preview in getStaticProps to fetch draft content.

// pages/api/preview.js export default function handler(req, res) { res.setPreviewData({ userId: req.query.userId }); res.redirect(`/posts/${req.query.slug}`); } // pages/posts/[slug].js export async function getStaticProps(context) { const post = context.preview ? await getDraftPost(context.params.slug) : await getPublishedPost(context.params.slug); return { props: { post } }; }

Custom App (_app.js)

_app.js wraps every page and persists layout between page navigations; use it for global CSS, providers (Redux, Theme), and layout components that shouldn't unmount during navigation.

// pages/_app.js import '../styles/globals.css'; import { ThemeProvider } from 'next-themes'; import { SessionProvider } from 'next-auth/react'; export default function App({ Component, pageProps: { session, ...pageProps } }) { // Use Component.getLayout for per-page layouts const getLayout = Component.getLayout || ((page) => page); return ( <SessionProvider session={session}> <ThemeProvider> {getLayout(<Component {...pageProps} />)} </ThemeProvider> </SessionProvider> ); }

Custom Document (_document.js)

_document.js customizes the <html> and <body> tags; use it to set lang attribute, inject scripts that must load before React, or add meta tags—it only renders on the server.

// pages/_document.js import { Html, Head, Main, NextScript } from 'next/document'; export default function Document() { return ( <Html lang="en"> <Head> {/* Favicons, fonts, global meta */} <link rel="icon" href="/favicon.ico" /> </Head> <body className="antialiased"> <Main /> <NextScript /> </body> </Html> ); }

Custom Error Page

Create pages/_error.js to handle all errors (both client and server) with a custom UI; this catches errors not handled by 404.js or 500.js pages.

// pages/_error.js function Error({ statusCode }) { return ( <div className="error-container"> <h1>{statusCode}</h1> <p> {statusCode === 404 ? 'Page not found' : 'An unexpected error occurred'} </p> </div> ); } Error.getInitialProps = ({ res, err }) => { const statusCode = res?.statusCode || err?.statusCode || 404; return { statusCode }; }; export default Error;

404 Page

Create pages/404.js (Pages Router) or app/not-found.js (App Router) for a custom "page not found" experience; this is statically generated at build time for optimal performance.

// pages/404.js (Pages Router) export default function Custom404() { return ( <div className="flex flex-col items-center justify-center min-h-screen"> <h1 className="text-4xl font-bold">404</h1> <p>Page not found</p> <Link href="/">Go home</Link> </div> ); } // app/not-found.js (App Router) export default function NotFound() { return <div>404 - Not Found</div>; }

500 Page

Create pages/500.js for a custom server error page; this is statically generated so it's available even if your server has issues—keep it simple with no data fetching.

// pages/500.js export default function Custom500() { return ( <div className="error-page"> <h1>500 - Server Error</h1> <p>Something went wrong on our end.</p> <button onClick={() => window.location.reload()}> Try again </button> </div> ); } // Tip: Keep this page static (no getServerSideProps) // so it works even when the server is having issues

Error.js (App Router)

error.js creates an error boundary for a route segment, catching runtime errors and displaying a fallback UI with a reset function—it's automatically wrapped in a React Error Boundary.

// app/dashboard/error.js 'use client'; // Error components must be Client Components export default function Error({ error, reset }) { useEffect(() => { console.error(error); // Log to error reporting service }, [error]); return ( <div className="error-container"> <h2>Something went wrong!</h2> <button onClick={() => reset()}>Try again</button> </div> ); }
Error Boundary Hierarchy:
app/
├── error.js          ← Catches errors in layout/page
├── layout.js
├── page.js
└── dashboard/
    ├── error.js      ← Catches dashboard-specific errors
    └── page.js

Not-found.js (App Router)

not-found.js renders when notFound() is called or when a route segment doesn't match; place it at any level to customize the 404 experience for specific sections of your app.

// app/not-found.js (root level) import Link from 'next/link'; export default function NotFound() { return ( <div> <h2>Not Found</h2> <p>Could not find the requested resource</p> <Link href="/">Return Home</Link> </div> ); } // Trigger manually: import { notFound } from 'next/navigation'; async function getPost(id) { const post = await db.posts.find(id); if (!post) notFound(); // Renders not-found.js return post; }

Loading.js (App Router)

loading.js creates an instant loading state using React Suspense; it shows immediately during navigation while the page's async operations complete—great for perceived performance.

// app/dashboard/loading.js export default function Loading() { return ( <div className="animate-pulse"> <div className="h-8 bg-gray-200 rounded w-1/4 mb-4" /> <div className="h-4 bg-gray-200 rounded w-full mb-2" /> <div className="h-4 bg-gray-200 rounded w-3/4" /> </div> ); }
Navigation with loading.js:
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   /home     │ ──→ │  loading.js │ ──→ │ /dashboard  │
│             │     │  (instant)  │     │  (loaded)   │
└─────────────┘     └─────────────┘     └─────────────┘

AMP Support

Next.js supports AMP (Accelerated Mobile Pages) with amp: true or amp: 'hybrid' configuration; however, AMP's relevance has diminished since Google removed the AMP requirement for Top Stories in 2021.

// pages/blog/[slug].js export const config = { amp: 'hybrid', // Both AMP and regular versions // amp: true // AMP-only }; export default function BlogPost({ data }) { const isAmp = useAmp(); return ( <article> {isAmp ? ( <amp-img src={data.image} width="800" height="400" /> ) : ( <Image src={data.image} width={800} height={400} /> )} </article> ); }

MDX Support

MDX lets you use JSX components in Markdown; use @next/mdx for build-time compilation or next-mdx-remote for CMS content—perfect for documentation sites and blogs with interactive components.

// next.config.js const withMDX = require('@next/mdx')(); module.exports = withMDX({ pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'], });
// app/docs/intro.mdx import { Callout } from '../components/Callout'; # Welcome to the Docs This is **MDX** - Markdown with JSX! <Callout type="warning"> This component is rendered inside Markdown! </Callout>

Migrating from Pages to App Router

Migration is incremental; both routers work simultaneously—start by moving pages one-by-one, converting getServerSideProps to async components, and replacing useRouter from next/router with next/navigation.

Migration Checklist:
┌──────────────────────────────────────────────────────┐
│  Pages Router          →    App Router               │
├──────────────────────────────────────────────────────┤
│  pages/index.js        →    app/page.js              │
│  getServerSideProps    →    async component          │
│  getStaticProps        →    async component + cache  │
│  useRouter (next/router) → useRouter (next/navigation)│
│  _app.js               →    layout.js                │
│  _document.js          →    layout.js <html>         │
└──────────────────────────────────────────────────────┘

Codemods

Next.js provides automated codemods to update deprecated APIs when upgrading versions; run npx @next/codemod to transform your codebase automatically, saving hours of manual refactoring.

# List available codemods npx @next/codemod --help # Common transformations npx @next/codemod next-image-to-legacy-image ./pages npx @next/codemod next-image-experimental ./pages npx @next/codemod new-link ./pages # Remove <a> from <Link> # App Router migration helpers npx @next/codemod built-in-next-font ./pages

Progressive Enhancement

Build your Next.js app to work without JavaScript first; forms can use Server Actions, links work natively, and content renders on the server—then enhance with client-side interactivity for a resilient user experience.

// Progressive form - works without JS! async function submitForm(formData) { 'use server'; await db.insert(Object.fromEntries(formData)); redirect('/success'); } export default function Contact() { return ( <form action={submitForm}> <input name="email" type="email" required /> <button type="submit">Subscribe</button> {/* Works: No JS, With JS (enhanced), Slow connection */} </form> ); }

Streaming SSR

Streaming SSR sends HTML to the browser progressively as it's generated, instead of waiting for the entire page; this improves TTFB and allows showing content while slow data fetches complete.

// app/dashboard/page.js import { Suspense } from 'react'; export default function Dashboard() { return ( <div> <h1>Dashboard</h1> {/* Streams immediately */} <Suspense fallback={<Skeleton />}> <SlowDataComponent /> {/* Streams when ready */} </Suspense> </div> ); }
Streaming Timeline:
─────────────────────────────────────────────────►
│<html><h1>Dashboard</h1>│<Skeleton>│<ActualData>│
│    Immediate           │  0.5s    │    2s      │

Partial Prerendering (Experimental)

PPR combines static shells with dynamic content—the static parts are served instantly from the edge while dynamic portions stream in; it's the best of both worlds but currently experimental.

// next.config.js module.exports = { experimental: { ppr: true, }, }; // app/page.js - Static shell + dynamic parts export default function Page() { return ( <div> <Header /> {/* Static - served from edge */} <Suspense fallback={<Skeleton />}> <DynamicContent /> {/* Streams in */} </Suspense> </div> ); }
PPR Response:
┌──────────────────────────────────────┐
│  Static Shell (instant, edge-cached) │
│  ┌─────────────────────────────────┐ │
│  │     Dynamic Hole (streams)      │ │
│  └─────────────────────────────────┘ │
└──────────────────────────────────────┘

Testing Next.js

Jest Setup

Configure Jest with next/jest for automatic Next.js integration including SWC transforms, module path aliases, and proper environment setup—it handles the complex configuration for you.

// jest.config.js const nextJest = require('next/jest'); const createJestConfig = nextJest({ dir: './', // Path to Next.js app }); const customJestConfig = { setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], testEnvironment: 'jest-environment-jsdom', moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', }, }; module.exports = createJestConfig(customJestConfig);
// jest.setup.js import '@testing-library/jest-dom';

React Testing Library

RTL encourages testing user behavior over implementation details; combine it with Jest for component testing—query elements like a user would (by role, text, label) rather than by test IDs or class names.

// __tests__/Button.test.jsx import { render, screen, fireEvent } from '@testing-library/react'; import Button from '../components/Button'; describe('Button', () => { it('calls onClick when clicked', () => { const handleClick = jest.fn(); render(<Button onClick={handleClick}>Click me</Button>); fireEvent.click(screen.getByRole('button', { name: /click me/i })); expect(handleClick).toHaveBeenCalledTimes(1); }); });

Testing Components

Test components in isolation by mocking dependencies and focusing on render output and user interactions; use render(), query the DOM, simulate events, and assert on the resulting state.

// __tests__/ProductCard.test.jsx import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import ProductCard from '../components/ProductCard'; const mockProduct = { id: 1, name: 'Widget', price: 29.99 }; test('displays product info and handles add to cart', async () => { const onAddToCart = jest.fn(); render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />); expect(screen.getByText('Widget')).toBeInTheDocument(); expect(screen.getByText('$29.99')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: /add to cart/i })); expect(onAddToCart).toHaveBeenCalledWith(mockProduct); });

Testing Pages

Test pages by mocking getServerSideProps/getStaticProps or rendering them directly; for App Router pages, mock fetch calls and test the async component output.

// __tests__/pages/Home.test.jsx import { render, screen } from '@testing-library/react'; import Home, { getStaticProps } from '../../pages/index'; // Mock data jest.mock('../../lib/api', () => ({ getPosts: jest.fn(() => Promise.resolve([ { id: 1, title: 'Test Post' } ])), })); test('renders posts from getStaticProps', async () => { const { props } = await getStaticProps({}); render(<Home {...props} />); expect(screen.getByText('Test Post')).toBeInTheDocument(); });

Testing API Routes

Test API routes by calling the handler function directly with mocked req and res objects, or use node-mocks-http for more realistic request/response simulation.

// __tests__/api/users.test.js import { createMocks } from 'node-mocks-http'; import handler from '../../pages/api/users'; describe('/api/users', () => { it('returns users on GET', async () => { const { req, res } = createMocks({ method: 'GET' }); await handler(req, res); expect(res._getStatusCode()).toBe(200); expect(JSON.parse(res._getData())).toEqual( expect.arrayContaining([expect.objectContaining({ id: expect.any(Number) })]) ); }); it('returns 405 for unsupported methods', async () => { const { req, res } = createMocks({ method: 'DELETE' }); await handler(req, res); expect(res._getStatusCode()).toBe(405); }); });

Testing Server Components

Server Components can't be tested with traditional RTL because they're async and run on the server; test them by awaiting the component and checking the rendered output, or test the data-fetching logic separately.

// __tests__/ServerComponent.test.jsx import { render, screen } from '@testing-library/react'; import UserProfile from '../app/users/[id]/page'; // Mock fetch global.fetch = jest.fn(() => Promise.resolve({ json: () => Promise.resolve({ id: 1, name: 'John' }), }) ); test('Server Component renders user data', async () => { // Server Components are async functions const Component = await UserProfile({ params: { id: '1' } }); render(Component); expect(screen.getByText('John')).toBeInTheDocument(); });

Testing Client Components

Client Components are tested like regular React components with RTL; mock any Next.js hooks (useRouter, useParams) and test interactivity including state changes and event handlers.

// __tests__/Counter.test.jsx 'use client'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import Counter from '../components/Counter'; test('increments count on button click', async () => { render(<Counter initialCount={0} />); expect(screen.getByText('Count: 0')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: /increment/i })); expect(screen.getByText('Count: 1')).toBeInTheDocument(); });

E2E Testing (Playwright, Cypress)

E2E tests verify complete user flows in a real browser; Playwright is faster and more modern, Cypress has better DX for debugging—both integrate well with Next.js via webServer config.

// playwright.config.js import { defineConfig } from '@playwright/test'; export default defineConfig({ webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, }, });
// tests/checkout.spec.js import { test, expect } from '@playwright/test'; test('complete checkout flow', async ({ page }) => { await page.goto('/products'); await page.click('text=Add to Cart'); await page.click('text=Checkout'); await page.fill('[name=email]', 'test@example.com'); await page.click('text=Place Order'); await expect(page.locator('h1')).toHaveText('Order Confirmed'); });

Visual Regression Testing

Visual regression testing captures screenshots and compares them against baselines to catch unintended UI changes; Playwright has built-in support, or use dedicated tools like Percy or Chromatic.

// tests/visual.spec.js (Playwright) import { test, expect } from '@playwright/test'; test('homepage visual regression', async ({ page }) => { await page.goto('/'); // Full page screenshot comparison await expect(page).toHaveScreenshot('homepage.png', { maxDiffPixels: 100, // Allow minor differences }); // Component-level screenshot const hero = page.locator('[data-testid="hero"]'); await expect(hero).toHaveScreenshot('hero-section.png'); }); // Run: npx playwright test --update-snapshots (first time)

Testing Middleware

Test middleware by creating mock NextRequest objects and calling your middleware function directly; verify the response (redirect, rewrite, headers) matches expected behavior.

// __tests__/middleware.test.js import { middleware } from '../middleware'; import { NextRequest } from 'next/server'; describe('Auth Middleware', () => { it('redirects unauthenticated users to login', async () => { const request = new NextRequest('http://localhost/dashboard'); // No auth cookie const response = await middleware(request); expect(response.status).toBe(307); expect(response.headers.get('location')).toContain('/login'); }); it('allows authenticated users through', async () => { const request = new NextRequest('http://localhost/dashboard'); request.cookies.set('session', 'valid-token'); const response = await middleware(request); expect(response.status).toBe(200); }); });

Testing Server Actions

Test Server Actions by calling them directly as async functions; mock database calls and validate both the action logic and the returned/redirected response.

// __tests__/actions.test.js import { createPost, deletePost } from '../app/actions'; import { redirect } from 'next/navigation'; jest.mock('next/navigation', () => ({ redirect: jest.fn(), })); jest.mock('../lib/db', () => ({ posts: { create: jest.fn(() => ({ id: 1 })), delete: jest.fn(), }, })); test('createPost creates and redirects', async () => { const formData = new FormData(); formData.set('title', 'Test Post'); await createPost(formData); expect(db.posts.create).toHaveBeenCalledWith({ title: 'Test Post' }); expect(redirect).toHaveBeenCalledWith('/posts/1'); });

Mocking next/router

Mock next/router to control navigation behavior in Pages Router tests; provide a mock implementation of useRouter with the properties your component uses.

// __tests__/Navigation.test.jsx (Pages Router) import { render, screen } from '@testing-library/react'; import { useRouter } from 'next/router'; import Navigation from '../components/Navigation'; jest.mock('next/router', () => ({ useRouter: jest.fn(), })); test('highlights active nav item', () => { useRouter.mockReturnValue({ pathname: '/about', push: jest.fn(), query: {}, }); render(<Navigation />); expect(screen.getByText('About')).toHaveClass('active'); expect(screen.getByText('Home')).not.toHaveClass('active'); });

Mocking next/navigation

Mock next/navigation for App Router tests; this includes useRouter, usePathname, useParams, and useSearchParams—each needs to be mocked separately.

// __tests__/ClientComponent.test.jsx (App Router) import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import SearchForm from '../components/SearchForm'; const mockPush = jest.fn(); const mockPathname = '/search'; jest.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }), usePathname: () => mockPathname, useSearchParams: () => new URLSearchParams('q=test'), })); test('updates URL on search', async () => { render(<SearchForm />); await userEvent.type(screen.getByRole('searchbox'), 'nextjs'); await userEvent.click(screen.getByRole('button', { name: /search/i })); expect(mockPush).toHaveBeenCalledWith('/search?q=nextjs'); });

Testing Dynamic Imports

Test dynamically imported components by mocking next/dynamic or the imported module itself; ensure loading states render and the actual component appears after the async import resolves.

// __tests__/DynamicModal.test.jsx import { render, screen, waitFor } from '@testing-library/react'; import DynamicModal from '../components/DynamicModal'; // Mock the dynamically imported component jest.mock('../components/Modal', () => { return function MockModal({ isOpen }) { return isOpen ? <div>Modal Content</div> : null; }; }); test('renders loading state then modal', async () => { render(<DynamicModal isOpen={true} />); // Loading state appears first expect(screen.getByText('Loading...')).toBeInTheDocument(); // Modal appears after dynamic import await waitFor(() => { expect(screen.getByText('Modal Content')).toBeInTheDocument(); }); });