A Lighthouse 100/100/100/100 score on a real Next.js 16 site is not a stunt. It is achievable, repeatable, and worth doing if you are running a marketing site that needs to convert. We hit it on this site and keep it there. The trick is that "100" is not one optimization - it is a stack of small decisions made consistently. This post is the actual checklist we use, the order we apply it, and the gotchas that catch every team that tries.
For context on why this matters in 2026, the Web Design and UX 2026 Playbook and Core Web Vitals in 2026: What Still Matters cover the strategic angle. This post is purely the implementation.
The TL;DR
- Lighthouse 100 is a lab score. It correlates with but does not equal real-user CWV. Optimize for both.
- The biggest LCP win is almost always image-related. AVIF, fetchPriority, sizes, and a CDN.
next/fontsaves 200-500ms of FCP by eliminating render-blocking font requests.- Third-party scripts kill the Performance score faster than anything else. Audit ruthlessly.
- ISR over SSR for marketing pages. Static-with-revalidation hits TTFB targets that SSR cannot.
- Code-split below-the-fold and modal content. Hydration cost is the silent INP killer.
- The React Compiler in Next.js 16 helps for free - upgrade and let it do work.
Why Lighthouse 100 actually matters (and where it does not)
Lighthouse 100 is a synthetic score in a controlled environment. It is not the same thing as passing Core Web Vitals at p75 of real users. A site with Lighthouse 100 in your CI but Lighthouse 60 from a Pixel 6a on flaky LTE is failing the user that matters.
That said, Lighthouse 100 is worth chasing for two reasons:
- It forces you to apply every known best practice. You cannot reach 100 without proper image handling, font loading, JS deferral, and accessibility basics. The discipline produces a faster site for real users too.
- It catches regressions in CI. A Lighthouse score that drops from 100 to 92 on a PR is an immediate signal that something got slower. Without that signal, performance decays one PR at a time.
What 100 does not give you: real-user pass rate. For that, monitor CrUX through Search Console or PageSpeed Insights and treat Lighthouse as the synthetic complement, not the source of truth.
The four scores and what each measures
| Score | What it measures | Where it usually fails |
|---|---|---|
| Performance | LCP, INP, CLS, FCP, TBT, TTI | Images, fonts, JS, third-party scripts |
| Accessibility | Color contrast, ARIA, semantic HTML | Contrast, missing alt, missing labels |
| Best Practices | HTTPS, console errors, deprecated APIs | console.log in production, mixed content |
| SEO | Meta tags, robots.txt, mobile-friendly | Missing description, missing viewport |
Performance is the hard one. The other three are checklist items you fix once. Performance is a discipline you maintain.
Step 1: Image optimization
LCP is almost always the hero image, and the hero image is almost always under-optimized. The full Next.js 16 pattern:
import Image from 'next/image';
import heroImage from '@/public/images/hero.jpg';
export default function Hero() {
return (
<Image
src={heroImage}
alt="DesignKey Studio team building a SaaS product"
priority
fetchPriority="high"
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 80vw, 1200px"
placeholder="blur"
className="w-full h-auto"
/>
);
}
What each piece does:
prioritytells Next.js to preload this image. Use on the LCP image only. One per page.fetchPriority="high"is the explicit browser hint.priorityalready sets it; the explicit attribute is for non-Next image cases.sizeslets the browser pick the right resolution from the srcset. Without it, the browser fetches the largest variant.placeholder="blur"with a static import gives you an automatic LQIP. No CLS, no flash of empty.altis mandatory for accessibility.
Configure Next.js to serve AVIF first, WebP fallback:
// next.config.mjs
export default {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
};
AVIF is roughly 30% smaller than WebP at the same visual quality. Modern browsers support it; older ones fall through to WebP. The combination saves dramatic bytes on mobile.
If you serve from a separate domain or CDN, configure the loader. For Cloudflare or Imgix, use the loader prop. For Vercel deployments, the built-in optimizer is good enough that custom loaders rarely pay off.
Step 2: Font loading with next/font
Render-blocking fonts are the second-most-common LCP culprit. next/font solves this with a single import:
// src/app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
preload: true,
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
);
}
What this does:
- Self-hosts the font so there is no third-party round-trip to Google Fonts.
- Subsets to Latin only so the font file is 60% smaller than the full international set.
display: swaprenders text in the fallback immediately, swaps when the font loads. No FOIT.- Preloads the font in
<head>so the browser fetches it before parsing the page.
The combination improves FCP by 200-500ms on cold loads. Compared to the old pattern of <link href="https://fonts.googleapis.com/..."> in head, it is dramatic.
For variable fonts, prefer them over loading 3-4 weights separately. One variable font file replaces multiple static files at smaller total weight.
The CLS gotcha: even with display: swap, the fallback font has different metrics than the real font, which causes layout shift when it swaps. Next 16 ships an automatic adjustment via the adjustFontFallback option (on by default for Google Fonts) that calculates size-adjust, ascent-override, descent-override, and line-gap-override to match the fallback as closely as possible. Leave it on.
Step 3: Audit and remove third-party scripts
This is the unglamorous one and it is the biggest performance win on most sites. The script audit:
# Open Chrome DevTools > Network > filter by JS, sort by size
# Or run: lighthouse https://yoursite.com --view --preset=desktop
# Look at "Reduce the impact of third-party code"
The usual offenders:
- Analytics scripts - GA4, Plausible, Mixpanel. Most teams use 2-3 when one is enough.
- Tag managers - GTM is convenient and expensive. Every tag inside it adds weight.
- Chat widgets - Intercom, Drift, Zendesk Chat. Easily 200KB+ and they hurt INP.
- Ad scripts - on B2B SaaS marketing sites, these are usually unnecessary.
- A/B testing tools - VWO, Optimizely. The flicker prevention often blocks render.
- Heatmap scripts - Hotjar, FullStory. Useful but heavy. Load only on sample.
The fix patterns:
- Audit usage before retention. Most third-party scripts are installed once and never reviewed. Pull a list, ask which ones the team actually uses.
- Use Next.js
<Script>with the right strategy.strategy="afterInteractive"for analytics that does not need to fire before user interaction.strategy="lazyOnload"for chat widgets. Almost neverbeforeInteractive(it blocks). - Defer chat widgets to user intent. Load Intercom only after the user clicks a "Chat with us" trigger, not on page load. The conversion impact is zero; the performance impact is large.
- Self-host where possible. GA4 cannot be self-hosted; Plausible can. Self-hosted analytics avoids one third-party domain and the associated DNS + TLS round-trip.
// src/app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script
strategy="afterInteractive"
src="https://plausible.io/js/script.js"
data-domain="designkey.studio"
/>
</body>
</html>
);
}
Step 4: Code-splitting and below-the-fold deferral
Hydration cost is the silent INP killer. Every component in your initial bundle adds JavaScript that runs on hydration. Below-the-fold and modal content does not need to be in that bundle.
import dynamic from 'next/dynamic';
// Below-the-fold testimonial carousel
const TestimonialCarousel = dynamic(() => import('@/components/TestimonialCarousel'), {
loading: () => <div className="h-96 bg-muted animate-pulse" />,
});
// Modal that only opens on user click
const PricingModal = dynamic(() => import('@/components/PricingModal'), {
ssr: false,
});
The skeleton in loading prevents CLS. The ssr: false for modal content tells Next not to even render it server-side, since it is hidden by default.
For App Router specifically, prefer Server Components by default and only mark components 'use client' when they need state, effects, or browser APIs. A Server Component renders to HTML on the server and ships zero JS to the client. The less client JS, the faster the page.
Step 5: ISR over SSR for marketing pages
For pages that change infrequently (homepage, services, about, pricing, blog posts), Incremental Static Regeneration is dramatically faster than Server-Side Rendering.
// src/app/page.tsx
export const revalidate = 3600; // Regenerate at most once per hour
export default async function HomePage() {
const data = await getHomePageData();
return <Home data={data} />;
}
What this does: the page renders to static HTML at build time, served from the CDN edge with sub-100ms TTFB. Next regenerates the static HTML in the background after the revalidate window passes. Users always get the static version; no user ever waits for the SSR.
For pages that need to be fresh per request (dashboard, account, anything user-specific), SSR is correct. For marketing surfaces, ISR is the right choice 95% of the time.
The on-demand revalidation pattern handles the cases where you need fresh content faster than the schedule:
// On a CMS webhook
import { revalidatePath } from 'next/cache';
revalidatePath('/blog');
revalidatePath(`/post/${slug}`);
The CMS publishes, the webhook fires, and the relevant pages regenerate immediately. Best of both worlds.
Step 6: The before/after on a real audit
We ran a Lighthouse audit on a recent client marketing site at three points: pre-optimization, mid-optimization, and final.
| Metric | Before | After step 3 | After step 5 |
|---|---|---|---|
| Performance | 64 | 88 | 100 |
| Accessibility | 87 | 100 | 100 |
| Best Practices | 92 | 100 | 100 |
| SEO | 91 | 100 | 100 |
| LCP | 4.2s | 2.1s | 1.3s |
| INP | 312ms | 184ms | 96ms |
| CLS | 0.18 | 0.05 | 0.02 |
| FCP | 1.8s | 1.0s | 0.6s |
The biggest single jump was step 3 (third-party script audit). The site had 11 third-party scripts; we removed 6, deferred 3, and self-hosted analytics. Performance jumped 24 points before any image or font work.
The next biggest jump was step 1 (image optimization). The hero image was a 1.4MB JPEG; converted to AVIF with proper sizes, it dropped to 87KB. LCP went from 2.1s to 1.5s on that change alone.
The final push to 100 came from step 5 (ISR) plus a few accessibility fixes (color contrast on muted text, missing labels on a search input). None of those were heavy lifts; they were just the last 5%.
Common mistakes that cap you at 92
After auditing dozens of these:
- Loading the full Google Fonts CSS instead of using
next/font. Costs ~150ms of FCP. - Using
<img>instead of<Image>"because Next.js Image is annoying." It is annoying once; it pays back forever. - Not setting
priorityon the LCP image. Browser cannot guess; tell it. - Inline critical CSS via a build step. Next.js handles this automatically; manual injection often regresses.
- Excessive
'use client'. Mark only what needs state. The rest stays on the server. - Deploying to Vercel Hobby for a production site. The 10s function timeout and shared resources hurt p95 performance.
- Forgetting that mobile audits matter most. A desktop 100 with a mobile 72 is failing the audience that matters.
Where to start
If your current Lighthouse score is in the 60-80 range:
- Run Lighthouse on mobile profile, throttled. That is your real starting point.
- Audit third-party scripts first. Biggest win, lowest effort. Most teams remove or defer half of them with no business impact.
- Optimize the LCP image. AVIF, priority, sizes, placeholder. One day of work.
- Switch fonts to
next/font. One hour of work, 200-500ms of FCP. - Move marketing pages to ISR. One day of work, dramatic TTFB improvement.
If you are already in the 90s and chasing the last 5-10 points, the wins come from removing rather than adding: fewer scripts, less client JS, fewer below-the-fold components in the initial bundle. The diminishing returns are real but worth chasing if you care about real-user CWV pass rate.
For ongoing monitoring, gate Lighthouse-CI on every PR. The threshold can be relative ("PR cannot drop performance more than 5 points from main") rather than absolute, which catches regressions without blocking merges on noise.
For the broader picture on web performance and AI search, the Core Web Vitals 2026 post covers what real-user data shows in 2026. The How to Choose the Right Tech Stack post covers the framework-choice angle that affects what is achievable from day one.
If you want a second opinion on whether your Next.js site is leaving Lighthouse points on the table, that is the kind of audit we run as part of our Front-End Development service and UX/UI Design service. The first audit is free, and we will tell you the three changes that move the needle most.
Want a second opinion on your Lighthouse scores? Contact us for a free 30-minute consultation.