10 Next.js Performance Mistakes Killing Your Core Web Vitals | ZextOverse
10 Next.js Performance Mistakes Killing Your Core Web Vitals
Your Lighthouse score isn't a vanity metric. It's a direct proxy for how many users abandon your site before it loads — and how Google ranks you against competitors who got this right.
Google's Core Web Vitals (CWV) are three measurable signals that capture real user experience:
Metric
Measures
Good threshold
LCP (Largest Contentful Paint)
How fast the main content loads
≤ 2.5s
INP (Interaction to Next Paint)
How fast the page responds to input
≤ 200ms
CLS (Cumulative Layout Shift)
How stable the layout is
≤ 0.1
Since 2021, CWV have been a ranking signal in Google Search. Since March 2024, INP replaced FID as the responsiveness metric — a change that blindsided teams who optimized for the wrong number.
Next.js is exceptionally well-equipped to hit "Good" on all three — but only if you don't actively work against its defaults. These are the ten mistakes that do exactly that.
Mistake #1: Ignoring next/image and Rolling Your Own <img>
What goes wrong
Nothing destroys LCP faster than a raw <img> tag for your hero image. Without next/image, you get no automatic WebP/AVIF conversion, no lazy loading by default, no responsive srcset, and — critically — no size reservation that prevents layout shift.
// ❌ The CLS and LCP killer
<img src="/hero.jpg" alt="Hero" style={{ width: "100%" }} />
What to do instead
// ✅ Correct: use next/image with priority for above-the-fold images
import Image from "next/image";
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={630}
priority // disables lazy loading for LCP candidate
sizes="100vw"
quality={85}
/>
The priority prop is crucial. It tells Next.js to preload the image with a <link rel="preload"> tag, directly reducing LCP. Only use it for the one or two images visible above the fold — overusing it defeats the purpose.
CWV impact
LCP: priority images can reduce LCP by 0.5–1.5s on cold loads
CLS: explicit width/height reserves space, eliminating layout shift from images
Share this article:
Mistake #2: Over-fetching with Client Components When Server Components Suffice
What goes wrong
A common pattern in Next.js App Router codebases: wrapping entire page trees in "use client" because one leaf component needs useState. This sends unnecessary JavaScript to the browser, inflating the bundle and delaying interactivity.
// ❌ Entire page is client-side because of one interactive widget
"use client";
export default function BlogPost({ params }) {
const [likes, setLikes] = useState(0);
// ... entire page rendered client-side
}
What to do instead
Push interactivity down to the smallest possible component. Keep the page shell, data fetching, and static markup as Server Components.
// ✅ page.tsx — Server Component, fetches data, no JS sent
import LikeButton from "./LikeButton"; // only this is "use client"
export default async function BlogPost({ params }) {
const post = await getPost(params.slug); // runs on server
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton postId={post.id} /> {/* isolated client island */}
</article>
);
}
CWV impact
INP: smaller client bundles mean less main thread work during hydration
LCP: server-rendered HTML arrives faster; no waterfall of JS → fetch → render
Mistake #3: Not Streaming Long Server-Side Renders
What goes wrong
If your page fetches data from a slow API, the entire HTML response blocks until that fetch resolves. Users stare at a blank screen.
// ❌ Blocks the entire page response until the API responds
export default async function Dashboard() {
const analytics = await fetchAnalytics(); // slow: 1.2s
const reports = await fetchReports(); // slow: 0.9s — sequential!
return <DashboardUI analytics={analytics} reports={reports} />;
}
This is doubly wrong: the fetches are sequential when they could be parallel.
What to do instead
Use React.Suspense for streaming and Promise.all for parallel fetches:
// ✅ Streams shell immediately; deferred sections load as data arrives
import { Suspense } from "react";
export default function Dashboard() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsSection /> {/* streams when ready */}
</Suspense>
<Suspense fallback={<ReportsSkeleton />}>
<ReportsSection /> {/* streams independently */}
</Suspense>
</main>
);
}
// Inside AnalyticsSection (Server Component)
async function AnalyticsSection() {
const data = await fetchAnalytics(); // only blocks this section
return <AnalyticsUI data={data} />;
}
CWV impact
LCP: the page shell (including hero content) reaches the browser immediately
INP: less JavaScript executes at once since hydration is phased
Mistake #4: Misusing useEffect for Data That Should Come from the Server
This creates a three-stage waterfall. The user sees nothing meaningful until all three stages complete.
What to do instead
Fetch on the server. If the component truly needs to be interactive, pass server-fetched data as props:
// ✅ Data is available on first byte of HTML
export default async function ProductList() {
const products = await getProducts(); // server-side, no waterfall
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
If you need client-side revalidation, reach for SWR or TanStack Query with fallbackData populated from server props — you get the best of both worlds.
CWV impact
LCP: eliminates the fetch waterfall; content is in the initial HTML
INP: no hydration stall waiting for effect-triggered data
Analytics, chat widgets, A/B testing tools, and tag managers are among the heaviest contributors to poor INP and LCP. Loaded naively, they block the main thread during the critical load window.
// ❌ Render-blocking; blocks parser and main thread
<script src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX" />
What to do instead
Use next/script with the appropriate strategy:
import Script from "next/script";
// For analytics — defer until after page is interactive
<Script
src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX"
strategy="afterInteractive"
/>
// For non-critical widgets — load only when browser is idle
<Script
src="https://cdn.example.com/chat-widget.js"
strategy="lazyOnload"
/>
// For performance-critical inline scripts that must run before page renders
<Script id="theme-init" strategy="beforeInteractive">
{`document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light'`}
</Script>
Strategy
When it runs
Use for
beforeInteractive
Before page hydration
Theme init, cookie consent
afterInteractive
After hydration
Analytics, tag managers
lazyOnload
Browser idle time
Chat widgets, social embeds
CWV impact
INP: main thread freed during critical load window
LCP: parser unblocked; resources fetched sooner
Mistake #6: Not Setting Proper Cache-Control and Revalidation
What goes wrong
Developers either cache nothing (every request hits the origin) or cache everything forever (stale data in production). Both extremes hurt performance.
// ❌ No caching directive — every user triggers a full database query
export async function generateStaticParams() { ... }
export default async function BlogPost({ params }) {
const post = await fetch(`/api/posts/${params.slug}`, {
cache: "no-store" // fine for truly dynamic data; overkill for blog posts
});
}
What to do instead
Use Next.js's granular caching controls:
// ✅ Revalidate every 60 seconds — fresh enough, cached enough
const post = await fetch(`https://cms.example.com/posts/${slug}`, {
next: { revalidate: 60 } // ISR: Incremental Static Regeneration
});
// ✅ Tag-based invalidation for on-demand revalidation
const post = await fetch(`https://cms.example.com/posts/${slug}`, {
next: { tags: [`post-${slug}`] }
});
// In your webhook handler:
import { revalidateTag } from "next/cache";
revalidateTag(`post-${slug}`); // instant invalidation when content changes
For Route Handlers, set explicit Cache-Control headers:
LCP: cached responses arrive orders of magnitude faster than origin fetches
INP: reduced API latency means faster client-side state updates
Mistake #7: Dynamic Imports Without Thought — or Without Them at All
What goes wrong
Two opposite mistakes live here:
Mistake 7a: Importing heavy client-side libraries at the top level, bloating the initial bundle.
// ❌ Ships entire chart library to every user, even those who never see the chart
import { LineChart } from "recharts";
Mistake 7b: Using next/dynamic without ssr: false for components that cause hydration mismatches, or using it for tiny components where the overhead isn't worth it.
What to do instead
Dynamic-import genuinely heavy, non-critical components. Disable SSR only when the component is truly client-only (e.g., depends on window):
import dynamic from "next/dynamic";
// Heavy chart — load only when needed
const LineChart = dynamic(() => import("@/components/charts/LineChart"), {
loading: () => <ChartSkeleton />,
ssr: false, // only if it accesses browser APIs
});
// Rich text editor — definitely client-only, definitely heavy
const RichTextEditor = dynamic(() => import("@/components/RichTextEditor"), {
ssr: false,
loading: () => <EditorSkeleton />,
});
Use the Next.js Bundle Analyzer to identify targets:
npm install @next/bundle-analyzer
ANALYZE=true next build
Anything over 50kb that isn't needed on initial render is a dynamic import candidate.
CWV impact
INP: smaller initial JS bundle means faster Time to Interactive
LCP: main thread unblocked sooner; browser can paint earlier
Mistake #8: Rendering Fonts in a Way That Causes FOUT or Layout Shift
What goes wrong
Loading fonts from Google Fonts directly, or loading them without font-display: swap and size adjustments, causes two problems: a flash of unstyled text (FOUT) and layout shift as the font loads and changes element dimensions.
// ❌ External font request, no preconnect, no size adjustment
// In _document.tsx or layout.tsx
<link href="https://fonts.googleapis.com/css2?family=Merriweather" rel="stylesheet" />
What to do instead
Use next/font, which automatically self-hosts fonts, eliminates the external network request, and applies size-adjust to prevent layout shift:
// app/layout.tsx
import { Merriweather, JetBrains_Mono } from "next/font/google";
const merriweather = Merriweather({
subsets: ["latin"],
weight: ["400", "700"],
display: "swap", // show fallback font immediately
variable: "--font-body",
adjustFontFallback: true, // auto size-adjust to minimize CLS
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
display: "swap",
});
export default function RootLayout({ children }) {
return (
<html className={`${merriweather.variable} ${jetbrainsMono.variable}`}>
<body>{children}</body>
</html>
);
}
CLS: adjustFontFallback is the single most impactful CLS fix for text-heavy pages
LCP: self-hosted fonts eliminate DNS lookup + TCP handshake to Google's CDN
Mistake #9: Not Using generateStaticParams and generateMetadata Correctly
What goes wrong
Teams either skip static generation entirely for dynamic routes (missing a huge performance win) or generate too many static pages at build time (causing 30-minute builds).
Equally common: metadata is hardcoded or missing, which affects how social previews and search snippets render — and Open Graph images are served as huge, unoptimized PNGs.
// ❌ Every blog post hits the database on every request
export default async function BlogPost({ params }) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
return <Article post={post} />;
}
What to do instead
Use generateStaticParams for your most-visited pages, with fallback for the long tail:
// app/blog/[slug]/page.tsx
// Pre-render only the 100 most popular posts at build time
export async function generateStaticParams() {
const topPosts = await db.post.findMany({
orderBy: { views: "desc" },
take: 100,
select: { slug: true },
});
return topPosts.map(p => ({ slug: p.slug }));
}
// Other slugs render on-demand and are cached (ISR)
export const dynamicParams = true;
export const revalidate = 3600;
// Dynamic, accurate metadata
export async function generateMetadata({ params }) {
const post = await getPost(params.slug); // cached — same fetch is deduplicated
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [`/api/og?title=${encodeURIComponent(post.title)}`],
},
};
}
Use @vercel/og for dynamically generated Open Graph images that are served as optimized PNGs from the edge:
// app/api/og/route.tsx
import { ImageResponse } from "next/og";
export async function GET(request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get("title");
return new ImageResponse(
<div style={{ fontSize: 64, background: "#0f0f0f", color: "white", padding: 40 }}>
{title}
</div>,
{ width: 1200, height: 630 }
);
}
CWV impact
LCP: statically generated pages are served from CDN edge nodes in ~10ms
CLS: proper <head> metadata prevents layout recalculations on link unfurls
Mistake #10: Ignoring the React Compiler and Premature useMemo/useCallback
What goes wrong
Two opposite but equally damaging patterns appear in Next.js codebases:
Pattern A: Sprinkling useMemo and useCallback everywhere "for performance," without profiling. This adds overhead (the memoization itself has a cost), increases code complexity, and often memoizes values that change on every render anyway.
// ❌ Memoizing a cheap operation — pure overhead
const greeting = useMemo(() => `Hello, ${name}!`, [name]);
// ❌ useCallback on a function passed to a non-memoized child — no effect
const handleClick = useCallback(() => setCount(c => c + 1), []);
Pattern B: Not memoizing genuinely expensive computations or referentially unstable objects that cause child components to re-render.
// ❌ New object on every render → child re-renders every time parent renders
const config = { theme: "dark", locale: "en" }; // declared inline
return <ExpensiveChart config={config} data={data} />;
What to do instead
First: Enable the React Compiler (stable in React 19, supported in Next.js 15+). It automatically applies memoization where it's beneficial:
With the React Compiler enabled, manual useMemo/useCallback becomes largely unnecessary for render optimization. The compiler statically analyzes your components and memoizes them correctly.
Second: When you do memoize manually, profile first. Use React DevTools Profiler to identify which components actually re-render unnecessarily, then target those specifically.
// ✅ Memoize genuinely expensive computations
const sortedData = useMemo(
() => data.slice().sort(complexComparator),
[data] // only re-sort when data actually changes
);
// ✅ Stable reference for objects/arrays passed as props
const chartConfig = useMemo(
() => ({ theme, locale, formatters }),
[theme, locale]
);
CWV impact
INP: fewer unnecessary re-renders means less main thread work during interactions
React Compiler can reduce re-render counts by 30–60% in complex UIs without any manual annotation
Putting It Together: A CWV Audit Checklist
Before shipping, run through this checklist:
Images
All <img> tags replaced with next/image
LCP image has priority prop
All images have explicit width and height
JavaScript
No unnecessary "use client" at page level
Heavy libraries are dynamically imported
Third-party scripts use next/script with appropriate strategy
React Compiler enabled (Next.js 15+)
Data fetching
No useEffect data fetches for data that can come from the server
Slow data sections wrapped in <Suspense>
Fetches use Promise.all or Promise.allSettled for parallelism
Caching
Static routes use generateStaticParams
Dynamic routes have appropriate revalidate values
API routes have explicit Cache-Control headers
Fonts and metadata
All fonts loaded via next/font (no Google Fonts <link> tags)
generateMetadata implemented for all dynamic routes
Open Graph images generated with @vercel/og
Measure, Don't Guess
No performance work should begin without measurement. Use:
Chrome DevTools Performance panel — trace main thread activity during interactions
next build output — watch for large bundle warnings
ANALYZE=true next build with @next/bundle-analyzer — visualize what's in your bundles
The CrUX (Chrome User Experience Report) data in Search Console is the ground truth Google uses for ranking. Lab scores matter for debugging; field scores matter for SEO.
Conclusion
Next.js gives you an extraordinary set of performance primitives out of the box: Server Components, streaming, image optimization, font optimization, ISR, edge caching. The framework's defaults are, in most cases, the right choice.
The mistakes in this article share a common thread: they're all cases of working against the framework's design — reaching for client-side patterns when server-side is available, skipping built-in abstractions in favor of manual implementations, or optimizing by intuition rather than measurement.
Fix these ten issues and you're not just chasing a score. You're delivering a meaningfully faster experience to real users — users who, according to Google's data, convert better, bounce less, and return more often when pages load fast and respond instantly.