| | |

Next.js 27 ▲ Performance Auditing — Bundle Analysis & Core Web Vitals

⏱️ Estimated read: 17 min | |

Performance auditing in Next.js has become significantly more precise as the framework has evolved. What used to require third-party plugins and manual instrumentation is now increasingly built into the toolchain. The Next.js Bundle Analyzer, integrated with Turbopack since v16.1, provides import-chain tracing that shows exactly why a module ended up in the client bundle . The useReportWebVitals hook captures real-user metrics without requiring an external library . And the framework’s automatic optimizations—code splitting, tree-shaking, Server Components—handle the common cases before you ever need to intervene .

But the tools alone do not produce a faster application. The discipline is knowing which metric maps to which bundle decision. A large client bundle delays Time to First Byte and First Contentful Paint because the browser must download, parse, and execute JavaScript before rendering. It also degrades Interaction to Next Paint because the main thread is occupied during hydration. Cumulative Layout Shift is largely orthogonal to bundle size—it is an image and font problem—but a poorly optimized bundle can worsen it by delaying the resources that would have reserved space . Auditing effectively means reading the bundle report and the vitals report together, not in isolation.

Key point: Bundle analysis tells you what is in your JavaScript. Core Web Vitals tell you how users experience it. The audit is only useful when you connect the two: a 200KB chart library in the initial bundle is not a problem until it delays LCP on mobile, and a CLS score above 0.1 is not caused by bundle size but by missing dimensions on images.


Why performance auditing needs both layers

The bundle blindness problem. Developers routinely ship dependencies without knowing their cost. A utility library imported for one function, a UI component library imported for a single button, a polyfill that targets browsers no longer supported—each adds weight to the client bundle. The Bundle Analyzer makes this visible by showing the module graph and the import chain that pulled the dependency in . Without that visibility, optimization is guesswork. With it, the 80/20 rule applies: a small number of large dependencies usually dominate the bundle, and fixing those yields the largest gains.

The lab-versus-field problem. Lighthouse runs in a controlled environment with simulated throttling. It tells you what could happen under specific conditions. Core Web Vitals from useReportWebVitals tell you what did happen for real users on real devices and networks . A page can score 95 on Lighthouse and still have poor field LCP because the analytics script loads late, or because a third-party widget blocks the main thread. The field data is the ground truth. The lab data is a debugging tool. Auditing requires both because the lab data is reproducible and the field data is representative.

The Server Component shift. Next.js’s default architecture now pushes as much rendering as possible to the server. Server Components render on the server and send HTML, adding nothing to the client bundle . This means the largest performance wins often come not from minifying a client bundle but from moving a component out of it entirely. A data-heavy dashboard that fetches and displays a table does not need to be a Client Component. If it has no interactivity, it should not be in the client bundle at all. The audit reveals the cost; the architecture decision removes it.

The trade-off. Aggressive code splitting and lazy loading reduce initial bundle size but introduce network waterfalls. A component that loads only when visible improves initial load but may feel slower when the user scrolls to it. The audit must consider the user journey, not just the initial page load. A modal that opens on a button click is a good candidate for dynamic() import. A header navigation that appears on every page is not.


a. Running the Bundle Analyzer

The Next.js Bundle Analyzer for Turbopack is available from v16.1. It runs with a single command and opens an interactive view in the browser . The treemap shows each module as a rectangle sized by its contribution to the bundle. Clicking a module reveals its full import chain—the path from an application entry point to the dependency.

// Terminal command to launch the analyzer
// This does not require any configuration file changes.
// It uses Turbopack's module graph directly.
//
// npx next analyze
//
// The interactive UI allows filtering by:
// - Route (which page includes this module)
// - Environment (client or server)
// - Type (JavaScript, CSS, JSON)
//
// The import chain view answers the question:
// "Why is this package in my bundle?"

For CI or sharing, the --output flag writes the analysis to .next/diagnostics/analyze. This directory can be copied and compared before and after optimizations .

// Saving and comparing bundle snapshots
//
// npx next analyze --output
//
// The output is written to .next/diagnostics/analyze.
// Copy it before a refactor:
//
// cp -r .next/diagnostics/analyze ./analyze-before
//
// After the refactor, run again and diff the two directories.
// This shows exactly which modules grew or shrank.

Applications still using Webpack can use @next/bundle-analyzer, which requires adding the plugin to next.config.js and running the build with an environment variable .

// next.config.js — Webpack bundle analyzer setup
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  // Only enable when ANALYZE=true is set in the environment.
  // This keeps the plugin out of normal builds.
  enabled: process.env.ANALYZE === 'true',
});

/** @type {import('next').NextConfig} */
const nextConfig = {};

module.exports = withBundleAnalyzer(nextConfig);

// Run with: ANALYZE=true npm run build
// The report opens in three browser tabs for client and server bundles.

The Webpack plugin and the Turbopack analyzer serve the same purpose. The Turbopack version is faster and integrated into the framework’s module graph, but both produce the treemap that identifies large dependencies .


b. Collecting Core Web Vitals with useReportWebVitals

The useReportWebVitals hook from next/web-vitals reports the six Core Web Vitals metrics: TTFB, FCP, LCP, FID, CLS, and INP . It must be used in a Client Component because it relies on browser performance APIs. The recommended pattern is to create a small component that the root layout imports, keeping the client boundary as narrow as possible.

// app/_components/web-vitals.tsx
'use client';

import { useReportWebVitals } from 'next/web-vitals';

// Define the callback outside the component to prevent
// the function reference from changing on every render.
// A changing reference causes duplicate metric reports.
const logWebVitals = (metric: {
  name: string;
  value: number;
  rating: string;
  id: string;
}) => {
  // In production, send to an analytics endpoint.
  // The metric object includes:
  // - name: "LCP", "CLS", "INP", etc.
  // - value: the measured duration (ms) or score
  // - rating: "good" | "needs-improvement" | "poor"
  // - id: unique per page load
  console.log(metric);
};

export function WebVitals() {
  // The hook calls logWebVitals with each metric
  // as it becomes available during the page lifecycle.
  useReportWebVitals(logWebVitals);

  return null; // This component renders nothing.
}
// app/layout.tsx — importing the WebVitals component
import { WebVitals } from './_components/web-vitals';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {/* WebVitals is the only client boundary in the layout.
            It reports metrics without affecting the rest of the tree. */}
        <WebVitals />
        {children}
      </body>
    </html>
  );
}

The rating field maps each metric against Google’s thresholds. For CLS, a value of 0.1 or below is “good,” 0.1 to 0.25 is “needs improvement,” and above 0.25 is “poor” . For LCP, the thresholds are 2.5s and 4.0s. For INP, 200ms and 500ms . These ratings are what you filter on when reviewing field data: a route with a “poor” INP rating needs investigation regardless of how small its bundle is.


Complete Example Session

// ============================================
// PART 1: RUNNING THE BUNDLE ANALYZER
// ============================================

// npx next analyze
//
// This opens the interactive treemap.
// Filter by "client" environment to see what ships to the browser.
// The largest rectangles are the first candidates for optimization.
//
// Common findings:
// - A charting library (recharts, chart.js) in the initial bundle
// - A date library (moment.js) imported for one function
// - A UI library imported wholesale instead of per-component

// ============================================
// PART 2: IDENTIFYING A PROBLEMATIC IMPORT
// ============================================

// components/Dashboard.tsx — before optimization
import { Chart } from 'recharts'; // This pulls the entire library into the client bundle.
import { format } from 'date-fns'; // date-fns is tree-shakeable, but only if imported correctly.

export function Dashboard({ data }: { data: DataPoint[] }) {
  // The chart renders on every dashboard visit.
  // If the user never scrolls to it, the bundle was wasted.
  return (
    <div>
      <h1>Dashboard</h1>
      <Chart data={data} />
    </div>
  );
}

// ============================================
// PART 3: LAZY-LOADING THE HEAVY COMPONENT
// ============================================

// components/Dashboard.tsx — after optimization
import dynamic from 'next/dynamic';

// dynamic() removes Chart from the initial bundle.
// It loads only when the Chart component renders.
// The ssr: false option prevents server-side rendering of the chart,
// which is appropriate for client-only libraries.
const Chart = dynamic(() => import('./Chart'), {
  ssr: false,
  // A loading state is required because the component
  // is not available during the initial render.
  loading: () => <p>Loading chart…</p>,
});

export function Dashboard({ data }: { data: DataPoint[] }) {
  return (
    <div>
      <h1>Dashboard</h1>
      <Chart data={data} />
    </div>
  );
}

// ============================================
// PART 4: CONVERTING A CLIENT COMPONENT TO SERVER
// ============================================

// components/UserProfile.tsx — before: unnecessary client component
'use client';

export function UserProfile({ user }: { user: User }) {
  // No interactivity. No event handlers. No hooks.
  // This component does not need to be in the client bundle.
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// components/UserProfile.tsx — after: Server Component
// The 'use client' directive is removed.
// This component now renders on the server and adds 0 bytes to the client bundle.
export function UserProfile({ user }: { user: User }) {
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// ============================================
// PART 5: COLLECTING WEB VITALS
// ============================================

// app/_components/web-vitals.tsx
'use client';

import { useReportWebVitals } from 'next/web-vitals';

const reportMetric = (metric: {
  name: string;
  value: number;
  rating: string;
}) => {
  // Send to your analytics endpoint.
  // The rating field tells you immediately whether
  // this metric is within Google's thresholds.
  if (metric.rating === 'poor') {
    // Log poor metrics with additional context.
    console.warn('Poor vitals:', metric.name, metric.value);
  }

  // In production, use navigator.sendBeacon for reliability.
  navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
};

export function WebVitals() {
  useReportWebVitals(reportMetric);
  return null;
}

// ============================================
// PART 6: FIXING CLS WITH IMAGE DIMENSIONS
// ============================================

// components/Hero.tsx — before: CLS risk
export function Hero() {
  // Without width and height, the browser does not know
  // how much space to reserve. The image loads and pushes
  // everything below it down, causing a layout shift.
  return <img src="/hero.jpg" alt="Hero" />;
}

// components/Hero.tsx — after: CLS safe
import Image from 'next/image';

export function Hero() {
  // next/image requires width and height (or fill with a sized parent).
  // The browser reserves space from the aspect ratio,
  // so the image cannot shift the layout when it loads.
  return (
    <Image
      src="/hero.jpg"
      alt="Hero"
      width={1200}
      height={600}
      priority // LCP image should load early
    />
  );
}

// ============================================
// PART 7: FIXING FONT-RELATED CLS
// ============================================

// app/layout.tsx — font loading strategy
import { Inter } from 'next/font/google';

// next/font eliminates layout shift from web fonts.
// The font is self-hosted, loaded with a size-adjusted fallback,
// and does not require a network request to Google's servers.
const inter = Inter({
  subsets: ['latin'],
  // display: 'swap' shows fallback text immediately
  // and swaps when the font loads.
  display: 'swap',
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

// ============================================
// PART 8: AUDITING A ROUTE WITH POOR INP
// ============================================

// If a route has poor INP (above 500ms), the cause is
// usually a large client component doing expensive work
// during hydration or in an event handler.
//
// Step 1: Check the bundle analyzer for large client components.
// Step 2: Check whether those components can be Server Components.
// Step 3: If they must be client components, lazy-load
//         the non-critical parts.
//
// A common culprit: importing a large validation library
// (like zod) in a client form component.
// The validation can happen in a Server Action instead,
// removing the library from the client bundle entirely.

// ============================================
// PART 9: COMPARING BEFORE AND AFTER
// ============================================

// Before optimization:
// npx next analyze --output
// cp -r .next/diagnostics/analyze ./analyze-before
//
// After optimization:
// npx next analyze --output
// cp -r .next/diagnostics/analyze ./analyze-after
//
// Compare the two directories to see:
// - Which routes shrank
// - Which modules were removed
// - The total client bundle size reduction
//
// Pair this with Web Vitals data:
// - Did LCP improve on the optimized routes?
// - Did INP improve after removing the large library?
// - Did CLS change after adding image dimensions?

// ============================================
// PART 10: A COMPLETE AUDIT CHECKLIST
// ============================================

// 1. Run npx next analyze
// 2. Filter by client environment
// 3. Identify the 5 largest modules
// 4. For each, trace the import chain
// 5. Ask: can this be a Server Component?
// 6. If not, can it be dynamically imported?
// 7. If not, can the import be narrowed?
// 8. Check Web Vitals for LCP, INP, CLS
// 9. Map each poor rating to a specific cause
// 10. Re-run the analyzer after fixes and compare

The ten parts moved from analysis through identification, optimization, vitals collection, and the specific fixes for CLS and INP. The through-line is that bundle size and Core Web Vitals are connected but not identical: a large bundle often causes poor LCP and INP, but CLS has separate causes that bundle analysis will never reveal.


Quick Reference

Bundle Analyzer commands

CommandOutputUse case
npx next analyzeInteractive treemap in browserExploration and import tracing
npx next analyze --outputStatic files in .next/diagnostics/analyzeSharing and before/after comparison
ANALYZE=true npm run buildWebpack bundle report tabsWebpack-based projects

Core Web Vitals thresholds

MetricGoodNeeds improvementPoor
LCP≤ 2.5s2.5s – 4.0s> 4.0s
INP≤ 200ms200ms – 500ms> 500ms
CLS≤ 0.10.1 – 0.25> 0.25
FCP≤ 1.8s1.8s – 3.0s> 3.0s
TTFB≤ 800ms800ms – 1.8s> 1.8s

Bundle reduction techniques

TechniqueImpactWhen to use
Server ComponentRemoves component entirely from client bundleNo interactivity or browser APIs
dynamic() importDefers module to separate chunkHeavy component not needed on initial load
Narrow importIncludes only the function usedUtility libraries with many exports
next/fontEliminates font network request and CLSEvery project using web fonts
next/imageReserves space and optimizes formatEvery image in the layout

useReportWebVitals metric properties

PropertyTypeUse
nameStringFilter by metric (LCP, INP, CLS)
valueNumberThe measured duration or score
ratingStringPre-classified: good, needs-improvement, poor
deltaNumberChange since last report
idStringUnique per page load

Best Practices

✅ Do This:

// Lazy-load heavy components that are not visible initially
const Chart = dynamic(() => import('./Chart'), { ssr: false });  // ✅

// Use next/image with explicit dimensions to prevent CLS
<Image src="/hero.jpg" width={1200} height={600} alt="Hero" />   // ✅

// Keep client boundaries narrow with a dedicated WebVitals component
'use client'; export function WebVitals() { /* ... */ }          // ✅

// Import only the function needed from utility libraries
import debounce from 'lodash/debounce';                          // ✅

// Run the analyzer before and after optimizations
npx next analyze --output                                        // ✅

// Send vitals to an analytics endpoint with navigator.sendBeacon
navigator.sendBeacon('/api/vitals', JSON.stringify(metric));     // ✅

❌ Don’t Do This:

// Import an entire library for one component
import { Chart } from 'recharts';                                // ❌ (if only one chart is used)

// Leave 'use client' on components with no interactivity
'use client'; export function StaticContent() { /* ... */ }      // ❌

// Use <img> without dimensions in layout-critical positions
<img src="/hero.jpg" alt="Hero" />                               // ❌

// Define the vitals callback inside the component body
useReportWebVitals((m) => console.log(m));                       // ❌ (new reference each render)

// Optimize bundle size without checking field vitals
// A smaller bundle that still has poor INP is not a win.         // ❌

Common Pitfalls

PitfallWhy It HappensFix
Bundle analyzer shows server codeFiltering not set to client environmentUse the environment filter in the UI
Lazy-loaded component causes layout shiftLoading placeholder has different heightMatch the placeholder height to the loaded component
Web Vitals reported multiple timesCallback reference changes on each renderDefine callback outside the component
CLS persists despite image dimensionsWeb font causes text reflowUse next/font with display: 'swap'
INP poor after removing a libraryAnother client component is doing expensive workCheck the bundle for the next largest client module
Server Component imported into Client Component'use client' propagates to importsMove the Server Component up the tree or extract shared logic

Real-World Examples

1. Lazy-loading a chart

const Chart = dynamic(() => import('./Chart'), { ssr: false, loading: () => <Skeleton /> });

2. Reserving image space

<Image src="/product.jpg" width={400} height={400} alt="Product" />

3. Using next/font

const inter = Inter({ subsets: ['latin'], display: 'swap' });
<html className={inter.className}>

4. Narrow lodash import

import debounce from 'lodash/debounce';

5. Reporting vitals with rating filter

if (metric.rating === 'poor') { sendToAnalytics(metric); }

6. Removing unnecessary ‘use client’

// Before: 'use client'; export function Card() { return <div>{...}</div>; }
// After: export function Card() { return <div>{...}</div>; }

7. Saving a bundle snapshot

npx next analyze --output && cp -r .next/diagnostics/analyze ./before

8. Dynamic import for a modal

const Modal = dynamic(() => import('./Modal'), { ssr: false });

9. Web Vitals component in layout

<body><WebVitals />{children}</body>

10. Comparing bundle sizes

diff -r analyze-before analyze-after

Visual

The Audit Workflow

┌──────────────────────────────────────────────────────────────┐
│  FROM ANALYSIS TO IMPROVEMENT                                │
│                                                              │
│  ┌─────────────────┐                                        │
│  │ Bundle Analyzer │  Identifies large modules              │
│  │ npx next analyze│  and their import chains               │
│  └────────┬────────┘                                        │
│           │                                                  │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │  Classify each  │  Server Component? → Remove from bundle│
│  │  large module   │  Client-only? → Lazy-load              │
│  │                 │  Utility? → Narrow import              │
│  └────────┬────────┘                                        │
│           │                                                  │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │  Web Vitals     │  LCP, INP, CLS field data              │
│  │ useReportWebVitals│                                     │
│  └────────┬────────┘                                        │
│           │                                                  │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │  Correlate      │  Large bundle → poor LCP/INP?          │
│  │  bundle + vitals│  CLS → image/font dimensions?         │
│  └────────┬────────┘                                        │
│           │                                                  │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │  Re-measure     │  Compare snapshots. Verify improvement.│
│  └─────────────────┘                                        │
└──────────────────────────────────────────────────────────────┘

The Three Client Bundle Strategies

┌──────────────────────────────────────────────────────────────┐
│  WHAT HAPPENS TO A MODULE IN THE CLIENT BUNDLE               │
│                                                              │
│  ┌─────────────────┐                                        │
│  │  Server         │  Module renders on server.             │
│  │  Component      │  0 bytes to client.                    │
│  │                 │  Best option when no interactivity.    │
│  └─────────────────┘                                        │
│                                                              │
│  ┌─────────────────┐                                        │
│  │  Dynamic        │  Module split into separate chunk.     │
│  │  Import         │  Loaded only when component renders.   │
│  │                 │  Good for modals, charts, below-fold.  │
│  └─────────────────┘                                        │
│                                                              │
│  ┌─────────────────┐                                        │
│  │  Static         │  Module in initial bundle.             │
│  │  Import         │  Loaded on every page load.            │
│  │                 │  Necessary for above-fold interactivity│
│  └─────────────────┘                                        │
│                                                              │
│  The audit asks: which category does this module belong in?  │
└──────────────────────────────────────────────────────────────┘

Core Web Vitals Mapping

┌──────────────────────────────────────────────────────────────┐
│  WHICH METRIC DOES EACH PROBLEM AFFECT?                      │
│                                                              │
│  Large client bundle ──────▶ LCP (delays render)             │
│                              INP (occupies main thread)      │
│                                                              │
│  Missing image dimensions ─▶ CLS (layout shift)             │
│                                                              │
│  Web font without swap ────▶ CLS (text reflow)              │
│                              FCP (invisible text)            │
│                                                              │
│  Slow server response ─────▶ TTFB (first byte)              │
│                              LCP (delayed content)           │
│                                                              │
│  Expensive event handler ──▶ INP (slow interaction)         │
│                                                              │
│  The fix depends on the metric, not the bundle size alone.  │
└──────────────────────────────────────────────────────────────┘

The Bundle Analyzer Treemap

┌──────────────────────────────────────────────────────────────┐
│  READING THE TREEMAP                                         │
│                                                              │
│  ┌──────────────────────────────────────────────┐           │
│  │  react-dom (85KB)                            │           │
│  ├──────────────────────┬───────────────────────┤           │
│  │  recharts (120KB)    │  lodash (70KB)        │           │
│  │                      │                       │           │
│  │  ┌────────────────┐  │  ┌─────────────────┐  │           │
│  │  │ d3-scale (40KB)│  │  │ debounce (2KB)  │  │           │
│  │  │ d3-shape (35KB)│  │  │ (rest unused)   │  │           │
│  │  └────────────────┘  │  └─────────────────┘  │           │
│  └──────────────────────┴───────────────────────┘           │
│                                                              │
│  Rectangle area = module size.                              │
│  Click a module to see the import chain.                    │
│  The import chain answers "why is this here?"               │
│                                                              │
│  recharts: imported in Dashboard.tsx for one chart.         │
│  lodash: imported as `import _ from 'lodash'` for debounce. │
│  Both can be reduced.                                       │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Bundle Analyzer command (Turbopack)npx next analyze
Snapshot output.next/diagnostics/analyze
Webpack analyzer@next/bundle-analyzer plugin
Web Vitals hookuseReportWebVitals from next/web-vitals
Hook constraintMust be in a 'use client' component
CLS “good” threshold≤ 0.1
LCP “good” threshold≤ 2.5s
INP “good” threshold≤ 200ms
Image CLS fixnext/image with width and height
Font CLS fixnext/font with display: 'swap'

Key takeaways:

  • The Bundle Analyzer shows what, Web Vitals show how users feel. A module in the bundle is not inherently a problem. It becomes a problem when it delays LCP on mobile or blocks the main thread during an interaction. Use the analyzer to find candidates, and vitals to prioritize .
  • Server Components are the first optimization, not lazy loading. A component with no interactivity should not be in the client bundle at all. dynamic() import is the second option, for components that must run in the browser but are not needed on initial load .
  • CLS is an image and font problem. Bundle analysis will never reveal a CLS cause. The fix is next/image with explicit dimensions and next/font with a size-adjusted fallback. These reserve space before the resource loads .
  • The callback reference in useReportWebVitals must be stable. Defining the function inside the component creates a new reference on every render, causing duplicate metric reports. Define it outside the component or use useCallback .
  • Audit before and after. A bundle optimization that does not improve field vitals is not a win. A vitals improvement that came from a server-side change is still worth keeping. The two measurements together tell you whether the work mattered.

Remember: Performance auditing is not a one-time task. The bundle grows as dependencies are added. The vitals drift as third-party scripts change. The audit that found a 200KB chart library in the initial bundle is useful only if the fix is verified and the regression is caught before it ships. Run the analyzer before a release, check the vitals dashboard after a deploy, and treat a “poor” rating on any metric as a bug report from real users. The tools exist to make the invisible visible. The discipline is looking.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!