Next.js gives you excellent performance primitives out of the box, yet plenty of Next.js sites still score in the 50s and 60s on mobile PageSpeed Insights. The framework is rarely the problem. The usual culprits are oversized hero images, font swaps, heavy client components, and third-party scripts that were added one at a time and never reviewed together.
This guide explains what Core Web Vitals actually measure, how to diagnose each metric, and the concrete Next.js techniques — next/image, next/font, server components, code splitting, script strategies and caching — that reliably move a theme into the 90+ range. Everything here applies whether you are starting from a premium theme or tuning a site that has grown organically.
Core Web Vitals explained: LCP, INP and CLS
Core Web Vitals are three user-centric metrics that Google uses to describe loading, responsiveness and visual stability. They are assessed at the 75th percentile of real page loads, separately for mobile and desktop, which means a page passes only if at least three quarters of visits hit the “good” threshold.
| Metric | What it measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | When the largest visible image or text block finishes rendering | ≤ 2.5 s | 2.5 s – 4.0 s | > 4.0 s |
| INP (Interaction to Next Paint) | Latency between user interactions and the next visual update, across the whole visit | ≤ 200 ms | 200 ms – 500 ms | > 500 ms |
| CLS (Cumulative Layout Shift) | How much visible content unexpectedly moves around | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
INP replaced First Input Delay (FID) as a Core Web Vital in March 2024. The change matters: FID only measured the delay before the first interaction started being processed, while INP considers essentially every click, tap and key press during a visit and includes processing and rendering time. Sites that passed FID comfortably can fail INP because of heavy event handlers or large re-renders later in the session.
Lab score vs. field data
The 0–100 “performance score” in Lighthouse and PageSpeed Insights is a lab score, calculated from a simulated load on a throttled mobile device. It weights metrics such as LCP, Total Blocking Time (a lab proxy for responsiveness, since INP needs real interactions) and CLS. Core Web Vitals assessments, by contrast, use field data from real Chrome users via the Chrome UX Report (CrUX). A 90+ lab score is a useful engineering target, but passing Core Web Vitals in the field is what actually reflects user experience.
Measuring: Lighthouse, CrUX and the web-vitals library
Fixing performance without measurement is guesswork. Use three complementary sources:
- Lighthouse (in Chrome DevTools or via PageSpeed Insights) for reproducible lab diagnostics. Its audits point to the specific LCP element, render-blocking resources and long tasks.
- CrUX field data, visible in PageSpeed Insights and Search Console, to see what real users experience over a trailing 28-day window.
- Your own real-user monitoring using the
web-vitalslibrary, so you can see metrics per route, per device and per release rather than waiting for CrUX to update.
Next.js exposes a hook that reports vitals from the client. Send them to your analytics endpoint with navigator.sendBeacon so reporting never blocks the page:
// app/web-vitals.tsx
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
const body = JSON.stringify({
name: metric.name, // 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB'
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
id: metric.id,
path: window.location.pathname,
});
navigator.sendBeacon?.('/api/vitals', body);
});
return null;
}
Render <WebVitals /> once in your root layout. For debugging INP specifically, the web-vitals attribution build reports which element and event type caused the slow interaction, which is invaluable when the problem only happens on real devices.
Optimizing LCP with next/image and server rendering
On most theme landing pages the LCP element is a hero image, a hero heading, or a large background. LCP time breaks down into four parts: time to first byte, resource load delay, resource load time and element render delay. Attack each one.
Use next/image correctly for the hero
next/image serves resized, modern-format images (WebP or AVIF depending on configuration) and lazy-loads by default. That default is exactly wrong for the hero, which must load as early as possible. Mark the LCP image as a priority so it gets preloaded and fetched with high priority:
import Image from 'next/image';
import hero from '@/public/images/hero.jpg';
export function Hero() {
return (
<section className="relative">
<Image
src={hero}
alt="Dashboard preview showing wallet balances"
priority
sizes="(max-width: 768px) 100vw, 1200px"
placeholder="blur"
className="h-auto w-full"
/>
<h2 className="text-4xl font-bold">Ship your dApp faster</h2>
</section>
);
}
Depending on your Next.js version, the priority behaviour may be exposed as priority or through the newer preload and fetchPriority props; check the docs for the version your theme uses. Either way, the goals are the same: preload the hero, give it high fetch priority, and never lazy-load it.
The sizes attribute is the most commonly forgotten prop. Without it, a responsive image can download a much larger file than the viewport needs. Statically importing the image lets Next.js infer width, height and a blur placeholder automatically.
Other LCP fixes
- Avoid CSS background images for the hero. The browser cannot discover them until CSS is parsed. Use a real image element instead.
- Do not hide the hero behind client-side rendering. If the LCP element renders only after a client component fetches data or an animation library initializes, LCP waits for JavaScript. Render it on the server.
- Skip entrance animations on the LCP element. Fading a hero heading in from opacity 0 can delay when it counts as painted.
- Reduce TTFB with static rendering or caching (covered below) and a CDN close to your users.
Fonts, layout stability and CLS
Layout shifts come from content that arrives without reserved space: images without dimensions, late-loading fonts that change text metrics, injected banners, and embeds.
next/font
next/font self-hosts Google Fonts and local fonts at build time, so there is no request to a third-party font server at runtime. It also generates a size-adjusted fallback font, which dramatically reduces the shift when the web font swaps in.
// app/layout.tsx
import { Inter, JetBrains_Mono } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-sans' });
const mono = JetBrains_Mono({ subsets: ['latin'], display: 'swap', variable: '--font-mono' });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${mono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
Limit subsets to the ones you need, prefer variable fonts over several static weights, and load display or monospace fonts only where they are used.
Reserve space for everything
- Give every image, video and iframe explicit dimensions or an
aspect-ratio. - Reserve fixed-height slots for cookie banners, announcement bars and ad units, or render them as overlays that do not push content.
- Use skeletons with the same dimensions as the loaded content for streamed or client-fetched sections.
- Animate with
transformandopacity, never with properties liketop,heightormargin.
Server components, code splitting and INP
JavaScript is the main enemy of INP and a frequent cause of slow LCP on mid-range phones. The App Router’s biggest performance win is that components are server components by default: they render on the server and send no component JavaScript to the browser.
Push ‘use client’ to the leaves
A common anti-pattern in themes is putting 'use client' at the top of a page or layout because one button needs state. That turns the entire subtree into client JavaScript. Instead, keep pages and sections as server components and isolate interactivity into small client components.
// app/pricing/page.tsx (server component, no 'use client')
import { getPlans } from '@/lib/plans';
import { BillingToggle } from './billing-toggle'; // small client component
export default async function PricingPage() {
const plans = await getPlans();
return (
<main>
<h2>Pricing</h2>
<BillingToggle plans={plans} />
</main>
);
}
Split heavy components
Charts, rich text editors, maps, 3D scenes and wallet modals are large and rarely needed on first paint. Load them on demand with next/dynamic:
'use client';
import dynamic from 'next/dynamic';
const PriceChart = dynamic(() => import('./price-chart'), {
ssr: false,
loading: () => <div className="h-80 animate-pulse rounded-xl bg-neutral-100" />,
});
Note the loading placeholder has a fixed height, so lazy loading does not introduce CLS. Also check imports from large libraries: import individual functions or icons rather than entire packages, and use the bundle analyzer (@next/bundle-analyzer) to find surprises.
Keep interactions fast
- Break up long tasks. Do the minimum work needed to update the UI in an event handler, and defer the rest with
startTransition,setTimeoutorscheduler.yield()where supported. - Use
useDeferredValuefor expensive filtering or search results so typing stays responsive. - Avoid re-rendering large trees on every keystroke; move state down to the component that needs it and memoize expensive children.
- Virtualize long lists such as token tables and transaction histories.
Taming third-party scripts
Analytics, chat widgets, tag managers, heatmaps and A/B testing tools often cost more performance than your entire application. Audit them honestly: every script should justify its cost. For the ones you keep, use next/script with an appropriate strategy:
| Strategy | When it loads | Use for |
|---|---|---|
beforeInteractive | Before hydration | Rare cases like consent managers that must run first |
afterInteractive (default) | Soon after hydration | Analytics and tag managers that need early data |
lazyOnload | During browser idle time | Chat widgets, social embeds, feedback tools |
worker (experimental) | In a web worker via Partytown | Selected scripts, after careful testing |
import Script from 'next/script';
<Script src="/vendor/chat-widget.js" strategy="lazyOnload" />
A further trick for chat widgets and video embeds is the facade pattern: render a lightweight static placeholder that looks like the widget, and load the real thing only when the user clicks it.
Caching, rendering strategy and delivery
Fast TTFB underpins good LCP. Choose the cheapest rendering strategy each route can tolerate:
- Static rendering for marketing pages, docs and blog posts. They can be served straight from a CDN.
- Incremental revalidation (for example
export const revalidate = 3600in a route segment, or tag-based revalidation) for content that changes occasionally, such as pricing or token lists. - Dynamic rendering only for truly per-request content, combined with streaming via
loading.tsxand<Suspense>so the shell and LCP element arrive before slow data.
Caching semantics have changed across recent Next.js major versions, so confirm how fetch caching and route caching behave in the version your theme uses rather than assuming defaults. Beyond that, make sure static assets are served with long-lived immutable cache headers (Next.js does this for hashed build assets), enable compression, and keep your origin and database geographically close to each other.
For Web3 frontends, avoid blocking the initial render on RPC calls. Render the page shell and static content on the server, cache public on-chain reads where freshness allows, and fetch wallet-specific data on the client after hydration.
Before/after optimization checklist
Use this table as a working checklist when auditing a Next.js theme. The “before” column describes patterns we commonly find; the “after” column is the target state.
| Area | Before | After | Main metric |
|---|---|---|---|
| Hero image | Lazy-loaded or CSS background, no sizes | next/image with priority/preload and accurate sizes | LCP |
| Fonts | Linked from an external font server, many weights | next/font, variable font, limited subsets | LCP, CLS |
| Media dimensions | Images and embeds without width/height | Explicit dimensions or aspect-ratio | CLS |
| Client boundaries | 'use client' on whole pages | Server components with small interactive leaves | INP, LCP |
| Heavy widgets | Charts and modals in the main bundle | next/dynamic with sized placeholders | INP, LCP |
| Third-party scripts | Raw script tags in the head | Audited, next/script strategies, facades | INP, LCP |
| Rendering | Everything dynamic | Static or revalidated where possible, streaming elsewhere | LCP (TTFB) |
| Event handlers | Heavy synchronous work on click or input | Transitions, deferred values, yielding | INP |
| Monitoring | Occasional Lighthouse runs | RUM via web-vitals, CrUX review, Lighthouse CI on pull requests | All |
Work through it in order of impact: LCP image and fonts first, then client boundaries and scripts, then rendering strategy. Re-measure after each change so you know what actually helped. Adding Lighthouse CI with performance budgets to your pipeline prevents the slow creep back down that happens as features are added.
Performance is not a one-time project. It is a budget you defend with every pull request.
Conclusion
Hitting 90+ on PageSpeed with a Next.js theme is very achievable, and it rarely requires exotic techniques. Understand the three Core Web Vitals and their thresholds, remember that INP now judges every interaction rather than just the first, and measure with both lab tools and real-user data. Then apply the fundamentals: prioritize the hero image, self-host fonts with next/font, keep most of the tree as server components, split heavy widgets, discipline third-party scripts and choose the right caching strategy per route.
If you would rather have experts handle the audit, fixes and ongoing monitoring, our performance, SEO and maintenance service is built for exactly this — or contact us to talk through your site’s current scores.
