Performance Engineering · Next.js 15

Achieving Lighthouse 100
with Next.js 15

A deep dive into Server Components, image optimization, and bundle splitting strategies that push Lighthouse scores to perfection.

ZH
Zubair Hussain zovatotech@gmail.com
18
min read
2025
updated
100
Performance
100
Accessibility
100
Best Practices
100
SEO

A perfect Lighthouse score isn't a vanity metric — it's proof that your site respects every user's time, bandwidth, and device. With Next.js 15, the App Router and React Server Components give you the raw material. But raw material alone doesn't score 100. This guide is the blueprint.

Why 100/100 actually matters

In 2021 Google made Core Web Vitals a direct ranking signal. Since then, countless studies have confirmed the commercial impact: every 100ms reduction in page load time correlates with a ~1% revenue lift for e-commerce sites. A Lighthouse 100 means your LCP is under 2.5s, your CLS is under 0.1, and your INP (Interaction to Next Paint, which replaced FID in 2024) is under 200ms.

< 2.5s
LCP target
Largest Contentful Paint. The largest visible element must load fast.
< 0.1
CLS target
Cumulative Layout Shift. No elements jump around after render.
< 200ms
INP target
Interaction to Next Paint. UI responds instantly to every input.

What changed with Next.js 15 is that the compiler itself leans harder into static analysis, partial prerendering (PPR), and granular hydration. You get more performance headroom than any previous version — but you still have to wire it up correctly.

Server Components: The foundation of speed

React Server Components (RSC) are the single biggest performance lever in Next.js 15. They render on the server, send zero JavaScript to the client, and eliminate the waterfall of client-side data fetching that used to be unavoidable.

The component boundary model

Every component in the App Router is a Server Component by default. The only time you add 'use client' is when you genuinely need interactivity — useState, useEffect, browser APIs, or event handlers. The discipline here is ruthless: push 'use client' as far down the tree as possible.

RSC boundary architecture
SERVER — zero JS sent to browser layout.tsx Server Component · async · fetches data at build time / per request page.tsx Server Component Suspense boundary → no hydration cost Counter.tsx 'use client' useState · useEffect → hydrated island Footer.tsx Server Component Static HTML → zero JS bundle

Notice that Counter.tsx is an isolated island — its JavaScript ships to the browser, but layout.tsx, page.tsx, and Footer.tsx ship nothing. This is selective hydration at the component level, and it's the reason RSC-heavy apps routinely have First Input Delay of zero.

Async data fetching in Server Components

Because Server Components are async by default, you can await data directly in the component body with zero client-side loading states for the initial render:

TypeScript app/products/page.tsx
// No 'use client' → this is a Server Component
import { ProductCard } from '@/components/ProductCard'
import { getProducts } from '@/lib/api'

// next: { revalidate } makes this ISR — best of both worlds
async function getProductsWithCache() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 } // ISR: revalidate every hour
  })
  return res.json()
}

export default async function ProductsPage() {
  // Data is fetched server-side — no useEffect, no loading spinner
  const products = await getProductsWithCache()

  return (
    <section>
      {products.map((p) => (
        <ProductCard key={p.id} product={p} />
      ))}
    </section>
  )
}
💡
Deduplication is free. Next.js 15 automatically deduplicates identical fetch() calls within a single render pass. If three Server Components each call the same endpoint, only one HTTP request fires. No need for a global data layer or React Query on the server.

Image optimization done right

Images are consistently the #1 LCP killer. The Next.js <Image> component handles WebP/AVIF conversion, responsive srcset, and lazy loading automatically — but most developers leave significant performance on the table by not configuring it correctly.

The LCP image must be preloaded

Your hero image is almost certainly the LCP element. If you lazy-load it (the default), your LCP score collapses. The fix is explicit priority:

JSX app/(home)/HeroBanner.tsx
import Image from 'next/image'

export function HeroBanner() {
  return (
    <div className="relative h-[560px]">
      <Image
        src="/hero.jpg"
        alt="Hero banner showing our product"
        fill
        sizes="100vw"
        priority          {/* ← injects <link rel="preload"> */}
        quality={85}
        placeholder="blur"
        blurDataURL="data:image/webp;base64,..."
      />
    </div>
  )
}
⚠️
The sizes attribute is not optional. Without it, the browser assumes a 100vw image on every breakpoint, downloads a 2560px image on a 375px phone, and tanks your score. Always write an accurate sizes value — this single change can save 200–400KB per page view on mobile.

AVIF vs WebP: which format wins?

Format Browser support Compression vs JPEG Encode speed Recommendation
AVIF 93% global (2025) ~50% smaller Slow Enable first
WebP 97% global ~30% smaller Fast Automatic fallback
JPEG 100% Baseline Fastest Legacy fallback
TypeScript next.config.ts
import type { NextConfig } from 'next'

const config: NextConfig = {
  images: {
    // AVIF first — 50% smaller than JPEG for photos
    formats: ['image/avif', 'image/webp'],

    // Define all srcset breakpoints you actually use
    deviceSizes: [375, 640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],

    // Minimize layout shift with explicit aspect ratios
    minimumCacheTTL: 86400, // 24h CDN cache
  },
  // PPR: partial prerender static shells with dynamic holes
  experimental: {
    ppr: 'incremental',
  },
}

export default config

Bundle splitting strategies

JavaScript is the most expensive asset on the web — byte for byte, it costs 10–30× more CPU time than equivalent bytes of image data. Every kilobyte of JS you eliminate from the critical path is worth more than a proportional image saving.

Visualizing your bundle before you optimize

Typical unoptimized Next.js bundle (before)
framework.js
142kb
main.js
118kb
pages/_app.js
79kb
recharts.js
149kb
date-fns.js
63kb
Total (gzip)
551kb
After RSC + dynamic imports + tree shaking (after)
framework.js
90kb
main.js
36kb
recharts (lazy)
149kb
date-fns (server)
0kb
Critical path
126kb

The waterfall above is real-world. Moving date-fns formatting to a Server Component alone eliminated 63kb from the browser bundle. The trick is recognizing which libraries are data transformation utilities (never need the DOM, belong on the server) versus UI libraries (must run in the browser).

Dynamic imports and lazy loading

TypeScript app/dashboard/Charts.tsx
'use client'

import dynamic from 'next/dynamic'

// Recharts is 149kb — load only when the chart is visible
const RevenueChart = dynamic(
  () => import('@/components/RevenueChart'),
  {
    loading: () => <ChartSkeleton />,
    ssr: false, // Recharts uses window — skip SSR
  }
)

// Heavy modal — only load JS when user clicks
const VideoModal = dynamic(
  () => import('@/components/VideoModal')
)

export function Dashboard() {
  return (
    <main>
      <RevenueChart /> {/* 149kb deferred until visible */}
    </main>
  )
}

Analysing what ships to the browser

Never optimize blind. Run @next/bundle-analyzer to get a visual breakdown of every module in your output. The command is simple:

# Install
npm install @next/bundle-analyzer

# Run analysis (outputs an HTML treemap)
ANALYZE=true npm run build

Look for three categories of waste: duplicated packages (two versions of the same lib), large polyfills that modern browsers don't need, and client components that import server-only code — this last one is the most common mistake and ships Node.js utilities to the browser.

Core Web Vitals in depth

Fixing Cumulative Layout Shift (CLS)

Layout shift is frustrating to debug because it happens during loading, not after. The most common causes are images without explicit dimensions, fonts swapping in late, and dynamically injected banners or ad slots. Fix each class:

CSS / TSX Layout shift remediation
/* 1. Always reserve space for images */
.hero-img {
  aspect-ratio: 16 / 9;   /* locks layout before image loads */
  width: 100%;
  object-fit: cover;
}

/* 2. Reserve space for ads / embeds */
.ad-slot {
  min-height: 90px;        /* leaderboard standard */
  contain: layout;          /* isolate from rest of page */
}

/* 3. Skeleton-based loading states */
.skeleton {
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

Optimising Interaction to Next Paint (INP)

INP measures the worst interaction latency across the user's entire session. The main culprit is long tasks on the main thread. In Next.js, this usually means:

TypeScript Keeping the main thread free
'use client'
import { useState, useTransition, useDeferredValue } from 'react'

export function SearchResults({ items }: { items: Item[] }) {
  const [query, setQuery] = useState('')
  const [isPending, startTransition] = useTransition()
  
  // Deferred: React can deprioritize re-rendering this
  const deferred = useDeferredValue(query)
  
  // Expensive filter runs at lower priority — input stays responsive
  const filtered = items.filter(i =>
    i.name.toLowerCase().includes(deferred.toLowerCase())
  )

  return (
    <div>
      <input
        value={query}
        onChange={e => {
          // Mark the state update as non-urgent
          startTransition(() => setQuery(e.target.value))
        }}
      />
      {filtered.map(i => <ResultRow key={i.id} item={i} />)}
    </div>
  )
}

Font loading without the FOUT

Flash of Unstyled Text (FOUT) tanks your CLS score and looks unprofessional. Next.js 15 ships a first-class font system that eliminates it:

TypeScript app/layout.tsx
import { Inter, Syne } from 'next/font/google'

// next/font automatically:
// · Downloads fonts at build time (no runtime Google Fonts request)
// · Self-hosts from your own domain → no third-party connection
// · Generates size-adjust CSS to avoid FOUT
// · Inlines critical font face declarations

const syne = Syne({
  subsets: ['latin'],
  weight: ['400', '700', '800'],
  variable: '--font-syne',
  display: 'swap',
  preload: true,
})

const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',
  display: 'swap',
  // Only load the weights you actually use!
  weight: ['300', '400', '500'],
})

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={`${syne.variable} ${inter.variable}`}>
      <body>{children}</body>
    </html>
  )
}
💡
One typeface per weight subset. Each additional weight in next/font is a separate HTTP/2 push. Loading Inter 300 + 400 + 500 + 600 + 700 is five requests where two would do. Audit your CSS: every unique font-weight value you use must have a corresponding subset loaded — and every subset you load must be used.

Caching layers and ISR

Next.js 15 gives you four distinct caching layers. Understanding which layer to engage for which data pattern is the difference between a 30-point performance spread:

Next.js 15 caching layers
Browser HTTP Cache Cache-Control Router Cache Client-side RSC Edge / CDN Full Route Cache Static HTML + RSC ISR Stale Cache revalidate: N Next.js Server Data Cache fetch() memoize Request Memo Per render dedup APIs / DB Origin Cold data miss miss miss Free (no latency) Fast (edge hit) Medium (server) Slow (origin)

The goal is to serve as many requests as possible from the leftmost layers. A page served from the Full Route Cache at the edge never touches your server — it returns in single-digit milliseconds from a CDN node 30km from the user.

Final audit checklist

Run through this list before every production deploy. Every item is a direct Lighthouse signal:

A perfect Lighthouse score is an outcome, not a goal. The goal is a site that feels instant, works on every device, and respects your users' bandwidth. The score just proves you got there. With Next.js 15's RSC model, next/image, next/font, and the layered caching system, the tools have never been better. The strategy above got a real production Next.js app from a 71 to a 100 in a single sprint. It'll do the same for yours.

ZH

Zubair Hussain

Full-stack developer & co-founder of Xovato — a digital agency based in Hyderabad, Pakistan. I build performant web applications with the MERN stack, Next.js, and AI integrations for clients in the US and Australia.

thezubairh@gmail.com