Web Dev

Getting to 95+ Lighthouse in a Real Next.js App

A practical checklist for raising Lighthouse scores in a real Next.js 15 or 16 app, with versioned code, and why Core Web Vitals field data matters more.

Scoring 100 on a hello-world page proves nothing. The harder problem is a high score on a real application: one with a tracking pixel the marketing team will not remove, a custom font the brand guide demands, a hero image the designer keeps swapping, and a pile of third-party scripts of varying usefulness. No single fix gets such an app to 95 or above, and nobody can promise that number for a codebase they have not seen. What does work is a short list of techniques applied in a sensible order. This is that list, with code.

Which versions this covers. This post targets the App Router in Next.js 15 and 16, and was checked against the Next.js 16 documentation in September 2026. Two things changed in version 16 that affect the code below: the image priority prop was deprecated in favor of preload, and caching moved to Cache Components and the use cache directive. Each code block is labeled with the versions it applies to. The snippets are illustrations to adapt, so run them against your own version before relying on them.

Step 0: Measure the Right Thing

Lighthouse is a lab tool. It loads your page once, on simulated hardware and a simulated network, and reports what happened. That makes it good for fast iteration and poor as a final verdict. What Google uses in Search is field data: the Core Web Vitals (LCP, INP and CLS) collected from real Chrome users and published in the Chrome UX Report as a rolling 28-day window. A page passes when the 75th percentile of visits meets the “good” thresholds: LCP within 2.5 seconds, INP at or below 200 milliseconds, CLS at or below 0.1. Google’s own Search documentation describes these metrics, and it does not mention a Lighthouse score.

There is a second gap between lab and field. Lighthouse cannot measure INP, because nobody interacts with the page during a lab run. It reports Total Blocking Time as a stand-in. A page can score 98 and still respond slowly to taps on a mid-range phone.

So treat the Lighthouse score as a development signal and field data as the result. If your site has too little traffic to appear in the Chrome UX Report, collect your own field data. Next.js ships a useReportWebVitals hook that can send the metrics to any analytics endpoint.

Two practical rules for the lab runs themselves. Test production builds: next dev is unoptimized by design, so run next build && next start and audit that. Then use an incognito window, because browser extensions pollute traces, and take the median of three to five runs, because single runs vary by several points on any machine.

1. Let Server Components Remove JavaScript

The biggest lever in the App Router is shipping less JavaScript. A component that stays on the server adds nothing to the client bundle: no download, no parse, no hydration. The practice that makes this work is pushing 'use client' boundaries down to the leaves. A common mistake is a page-level component marked 'use client' because one button somewhere needs an onClick. That single line pulls the whole page, and everything it imports, into the bundle.

Before:

// Next.js 13.4 to 16 (App Router)
// page.tsx: 'use client' at the top pulls the whole tree into the bundle
'use client'
import { HeavyChart } from './heavy-chart'

export default function Dashboard() { ... }

After. The page stays on the server and only the interactive leaf hydrates:

// Next.js 13.4 to 16 (App Router)
// page.tsx: Server Component (the default)
import { StatsGrid } from './stats-grid'       // server
import { ExportButton } from './export-button' // 'use client', small

export default async function Dashboard() {
  const stats = await getStats() // direct data access, no API hop
  return (
    <section>
      <StatsGrid data={stats} />
      <ExportButton />
    </section>
  )
}

For heavy client components that are not visible on load (charts below the fold, modals, editors), use next/dynamic so their code loads on demand. One detail changed along the way: since Next.js 15, ssr: false is rejected inside a Server Component, so the dynamic import has to live in a small client wrapper. The lazy loading guide covers the variations.

// Next.js 15 and 16
// editor-slot.tsx
'use client'
import dynamic from 'next/dynamic'

const RichEditor = dynamic(() => import('./rich-editor'), {
  ssr: false,
  loading: () => <EditorSkeleton />,
})

export function EditorSlot() {
  return <RichEditor />
}

How much this saves depends on what the page was shipping. Suppose a dashboard marks its shell 'use client' and imports two charting libraries at the top level. Moving the shell to the server and loading the charts on demand can remove hundreds of kilobytes from the first load. Check the “First Load JS” column of your own build output before and after, and let that number, not an estimate, tell you what the change was worth.

2. Images: Where LCP Is Won or Lost

On most marketing pages the Largest Contentful Paint element is the hero image, so the recipe is short.

  • Use next/image for content images. It generates responsive sizes, serves WebP by default (AVIF if you add it to images.formats in the config), and requires dimensions, which prevents layout shift.
  • Tell the browser the hero matters. Images lazy-load by default, and the one image visitors see first should not.
  • Set sizes to match the layout. Without it, the browser may download a 1920px file for a 400px slot.

How you flag the hero depends on your version. In Next.js 15 and earlier it was the priority prop:

// Next.js 13 to 15 ('priority' is deprecated from 16.0)
<Image
  src={hero}
  alt="Product dashboard"
  priority
  sizes="(max-width: 768px) 100vw, 50vw"
/>

The Image component reference records that priority was deprecated in version 16.0.0 and replaced by preload, which inserts a preload link in the document head. The same page adds that in most cases loading="eager" or fetchPriority="high" is the better choice, and that preload should not be combined with either of them.

// Next.js 16: option A, preload the hero
<Image
  src={hero}
  alt="Product dashboard"
  preload
  sizes="(max-width: 768px) 100vw, 50vw"
/>

// Next.js 16: option B, no preload link, high fetch priority
<Image
  src={hero}
  alt="Product dashboard"
  loading="eager"
  fetchPriority="high"
  sizes="(max-width: 768px) 100vw, 50vw"
/>

Pick one option per image. If the LCP element differs between mobile and desktop, the docs advise against preload, since you would preload an image half your visitors do not need.

Two more points are easy to miss. Keep the source file reasonable, because no optimizer rescues a 4MB PNG gracefully. And avoid rendering the LCP image as a CSS background-image: the browser’s preload scanner cannot see it, so the download starts late however fast everything else is.

3. Fonts: Self-Hosted, With Matched Fallbacks

next/font downloads font files at build time and serves them from your own domain, so the browser makes no request to a font CDN. It also generates a fallback font with adjusted metrics, so the swap from system font to web font barely moves the layout. That swap is where a lot of unexplained CLS comes from.

// Next.js 13.2 to 16
import { Inter } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
})

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  )
}

The rest is discipline: two font families at most, only the weights you use (each static weight is another file), and a variable font when you need several weights. If a design system defines the type scale, the list of weights is already written down, which makes this an easy check.

4. Third-Party Scripts: The Hard Conversation

Once the basics are done, the remaining score is mostly a negotiation over third-party scripts. Analytics, chat widgets, session recorders and ad pixels all run JavaScript on the main thread during the first few seconds of a visit. The technical part is simple:

  • Load them through next/script with an explicit strategy: afterInteractive for scripts that matter early, lazyOnload for the rest. A chat widget rarely needs to load before the browser is idle.
  • Better, load heavy widgets on intent. Render a static launcher button and mount the real widget when the visitor first hovers or clicks. It looks identical and costs nothing up front.
// Next.js 13 to 16 (App Router)
import Script from 'next/script'

<Script
  src="https://widget.example.com/loader.js"
  strategy="lazyOnload"
/>

The organizational part is harder and matters more. Give every script an owner and a reason, and review the list on a schedule. Scripts with no owner are common on sites that have been through a few marketing campaigns, and removing one is the cheapest performance work available. For your own code, a bundle analyzer in CI makes growth visible at review time. Tag managers deserve particular care, because they let scripts reach production without a code review.

Consent is part of the same topic. Scripts that wait for a cookie choice do not run during a Lighthouse test, so the lab score reflects the page before consent. Field data includes visitors who accepted. Expect the two to differ.

5. Caching and Rendering Strategy

The fastest response is one that has already been rendered. Public pages (marketing, blog, documentation) should be prerendered and served from a cache, with revalidation on a timer or on demand. How you express that changed in Next.js 16.

In the previous model, a route segment export did it:

// Next.js 13 to 15, and Next.js 16 with cacheComponents turned off
// app/blog/[slug]/page.tsx
export const revalidate = 3600 // regenerate at most once an hour

Next.js 16 introduced Cache Components, which you turn on with a config flag. With it enabled, caching is explicit: you mark a function, component or page with use cache and give it a lifetime. According to the route segment config reference, the revalidate, dynamic and fetchCache exports are removed in this mode, so the block above stops applying.

// Next.js 16 with Cache Components
// next.config.ts
const nextConfig = { cacheComponents: true }
export default nextConfig

// app/blog/[slug]/page.tsx
import { cacheLife } from 'next/cache'

async function getPost(slug: string) {
  'use cache'
  cacheLife('hours')
  return db.posts.findBySlug(slug)
}

In this model, anything that reads cookies, headers or uncached data sits inside a <Suspense> boundary. The static shell is served at once and the dynamic part streams in, which is the behavior earlier versions called Partial Prerendering. For LCP the rule is unchanged across versions: keep the LCP element in the prerendered part of the page, and keep slow queries behind a boundary so the shell does not wait for them.

Logged-in views such as dashboards and account areas render per request. They are rarely the pages that show up in search or carry your acquisition traffic, so spend the first effort on public routes.

6. The Long Tail: INP and CLS

With LCP under control, the remaining work is in the two metrics Lighthouse models least well.

INP suffers from long tasks on the main thread. The Server Component work in step 1 is also the main INP fix: less hydration, fewer long tasks. For expensive client-side updates that remain, startTransition and useDeferredValue keep input handlers from waiting behind a large render. Measure INP in the field or with the Performance panel while interacting with the page, since a Lighthouse run cannot produce it.

CLS comes from anything sized after load. next/image and next/font handle images and fonts. The case no framework solves is content injected at the top of the page: cookie banners, announcement bars, A/B test swaps. Reserve the space in the initial layout, or use an overlay or a transform that does not push content down. One late banner can take a page past the 0.1 threshold by itself.

7. Lock It in With Budgets in CI

Performance decays one reasonable pull request at a time. A page that scored well at launch can drift a long way in six months with nobody making an obviously bad decision. The remedy is enforcement. Lighthouse CI runs the audit on every pull request and fails the check when a category drops below a threshold you choose:

// lighthouserc.json (Lighthouse CI, independent of the Next.js version)
{
  "ci": {
    "collect": {
      "startServerCommand": "npm run start",
      "url": ["http://localhost:3000/"],
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }]
      }
    }
  }
}

Set the threshold a few points below your current median so normal run-to-run variance does not block merges. Pair it with a first-load JavaScript budget per route. The tooling matters less than the change in conversation: “this pull request adds 40KB, is the feature worth it?” at review time is far cheaper than “who broke the score?” three months later.

The Order Matters

The sequence above is ranked by typical effect. Measure production builds first. Then clean up client boundaries and dynamic imports, fix the LCP image, sort out fonts, negotiate the third-party scripts, settle rendering and caching, and finish with INP and CLS details. Teams that begin with the details spend a lot of effort for few points.

Keep the caveat from step 0 in view: Lighthouse is a proxy. A page can score 96 and still feel slow because one API call takes two seconds, and when lab and field disagree, the field wins. Performance work also has a price, so it belongs in the estimate from the start. Our post on what a custom web app costs covers how that kind of work is budgeted.

If your Next.js app is stuck in the 60s or 70s and you would like a second pair of eyes, our web development team can review it and give you a prioritized list of fixes. Get a quote and tell us what you are seeing.

All articles

Need Help With This?

Tell us what you are building. We reply within 24 hours and suggest a team or individual developers that fit the work.