Skip to content
Kamer StudioKamer Studio
All posts
Engineering notesPublished on: September 3, 20267 min read

A 95+ Lighthouse score in Next.js: a practical checklist

Font loading, image sizing, third-party scripts and layout shift — a checklist across four areas, each one actionable.

Getting a Lighthouse score past 95 is never one big change — it's the sum of small losses from four separate sources. This post works through all four: font loading, image sizing, third-party scripts, and layout shift. At the end, I cover where to actually measure this, because optimizing by guesswork is wasted time.

These four aren't arbitrary — they map directly onto three of the Core Web Vitals metrics Lighthouse scores. Font and image optimization affect Largest Contentful Paint (LCP — how fast the largest piece of content on screen gets painted). Third-party scripts block the main thread and drag out interaction delay (INP). Image sizing and reserved space determine Cumulative Layout Shift (CLS — how much the page "jumps" while it loads). Improve these three metrics one at a time and the overall score follows.

Font loading: self-hosting with next/font

Loading Google Fonts through a <link> tag means one more network round trip — the browser connects to your server first, then Google's. next/font downloads the font at build time and serves it from your own server instead, removing the third-party request entirely:

// lib/fonts.ts
import { Inter } from 'next/font/google'

export const inter = Inter({
  subsets: ['latin', 'latin-ext'],
  display: 'swap',
  variable: '--font-inter',
})

display: 'swap' prevents text from staying invisible (FOIT) — it renders with a system font first and swaps in the real one once it's ready. The real win here is less visible: next/font automatically configures a fallback font matched to your chosen font's metrics (via CSS properties like size-adjust and ascent-override), so line heights and word widths stay nearly identical across the swap. That kills the page-jumps-down-when-the-font-loads problem — one of the most common sources of layout shift — at the root.

If you're using more than one font weight (regular, medium, bold), list them explicitly with the weight parameter — for a static (non-variable) font, next/font/google only downloads the weights you specify, so every weight you don't use is a file that never gets downloaded, never wasted. Keep subsets limited to the character sets you actually need in the same way — a Turkish site needs latin-ext alongside latin, but there's no reason to download characters from an alphabet you'll never use.

Binding the font to a CSS variable via variable and applying it at the root element means you don't have to import it separately in every component:

// app/layout.tsx
import { inter } from '@/lib/fonts'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html className={inter.variable}>
      <body>{children}</body>
    </html>
  )
}

Image sizing: next/image and explicit dimensions

next/image optimizes size automatically, but what actually matters is giving it width and height (or fill). Next uses these to calculate the image's aspect ratio and tells the browser how much space to reserve before the image ever loads:

import Image from 'next/image'

<Image
  src="/team.jpg"
  alt="Team photo"
  width={1200}
  height={800}
  sizes="(min-width: 768px) 50vw, 100vw"
/>

An <img> left without dimensions behaves as zero-height until the browser learns the image's real size; once it loads, everything below it gets shoved down. That's a direct hit to Lighthouse's Cumulative Layout Shift (CLS) score. The sizes attribute matters just as much: it tells the browser which image size to download based on screen width, so a mobile visitor doesn't download a desktop-sized file.

If an image is visible above the fold on load — a hero image, for instance — add priority; this tells Next to fetch it immediately instead of lazily, and it usually improves Largest Contentful Paint (LCP). next/image's default behavior is to lazy-load anything below the fold until the browser gets close to it; priority is the exception to that, and it should be reserved for the one image that's genuinely above the fold — adding priority to every image makes all of them equally prioritized, which means none of them are.

Another benefit of next/image: it automatically serves whichever format the browser supports most efficiently (AVIF or WebP) — even if your source file is a JPEG, a browser that supports it gets served a smaller-format file, with no manual conversion on your end.

Defer or drop third-party scripts

Every script you add — a live-chat widget, a tracking pixel, an external form tool — occupies the main thread for a while. next/script gives you four loading strategies:

import Script from 'next/script'

<Script src="https://widget.example.com/embed.js" strategy="lazyOnload" />
  • beforeInteractive: for critical scripts that need to run before the page becomes interactive. Rarely needed.
  • afterInteractive (default): runs right after the page loads.
  • lazyOnload: runs when the browser is idle — the right choice for scripts that aren't needed immediately, like a chat widget.
  • worker: moves the script to a separate worker off the main thread (experimental, not suitable for every script).

But the best optimization is usually not adding the script at all. Before adding one, ask: does this genuinely need to run on every page, or just one? If it's unused, remove it — there's no faster performance win than a line of code you never add.

If you can't remove a script — a payment provider's SDK, a map component, some dependency that's actually required — at least consider deferring it until the moment it's needed. A map widget doesn't have to load the instant the page opens; it can load when the visitor scrolls to the section that has it, or clicks a button. In Next.js, you can do this with dynamic(), importing a component client-side only when it's needed — the script never blocks the rest of the page from loading.

Reserving space against layout shift

The third source of CLS is content whose size isn't known ahead of time landing on the page later: an ad, a notification bar, a component that loads late. The fix relies on the same principle — reserve the space up front:

.media-container {
  aspect-ratio: 16 / 9;
}

.skeleton-card {
  min-height: 240px;
}

aspect-ratio reserves a box of the correct proportions before the content arrives; nothing around it moves once the content shows up. The same logic applies to a dynamically loaded component: make sure the skeleton element you show during loading is close in height to the real content's average height — a small, arbitrary placeholder turns into a big jump once the actual content arrives.

Measuring: don't guess, check with @next/bundle-analyzer

Once you've applied the four areas above, the only way to see which one actually helped is to measure. The Lighthouse tab in Chrome DevTools gives you the overall score; to see which JavaScript file is bloating your page, a bundle analyzer is a more direct tool:

npm install --save-dev @next/bundle-analyzer
// next.config.ts
import bundleAnalyzer from '@next/bundle-analyzer'

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})

export default withBundleAnalyzer(nextConfig)
ANALYZE=true npm run build

This command opens a visual map of what each page's bundle is made of — every rectangle is a module, sized by how many kilobytes it adds to the final bundle. What's usually surprising is finding an unused library, or an entire date library pulled in for a single date format. Once you've found it, you have three options: swap it for a smaller alternative, import only the function you actually need (most modern libraries support this), or load it with dynamic() only on the page that genuinely needs it. Not every page needs to carry the same large bundle — a dashboard page's charting library shouldn't slow down the homepage load for a visitor who never sees that page.

One last note: a Lighthouse score isn't a one-time target, it's a habit. Adding a new dependency, loading a new image — the score shifts every time. Applying these four areas once and forgetting about them doesn't keep the score above 95; measuring again after every significant change does.