Skip to content
Performance

Hitting 90+ PageSpeed: Core Web Vitals Optimization for Next.js Themes

Optimize LCP, INP and CLS in Next.js themes with next/image, next/font, server components and smart third-party script loading to reach 90+ PageSpeed.

CoodesCoodes Engineering Team 10 min read · 2,242 words
Hitting 90+ PageSpeed: Core Web Vitals Optimization for Next.js Themes
Table of contents

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.

MetricWhat it measuresGoodNeeds improvementPoor
LCP (Largest Contentful Paint)When the largest visible image or text block finishes rendering≤ 2.5 s2.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 ms200 ms – 500 ms> 500 ms
CLS (Cumulative Layout Shift)How much visible content unexpectedly moves around≤ 0.10.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.

Note: Lab scores vary between runs because of network and CPU variability. Run Lighthouse several times and look at the median, and never compare a run on your fast laptop with PageSpeed Insights’ throttled mobile profile.

Measuring: Lighthouse, CrUX and the web-vitals library

Fixing performance without measurement is guesswork. Use three complementary sources:

  1. 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.
  2. CrUX field data, visible in PageSpeed Insights and Search Console, to see what real users experience over a trailing 28-day window.
  3. Your own real-user monitoring using the web-vitals library, 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 transform and opacity, never with properties like top, height or margin.
Tip: In Chrome DevTools, the Performance panel highlights layout shifts and the elements responsible. Record a page load with mobile throttling and check the Layout Shifts track before guessing.

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, setTimeout or scheduler.yield() where supported.
  • Use useDeferredValue for 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:

StrategyWhen it loadsUse for
beforeInteractiveBefore hydrationRare cases like consent managers that must run first
afterInteractive (default)Soon after hydrationAnalytics and tag managers that need early data
lazyOnloadDuring browser idle timeChat widgets, social embeds, feedback tools
worker (experimental)In a web worker via PartytownSelected 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.

Warning: Tag managers make it easy for non-developers to add scripts without review. If your scores regress with no code changes, check the tag manager container first.

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 = 3600 in 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.tsx and <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.

AreaBeforeAfterMain metric
Hero imageLazy-loaded or CSS background, no sizesnext/image with priority/preload and accurate sizesLCP
FontsLinked from an external font server, many weightsnext/font, variable font, limited subsetsLCP, CLS
Media dimensionsImages and embeds without width/heightExplicit dimensions or aspect-ratioCLS
Client boundaries'use client' on whole pagesServer components with small interactive leavesINP, LCP
Heavy widgetsCharts and modals in the main bundlenext/dynamic with sized placeholdersINP, LCP
Third-party scriptsRaw script tags in the headAudited, next/script strategies, facadesINP, LCP
RenderingEverything dynamicStatic or revalidated where possible, streaming elsewhereLCP (TTFB)
Event handlersHeavy synchronous work on click or inputTransitions, deferred values, yieldingINP
MonitoringOccasional Lighthouse runsRUM via web-vitals, CrUX review, Lighthouse CI on pull requestsAll

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.

Written by the Coodes Engineering Team

We build premium Web2 & Web3 themes and help teams ship dApps, SaaS platforms and Flutter apps.

Talk to us

Related articles

Architecting a Multi-Wallet Crypto App in Flutter Mobile
12 min read

Architecting a Multi-Wallet Crypto App in Flutter

Clean architecture, secure key storage, HD wallets, multi-chain abstraction and WalletConnect: how to structure a production multi-wallet crypto app in Flutter.

Read article

Ready to Build Something Amazing?

Join thousands of developers who trust our themes for their projects. Professional designs, clean code, and ongoing support.