A 100 Lighthouse score is achievable in Next.js - but "use Next.js" is not enough. The framework eliminates some performance problems by default, but a handful of common mistakes keep scores stuck in the 70s regardless of server speed. Here are the techniques that actually make the difference, in order of impact.
Understanding what Lighthouse measures
Lighthouse Performance is a weighted average of five metrics. Knowing the weights tells you where to focus:
| Metric | Weight | Target |
|---|---|---|
| Total Blocking Time (TBT) | 30% | < 200ms |
| Largest Contentful Paint (LCP) | 25% | < 2.5s |
| Cumulative Layout Shift (CLS) | 15% | < 0.1 |
| First Contentful Paint (FCP) | 10% | < 1.8s |
| Speed Index | 10% | < 3.4s |
TBT (30% weight) is the most impactful and most commonly ignored. It measures main thread blocking from JavaScript execution. Third-party scripts, large client bundles, and unoptimised renders all drive it up.
1. next/image - not optional
A single unoptimised image can cost 10-15 Lighthouse points through LCP delay and layout shift:
// ❌ Causes layout shift + no format optimisation
<img src="/hero.jpg" alt="Hero" />
// ✅ Prevents layout shift, serves WebP, lazy loads by default
import Image from "next/image";
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={630}
priority // only for above-the-fold images - the LCP image
sizes="(max-width: 768px) 100vw, 1200px"
/>
Critical: only use priority on your actual LCP image. Using it on multiple images defeats the optimisation and can hurt performance.
2. next/font - eliminate FOUT and the extra network request
Loading Google Fonts with a <link> tag blocks rendering and causes a flash of unstyled text:
// ❌ External request, FOUT, no size-adjust
// <link href="https://fonts.googleapis.com/css2?family=Inter..." />
// ✅ Self-hosted, zero layout shift, no FOUT
import { Inter, Playfair_Display } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
// layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.variable}>
<body className="font-sans">{children}</body>
</html>
);
}
3. Bundle analysis - find weight before you ship it
A single unoptimised import can add 200KB to your bundle:
npm install @next/bundle-analyzer
# next.config.ts
import bundleAnalyzer from "@next/bundle-analyzer";
const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true" });
export default withBundleAnalyzer({});
# Run
ANALYZE=true npm run build
Common offenders to look for in the treemap:
// ❌ Moment.js (~250KB)
import moment from "moment";
// ✅ Native Intl API (zero bundle cost)
const formatted = new Intl.DateTimeFormat("en-US", {
month: "short", day: "numeric", year: "numeric"
}).format(date);
// ❌ Full lodash (~70KB)
import _ from "lodash";
// ✅ Native methods
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
4. Static generation over dynamic rendering wherever possible
Static pages are served from a CDN - the difference between 50ms and 500ms TTFB. Pages are static by default in App Router unless you opt into dynamic rendering:
// Static by default - pre-rendered at build time
export default async function BlogPage() {
const posts = await getPosts(); // runs at build time
return <PostList posts={posts} />;
}
// ISR - revalidate on a schedule
export const revalidate = 3600; // re-generate every hour
// Forces dynamic - avoid unless necessary:
// - Using cookies() or headers() from next/headers
// - export const dynamic = "force-dynamic"
5. Lazy-load heavy client components
Chart libraries, rich text editors, and map components should not be in the initial bundle if they appear below the fold:
import dynamic from "next/dynamic";
const RevenueChart = dynamic(
() => import("@/components/revenue-chart"),
{
loading: () => <ChartSkeleton />,
ssr: false, // chart library may need browser APIs
}
);
// Modal loads only when opened - not in initial bundle
const UserModal = dynamic(() => import("@/components/user-modal"), {
ssr: false,
});
6. Third-party scripts: use next/script with the right strategy
A single marketing script can add 500ms of TBT. next/script controls when scripts load:
import Script from "next/script";
// afterInteractive - after page is interactive (analytics, chat)
<Script src="https://analytics.example.com/script.js" strategy="afterInteractive" />
// lazyOnload - during browser idle time (social embeds, widgets)
<Script src="https://platform.twitter.com/widgets.js" strategy="lazyOnload" />
// beforeInteractive - before hydration (consent managers only)
<Script src="/consent.js" strategy="beforeInteractive" />
7. Fix CLS: the hidden Lighthouse killer
Common CLS sources in Next.js apps:
-
Images without dimensions - always provide
width/heightor usefillwith a sized container -
Dark mode flash - elements shift if the theme changes after first paint. Fix with a blocking inline script, not
useEffect -
Cookie banners - use
position: fixedat the bottom so they don't push content - Skeleton loaders wrong height - match skeleton dimensions to actual content
/* Reserve space for dynamic content */
.ad-slot {
min-height: 250px;
contain: layout;
}
/* Cookie banner - anchor to bottom, no layout shift */
.cookie-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
}
Quick wins checklist
- Run Lighthouse in incognito - extensions add TBT from their own scripts
- Test on throttled connection (Lighthouse Fast 4G) - your dev machine is not representative
- Preconnect to critical third-party origins:
<link rel="preconnect" href="https://fonts.googleapis.com" /> - Add
rel="preload"for your LCP image if it's a CSSbackground-image - Enable Brotli compression - Vercel enables it by default; self-hosted apps need explicit config
- Check for unused CSS - Tailwind v4 with JIT purges unused styles automatically, but custom CSS may not
Originally published at thekitbase.app
Top comments (0)