Getting to 95+ Lighthouse in a Real Next.js App
Senior engineers building AI-native software for clients worldwide
Anyone can score 100 on a hello-world page. Hitting 95+ on a real Next.js app — with analytics, custom fonts, hero images, and a marketing team — takes a specific set of techniques. Here is the exact checklist we run, with code.
Scoring 100 on a hello-world page proves nothing. The interesting problem is scoring 95+ on a real application — one with a tracking pixel the marketing team refuses to remove, a custom font the brand guide demands, a hero image the designer keeps swapping, and eleven third-party scripts of varying legitimacy. We have taken multiple production Next.js apps from the low 60s into the mid-to-high 90s, and the work is never one magic fix. It is the same eight or nine techniques, applied in the same order. This is that checklist, with the actual code.
Step 0: Measure the Right Thing
Before touching code, two ground rules. First, lab scores and field data are different animals. Lighthouse in your DevTools is a lab test on your hardware; Google's Core Web Vitals assessment (and your search ranking) come from field data collected from real Chrome users over 28 days. We optimise against lab scores for fast iteration but treat field data — via the Vercel Speed Insights dashboard or the Chrome UX Report — as the source of truth. Second, always test production builds. next dev is unoptimised by design; auditing it wastes everyone's afternoon. Run next build && next start, test in an incognito window (extensions pollute traces), and run Lighthouse 3-5 times taking the median, because single runs vary by several points on any machine.
1. Let Server Components Delete Your JavaScript
The single biggest lever in the App Router era is shipping less JavaScript, and Server Components are how you do it wholesale. Every component that stays on the server contributes zero bytes to the client bundle — no download, no parse, no hydration cost. The practice that makes this work: push 'use client' boundaries to the leaves. The recurring crime we find in audits is a page-level component marked 'use client' because one button somewhere needs an onClick — which drags the entire page, and everything it imports, into the bundle.
Bad:
// page.tsx — 'use client' at the top poisons the whole tree
'use client'
import { HeavyChart } from './heavy-chart'
export default function Dashboard() { ... }Good — the page stays on the server, only the interactive leaf hydrates:
// page.tsx — Server Component (default)
import { StatsGrid } from './stats-grid' // server
import { ExportButton } from './export-button' // 'use client', tiny
export default async function Dashboard() {
const stats = await getStats() // direct DB call, 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 — add next/dynamic so their code loads on demand:
const RichEditor = dynamic(() => import('./rich-editor'), {
ssr: false,
loading: () => <EditorSkeleton />,
})On one client dashboard, converting the shell to Server Components and dynamically importing two chart libraries cut first-load JavaScript from roughly 480KB to under 200KB — worth about 15 Lighthouse points on mid-range mobile before we touched anything else.
2. Images: Where LCP Lives and Dies
In almost every audit we run, the Largest Contentful Paint element is a hero image, and fixing it follows the same recipe:
- Use
next/image, everywhere, no exceptions. It delivers AVIF/WebP automatically, generates responsive sizes, and enforces dimensions that prevent layout shift. - Mark the LCP image
priority. Everything below the fold lazy-loads by default; the one image users see first must not. Forgettingpriorityon the hero is the most common single-line fix in our audits, routinely worth 0.5-1.5s of LCP. - Set
sizeshonestly. Without it, the browser may download a 1920px image for a 400px slot.
<Image
src={hero}
alt="Product dashboard"
priority
sizes="(max-width: 768px) 100vw, 50vw"
className="rounded-xl"
/>Two more that people miss: keep the hero under ~200KB at source (no optimiser rescues a 4MB PNG gracefully), and never render the LCP image via CSS background-image — the preload scanner cannot see it, so it starts downloading late no matter how fast everything else is.
3. Fonts: Zero Layout Shift, No Render Blocking
next/font solved web fonts so thoroughly that any project still using <link> tags to a font CDN is leaving points on the table. It self-hosts the files (no third-party connection), inlines the @font-face CSS, and — the underrated part — applies size-adjusted fallback metrics so the swap from system font to web font barely moves the layout, which is where mysterious CLS usually comes from.
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}>...</html>
}Discipline items: two font families maximum, load only the weights you actually use (every weight is another file), and prefer variable fonts when you need several weights. We have removed 300KB of unused font weights from a single project's critical path.
4. Third-Party Scripts: The Score Killers
Here is an uncomfortable truth from our audits: past the basics, your Lighthouse score is mostly a negotiation with your third-party scripts. Analytics, chat widgets, session recorders, ad pixels — each one ships JavaScript that executes on your main thread during your users' first impression. The technical toolkit:
- Load everything through
next/scriptwith an explicit strategy.afterInteractivefor things that genuinely matter early;lazyOnloadfor everything else. Chat widgets, in particular, almost never deserve to load before idle. - Better still, load heavy widgets on intent — mount the chat bundle when the user hovers or clicks the launcher, not on page load. A static launcher button that swaps in the real widget on first interaction looks identical and costs nothing up front.
import Script from 'next/script'
<Script
src="https://widget.example.com/loader.js"
strategy="lazyOnload"
/>But the higher-leverage move is organisational: put a budget on it. We run @next/bundle-analyzer in CI and hold a simple line — any new third-party script must name what it displaces or get an explicit sign-off on the points it costs. When we audited one marketing site, three abandoned tracking scripts nobody could name an owner for were costing 11 points. Deleting code remains the best performance technique ever invented.
5. Caching and Rendering Strategy
The fastest server response is the one that is already rendered. Our defaults: marketing and content pages are statically generated with time-based revalidation, so they serve from the CDN edge and TTFB stops being a variable:
// blog/[slug]/page.tsx
export const revalidate = 3600 // regenerate at most hourlyTruly dynamic pages (dashboards, account areas) render dynamically — but usually do not need to be in your Lighthouse-critical path, because logged-in app views are not what Google is scoring for your acquisition pages. Keep the split deliberate: static-by-default for everything public, dynamic where personalisation genuinely demands it, and <Suspense> boundaries around the slow data sections of dynamic pages so the shell streams instantly instead of the whole page waiting on your slowest query.
6. The Long Tail: INP and CLS Details
Once LCP is under control, the remaining points hide in interaction and stability metrics:
- INP suffers from long hydration and long tasks. The Server Component work in step 1 is also your INP fix — less hydration, fewer long tasks. For expensive client-side work that remains, break it up (
startTransition, deferred values) so input handlers are never stuck behind a 400ms render. - CLS comes from anything sized after load: images without dimensions (solved by
next/image), fonts (solved bynext/font), and — the one frameworks cannot solve — content injected at the top of the page: cookie banners, announcement bars, A/B test swaps. Reserve the space in the initial layout or animate in ways that do not shift content (transform, overlay). A single late-arriving banner can push CLS past the 0.1 threshold on its own.
7. Lock It In: Budgets in CI
The saddest audits we run are second audits — apps that hit 95 six months ago and drifted back to 74 one innocent pull request at a time. Performance is not a project, it is a property, and properties need enforcement. Two mechanisms keep the score from decaying: run Lighthouse CI on every pull request against your staging build with assertions that fail the check when a category drops below threshold, and set a hard first-load JavaScript budget per route so a bundle regression is a red build, not a surprise in next quarter's field data. The point is not the tooling — it is moving the conversation from "who broke the score?" three months later to "this PR costs 40KB, is the feature worth it?" at review time, when the answer is still cheap.
The Order Matters
If you take one thing from this post, take the sequence, because it is ranked by effect size in the audits we run: production-build measurement first, then client-boundary cleanup and dynamic imports, then the LCP image, then fonts, then a hard negotiation with third-party scripts, then rendering/caching strategy, then INP/CLS detail work. Teams that start with the details invert the effort-to-points curve and burn out at 78.
And keep one honest caveat in view: Lighthouse is a proxy, not the goal. We have shipped 96-scoring pages that still felt slow because a critical API took two seconds — field data and real user journeys outrank the lab number every time they disagree. If your app is stuck in the 60s or 70s and you want it in the 90s without a rewrite, a performance audit is a one-to-two-week engagement for our web development team, and we hand you the prioritised list whether or not you hire us to execute it. Book a free consultation to get started.
Ready to build something like this?
Talk to Fajarix →