Engineering

How to Get Lighthouse 100 in a Real Next.js App (Not a Demo)

Getting a perfect Lighthouse score on a todo app is easy. Doing it on a production e-commerce site with 50+ components, third-party scripts, and real images — that's the actual challenge.

Yazdan Asterki

Yazdan Asterki

Founder & Lead Engineer

May 15, 2025
12 min read
How to Get Lighthouse 100 in a Real Next.js App (Not a Demo)

The Problem with "Lighthouse 100" Claims

Most tutorials show you how to score 100 on a blank Next.js page with one

. That's useless. Real apps have Google Analytics, Intercom, custom fonts, hero images above the fold, and 30 npm packages.

This guide covers what we actually do at NineLab to hit 95–100 on production client sites.

The Three Killers of Lighthouse Score

1. Largest Contentful Paint (LCP) — Target: < 2.5s

LCP measures when the largest visible element renders. Almost always, this is your hero image.

Fix: Preload the hero image

// app/layout.tsx
export default function Layout({ children }) {
  return (
    <html>
      <head>
        <link
          rel="preload"
          as="image"
          href="/hero.jpg"
          fetchPriority="high"
        />
      </head>
      <body>{children}</body>
    </html>
  )
}

Fix: Use priority on the hero Image

<Image
  src="/hero.jpg"
  alt="Hero"
  fill
  priority // This adds fetchpriority="high" + preload link
  sizes="100vw"
/>

Fix: Avoid lazy loading above-the-fold images

// ❌ Wrong — never lazy load above-the-fold
<Image src="/hero.jpg" loading="lazy" />

// ✅ Correct
<Image src="/hero.jpg" priority />

2. Cumulative Layout Shift (CLS) — Target: < 0.1

CLS happens when elements move after initial render. Common culprits:

  • Images without explicit width/height
  • Fonts loading after text renders (FOIT/FOUT)
  • Dynamic content inserted above existing content
  • Third-party ads and embeds
  • Fix: Always specify image dimensions

    // ✅ Fixed dimensions prevent layout shift
    <Image src="/logo.png" width={200} height={60} alt="Logo" />
    
    // Or use aspect-ratio for responsive images
    <div style={{ aspectRatio: '16/9', position: 'relative' }}>
      <Image src="/cover.jpg" fill alt="Cover" />
    </div>
    

    Fix: Font display swap

    const inter = Inter({
      subsets: ['latin'],
      display: 'swap', // Show fallback font immediately, swap when loaded
    })
    

    3. Third-Party Scripts

    Google Tag Manager alone can cost you 15–20 Lighthouse points if loaded synchronously.

    Fix: Use Next.js Script component

    import Script from 'next/script'
    
    // afterInteractive = loads after page is interactive
    <Script
      src="https://www.googletagmanager.com/gtag/js"
      strategy="afterInteractive"
    />
    
    // lazyOnload = lowest priority, loads when browser is idle
    <Script
      src="https://widget.intercom.io/widget/xxx"
      strategy="lazyOnload"
    />
    

    Advanced: Bundle Analysis

    npm install @next/bundle-analyzer
    
    // next.config.mjs
    import bundleAnalyzer from '@next/bundle-analyzer'
    
    const withBundleAnalyzer = bundleAnalyzer({
      enabled: process.env.ANALYZE === 'true',
    })
    
    export default withBundleAnalyzer({})
    
    ANALYZE=true npm run build
    

    Look for any single module over 50KB. Those are your targets.

    The Checklist We Use at NineLab

  • [ ] Hero image uses `priority` prop
  • [ ] All images have explicit width/height or fill with aspect-ratio container
  • [ ] Fonts use `display: swap`
  • [ ] Google Analytics / GTM loaded with `afterInteractive`
  • [ ] No render-blocking scripts in ``
  • [ ] CSS not imported from node_modules in critical path
  • [ ] `next/image` used for all images (never raw `` for content images)
  • [ ] Dynamic imports for heavy components below the fold
  • Following this checklist consistently gets us 95+ on every project.

    Next.jsPerformanceCore Web VitalsSEO
    Yazdan Asterki

    Written by

    Yazdan Asterki

    Founder & Lead Engineer at NineLab

    Work with us →