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:
| Step | Check | Why it matters |
|---|---|---|
| 1 | Which route is failing: public page, blog, product page, dashboard, checkout? | Public static pages and private app routes need different rendering strategies. |
| 2 | Which metric is failing: LCP, CLS, or INP? | The best fix for LCP can be irrelevant to INP. |
| 3 | Does the route render static, dynamic, or client-heavy? | Accidental dynamic rendering and root client shells are common hidden costs. |
| 4 | What is in First Load JS shared by all? | Shared JavaScript affects every route, including pages that do not use the feature. |
| 5 | Which 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:
| Mistake | Result |
|---|---|
Hero image missing priority | Browser discovers it too late. |
| Image hidden inside a Client Component | HTML arrives without the LCP candidate. |
Missing sizes on a responsive image | Browser may download an oversized asset. |
| CSS background hero | Harder for the browser to discover and prioritize. |
Multiple images marked priority | You 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: swapautomatically - 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:
| Signal | What it means |
|---|---|
High shared by all | Inspect root layout, providers, global client shells, and shared imports. |
One route has high Size | Inspect that route's direct imports. |
Public route became ƒ | Look for cookies(), headers(), uncached fetches, or forced dynamic config. |
| First Load JS grew after a PR | Check new dependencies and new Client Component boundaries. |
For deeper bundle work, see Next.js Bundle Size.
Quick wins checklist
LCP
- Use
<Image>withpriorityfor hero images - Preload critical fonts with
next/font - Avoid large client-side bundles
CLS
- Always set
widthandheighton<Image> - Use
next/fontto 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 buildfor unexpectedƒroutes - Inspect
First Load JS shared by allafter 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:
| Metric | Useful dimensions |
|---|---|
| LCP | Route, device type, LCP element if available. |
| CLS | Route, session source, whether ads/embeds loaded. |
| INP | Route, interaction target, component area. |
| TTFB | Runtime 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:
- Next.js vs Nuxt Performance - Comparing the two frameworks with real benchmarks
- React Performance Optimization - General React patterns that apply to Next.js