DEV Community

Cover image for Next.js Performance Best Practices: How to Hit 100 Lighthouse in 2026
TheKitBase
TheKitBase

Posted on • Originally published at thekitbase.app

Next.js Performance Best Practices: How to Hit 100 Lighthouse in 2026

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"
/>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

7. Fix CLS: the hidden Lighthouse killer

Common CLS sources in Next.js apps:

  • Images without dimensions - always provide width/height or use fill with 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: fixed at 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;
}
Enter fullscreen mode Exit fullscreen mode

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 CSS background-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)