Next.js ships with solid performance defaults - automatic code splitting, image optimization, and font optimization. But those defaults only help if you use them correctly.

I've seen Next.js sites score 30 and sites score 95 on the same framework. The difference isn't Next.js - it's how it's used. This guide covers the most common mistakes that hurt your LCP, CLS, and INP scores.

When I audit a slow Next.js app, I start by separating framework problems from app problems. Next.js can render a route quickly and still ship too much client JavaScript. It can optimize an image and still load it too late. It can server-render content and still hydrate a huge client shell. The framework gives you tools, but it does not choose the boundaries for you.

The Next.js audit order I use

Work in this order before rewriting components:

StepCheckWhy it matters
1Which route is failing: public page, blog, product page, dashboard, checkout?Public static pages and private app routes need different rendering strategies.
2Which metric is failing: LCP, CLS, or INP?The best fix for LCP can be irrelevant to INP.
3Does the route render static, dynamic, or client-heavy?Accidental dynamic rendering and root client shells are common hidden costs.
4What is in First Load JS shared by all?Shared JavaScript affects every route, including pages that do not use the feature.
5Which element or interaction produced the bad Web Vital?Fix the measured problem, not a generic checklist.

Run next build early and keep the output next to your Lighthouse or PageSpeed report. If every public route has high First Load JS, inspect layouts and shared Client Components first. If only one route is heavy, inspect that route's imports.

The biggest Next.js performance mistakes

1. Not using the Image component

This is the most common mistake. The Next.js <Image> component handles lazy loading, responsive sizing, and modern formats automatically. The native <img> tag does none of that.

Bad:

<img src="/hero.jpg" alt="Hero" />

Good:

import Image from 'next/image';

<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority // Use for LCP images
/>

The priority prop is crucial for your LCP image. It disables lazy loading and preloads the image. Without it, your hero image loads late and tanks your LCP.

For images above the fold: Always add priority.

For images below the fold: Let lazy loading do its job (it's the default).

For responsive images, also set sizes. Without it, the browser can choose a larger source than it needs:

<Image
  src="/hero.jpg"
  alt="Product dashboard with faster Core Web Vitals"
  width={1200}
  height={720}
  priority
  sizes="(max-width: 768px) 100vw, 1200px"
/>

Common LCP image mistakes in Next.js:

MistakeResult
Hero image missing priorityBrowser discovers it too late.
Image hidden inside a Client ComponentHTML arrives without the LCP candidate.
Missing sizes on a responsive imageBrowser may download an oversized asset.
CSS background heroHarder for the browser to discover and prioritize.
Multiple images marked priorityYou create preload competition instead of a clear LCP path.

2. Loading everything client-side

Next.js 13+ introduced Server Components. They run on the server, send zero JavaScript to the client, and are the default in the App Router. But many developers still sprinkle "use client" everywhere.

Bad:

"use client"; // Everything is now client-side

export default function ProductList({ products }) {
  return (
    <div>
      {products.map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}

Good:

// No "use client" - runs on server by default
export default function ProductList({ products }) {
  return (
    <div>
      {products.map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}

// Only the interactive part is client-side
"use client";
function AddToCartButton({ productId }) {
  return <button onClick={() => addToCart(productId)}>Add to Cart</button>;
}

Rule of thumb: Start with Server Components. Only add "use client" when you need interactivity (event handlers, hooks like useState).

The "use client" directive applies to the module and everything it imports. If you place it on a large page component, you can accidentally move an entire route into the client bundle.

Use this split instead:

// app/products/page.jsx - Server Component
import ProductGrid from './ProductGrid';
import SortControls from './SortControls';

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <>
      <SortControls />
      <ProductGrid products={products} />
    </>
  );
}
// app/products/SortControls.jsx
"use client";

export default function SortControls() {
  return <button onClick={() => openSortMenu()}>Sort</button>;
}

The product content stays server-rendered. Only the control that needs a click handler ships client JavaScript.

3. Blocking fonts

Custom fonts can block rendering if loaded incorrectly. Next.js has built-in font optimization, but you have to use it.

Bad:

// In your CSS or a link tag
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap');

Good:

// app/layout.js
import { Inter } from 'next/font/google';

const inter = Inter({ 
  subsets: ['latin'],
  display: 'swap', // Prevents FOIT
});

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

The next/font module:

  • Self-hosts fonts (no external requests)
  • Preloads the font files
  • Applies font-display: swap automatically
  • Eliminates layout shift from font loading

4. Not code-splitting heavy components

Even with automatic code splitting, large components included in your main bundle hurt initial load time. Use dynamic imports for heavy components.

Bad:

import HeavyChartLibrary from './HeavyChartLibrary';

export default function Dashboard() {
  return <HeavyChartLibrary data={data} />;
}

Good:

import dynamic from 'next/dynamic';

const HeavyChartLibrary = dynamic(() => import('./HeavyChartLibrary'), {
  loading: () => <div>Loading chart...</div>,
  ssr: false // Skip server rendering if not needed
});

export default function Dashboard() {
  return <HeavyChartLibrary data={data} />;
}

Use dynamic imports for:

  • Charts and data visualization
  • Rich text editors
  • Maps
  • Anything not visible on initial load

Be careful with ssr: false. It is useful for browser-only widgets, but it also removes server-rendered HTML for that component. If the component is above the fold, this can hurt LCP and perceived loading. For below-fold charts, modals, editors, and admin widgets, it is usually fine.

5. Fetching data on the client when you could fetch on the server

Client-side data fetching means: load page → load JavaScript → run JavaScript → fetch data → render. That's slow.

Server-side fetching means: fetch data → render → send complete HTML. Much faster.

Bad:

"use client";
import { useEffect, useState } from 'react';

export default function Products() {
  const [products, setProducts] = useState([]);
  
  useEffect(() => {
    fetch('/api/products').then(r => r.json()).then(setProducts);
  }, []);
  
  return <ProductList products={products} />;
}

Good:

// Server Component - fetches on server
async function Products() {
  const products = await fetch('https://api.example.com/products').then(r => r.json());
  return <ProductList products={products} />;
}

In App Router, async Server Components can fetch data directly. No useEffect, no loading states, no client-side JavaScript.

6. Accidentally making public routes dynamic

Static public pages are usually easiest to cache and fastest to serve. A route can become dynamic if it uses request-time APIs:

import { cookies, headers } from 'next/headers';

export default async function Page() {
  const theme = cookies().get('theme');
  const userAgent = headers().get('user-agent');
  ...
}

That may be correct for account pages, but it is often accidental on marketing pages and articles. If a shared layout reads cookies for a small widget, every child route can inherit that cost.

Safer pattern:

  • keep public layouts static
  • move auth/session reads into the smallest route segment that needs them
  • use middleware or client enhancement for non-critical personalization
  • set explicit caching/revalidation for public data

7. Letting third-party scripts compete with your app

Use next/script instead of raw script tags so loading intent is explicit:

import Script from 'next/script';

export function Analytics() {
  return (
    <Script
      src="https://analytics.example/script.js"
      strategy="afterInteractive"
    />
  );
}

For chat widgets, surveys, heatmaps, and other non-critical tools, prefer lazyOnload or interaction-triggered loading. Third parties can affect all three metrics: they can block LCP, add layout shifts, and hurt INP.

8. Ignoring the build output

The build table is not perfect, but it tells you where to look:

Route (app)                              Size     First Load JS
┌ ○ /                                    160 B          107 kB
├ ○ /pricing                             2.4 kB         109 kB
└ ƒ /dashboard                           60 kB          190 kB
+ First Load JS shared by all            101 kB

Read it this way:

SignalWhat it means
High shared by allInspect root layout, providers, global client shells, and shared imports.
One route has high SizeInspect that route's direct imports.
Public route became ƒLook for cookies(), headers(), uncached fetches, or forced dynamic config.
First Load JS grew after a PRCheck new dependencies and new Client Component boundaries.

For deeper bundle work, see Next.js Bundle Size.

Quick wins checklist

LCP

  • Use <Image> with priority for hero images
  • Preload critical fonts with next/font
  • Avoid large client-side bundles

CLS

  • Always set width and height on <Image>
  • Use next/font to prevent font-related shifts
  • Reserve space for dynamic content

INP

  • Minimize "use client" components
  • Use Server Components for data-heavy pages
  • Dynamic import heavy interactive components
  • Check whether one interaction re-renders a large Client Component tree
  • Keep analytics, chat, and ad scripts out of hot interaction paths

Routing and caching

  • Confirm public pages are static or intentionally revalidated
  • Keep request-time APIs out of shared public layouts
  • Check next build for unexpected ƒ routes
  • Inspect First Load JS shared by all after dependency changes

Measuring in Next.js

Next.js has built-in Web Vitals reporting:

// app/layout.js
export function reportWebVitals(metric) {
  console.log(metric);
  // Send to your analytics
}

In current App Router projects, put the reporter in a small Client Component and mount it from the root layout:

// app/web-vitals.jsx
"use client";

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

export function WebVitals() {
  useReportWebVitals((metric) => {
    navigator.sendBeacon('/analytics/web-vitals', JSON.stringify(metric));
  });

  return null;
}
// app/layout.jsx
import { WebVitals } from './web-vitals';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        <WebVitals />
      </body>
    </html>
  );
}

Track at least:

MetricUseful dimensions
LCPRoute, device type, LCP element if available.
CLSRoute, session source, whether ads/embeds loaded.
INPRoute, interaction target, component area.
TTFBRuntime region, cache status, route type.

Lab tools tell you what to fix before release. Field data tells you whether real users improved after deployment.

Or use the useReportWebVitals hook:

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

function WebVitalsReporter() {
  useReportWebVitals((metric) => {
    console.log(metric);
  });
  return null;
}

Frequently Asked Questions

Why is my Next.js site slow despite good defaults?

Common culprits: using <img> instead of <Image>, overusing "use client" directives, not preloading fonts with next/font, and fetching data client-side when server-side would be faster.

Should I use the App Router or Pages Router for performance?

The App Router with Server Components generally offers better performance because it sends less JavaScript to the client. However, both can achieve good Core Web Vitals with proper optimization.

When should I use the priority prop on Next.js Image?

Use priority on your LCP image - typically your hero image or the largest visible image above the fold. This disables lazy loading and adds a preload hint.

How do I reduce JavaScript bundle size in Next.js?

Use Server Components (default in App Router), dynamically import heavy components with next/dynamic, and avoid adding "use client" to components that don't need interactivity.

Does next/font really help performance?

Yes. It self-hosts fonts (eliminating external requests), preloads font files, and prevents layout shift from font loading. Always use it instead of CSS @import or <link> tags for Google Fonts.

What's next

Not sure where your Next.js app is losing points? Run it through PageSpeedFix - we'll identify the specific issues and give you the exact code to fix them.

Related guides:

D
Declan

Software engineer in Melbourne. Writes guides at PageSpeedFix and builds web performance tools. More at dekk.dev.