Launch pricing - early-adopter rates, prices increase June 9th. Shop now →

Development10 min readby Minnie

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

The techniques that actually move Lighthouse scores from 70 to 100 in Next.js apps - image optimization, font loading, bundle analysis, caching, and the mistakes that cost you 20 points without realising.

A 100 Lighthouse score is achievable in Next.js with App Router - but 'use Next.js' is not enough. The framework eliminates some performance problems by default (code splitting, static generation), but a handful of common mistakes will keep your score stuck in the 70s regardless of how fast your server is. Here are the techniques that actually make the difference, in order of impact.

Understanding what Lighthouse actually measures

Lighthouse Performance score is a weighted average of five Core Web Vitals metrics. Knowing the weights tells you where to focus first:

MetricWeightWhat it measuresTarget
Largest Contentful Paint (LCP)25%When the main content loads< 2.5s
Total Blocking Time (TBT)30%Time main thread is blocked (proxy for FID)< 200ms
Cumulative Layout Shift (CLS)15%Visual stability - elements moving after load< 0.1
First Contentful Paint (FCP)10%First pixel rendered< 1.8s
Speed Index10%How quickly content is visually complete< 3.4s
Time to Interactive (TTI)10%When page responds to user input< 3.8s

TBT (30% weight) is the most impactful metric to optimise and the most commonly ignored. It measures how long the main thread is blocked by JavaScript execution. Third-party scripts, large client bundles, and unoptimised component renders all drive TBT up.

1. Images: next/image is not optional

A single unoptimised image can cost you 10-15 Lighthouse points through LCP delay and layout shift. next/image solves multiple problems simultaneously - it serves modern formats (WebP, AVIF), lazy loads below-the-fold images, reserves space to prevent layout shift, and generates responsive srcsets automatically.

// ❌ Causes layout shift + no format optimisation
<img src="/hero.jpg" alt="Hero image" />

// ✅ Prevents layout shift, serves WebP, lazy loads by default
import Image from "next/image";

<Image
  src="/hero.jpg"
  alt="Hero image"
  width={1200}
  height={630}
  priority         // only for above-the-fold images (hero, header logo)
  sizes="(max-width: 768px) 100vw, 1200px"
/>

// Critical: only use priority on the hero image
// Using it everywhere defeats its purpose and hurts performance
  • Set priority on your LCP image only (hero image, above-fold product photo). Using priority on multiple images defeats the optimization.
  • Always provide width and height (or use fill with a sized container) - missing dimensions cause CLS
  • Use sizes prop for responsive images - it generates the correct srcset for different viewport widths
  • SVGs in next/image have no benefit - use <img> or inline SVG for icons and illustrations
  • Keep hero images under 200KB as a WebP - next/image handles conversion but you still need a quality source

2. Fonts: next/font eliminates layout shift and FOUT

Loading Google Fonts with a <link> tag in 2026 costs you on two fronts: an external network request that blocks rendering, and a flash of unstyled text (FOUT) while the font loads. next/font downloads the font at build time, self-hosts it, and injects the correct font-display CSS to prevent both problems.

// ❌ 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",    // show fallback immediately, swap when ready
  variable: "--font-inter",
});

const playfair = Playfair_Display({
  subsets: ["latin"],
  weight: ["400", "700"],
  style: ["normal", "italic"],
  variable: "--font-playfair",
});

// layout.tsx
export default function RootLayout({ children }) {
  return (
    <html lang="en" className={`${inter.variable} ${playfair.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  );
}

3. Bundle analysis - find the weight before you ship it

JavaScript bundle size is the leading cause of high TBT scores. The problem is invisible until you measure it - a single import of an unoptimised library can add 200KB to your bundle without any obvious symptom during development.

# Install the bundle analyser
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 analysis
ANALYZE=true npm run build
# Opens a treemap in your browser showing every module in every chunk

What to look for in the treemap: any single module over 50KB in a client bundle is worth investigating. Common offenders: moment.js (use date-fns or Intl instead), lodash (use individual imports or native methods), chart libraries loaded on pages that don't have charts.

// ❌ Imports the entire lodash library (~70KB)
import _ from "lodash";
const sorted = _.sortBy(items, "name");

// ✅ Import only what you use
import sortBy from "lodash/sortBy";

// ✅ Or use native methods (no import at all)
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));

// ❌ Moment.js (~250KB including locales)
import moment from "moment";
const formatted = moment(date).format("MMM D, YYYY");

// ✅ Native Intl API (zero bundle cost)
const formatted = new Intl.DateTimeFormat("en-US", {
  month: "short", day: "numeric", year: "numeric"
}).format(date);

4. Static generation over dynamic rendering wherever possible

Every page that can be pre-rendered statically should be. Static pages are served from a CDN with no server processing time - the difference between a 50ms TTFB and a 500ms TTFB. In App Router, pages are static by default 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, not per request
  return <PostList posts={posts} />;
}

// Forces dynamic rendering - runs on every request:
// - Using cookies() or headers() from next/headers
// - Using searchParams without generateStaticParams
// - Setting export const dynamic = "force-dynamic"

// Revalidate static pages on a schedule (ISR):
export const revalidate = 3600; // re-generate every hour

// Or on-demand via revalidatePath() / revalidateTag() in Server Actions

5. Lazy-load heavy client components

Components that use heavy client-side libraries (chart libraries, rich text editors, map components, video players) should not be included in the initial bundle if they appear below the fold or on interaction. next/dynamic defers their load until needed.

import dynamic from "next/dynamic";

// ✅ Chart component loads only when it enters the viewport
const RevenueChart = dynamic(
  () => import("@/components/revenue-chart"),
  {
    loading: () => <ChartSkeleton />,  // show skeleton while loading
    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,
});

export default function Dashboard() {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <RevenueChart />           {/* deferred but renders on page */}
      <button onClick={() => setOpen(true)}>Open</button>
      {open && <UserModal />}    {/* only loads when opened */}
    </div>
  );
}

6. Third-party scripts: use next/script with the right strategy

Third-party scripts (analytics, chat widgets, A/B testing) are the single biggest source of TBT regression in otherwise well-optimised Next.js apps. A single marketing script can add 500ms of main thread blocking. next/script controls when scripts load with three strategies:

import Script from "next/script";

// afterInteractive (default) - loads after page is interactive
// Use for: analytics, chat widgets - they don't need to block anything
<Script src="https://analytics.example.com/script.js" strategy="afterInteractive" />

// lazyOnload - loads during browser idle time
// Use for: social media embeds, non-critical widgets
<Script src="https://platform.twitter.com/widgets.js" strategy="lazyOnload" />

// beforeInteractive - loads before hydration (use sparingly)
// Use for: consent managers, critical polyfills only
<Script src="/consent.js" strategy="beforeInteractive" />

// For Google Analytics specifically:
<Script
  src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX"
  strategy="afterInteractive"
/>

7. Fix CLS: the hidden Lighthouse killer

Cumulative Layout Shift (CLS) measures visual stability - elements jumping around after the page loads. The most common sources in Next.js apps:

  • Images without width/height attributes - always provide dimensions or use fill with a sized container
  • Dark mode flash - elements move if the theme changes after first paint. Fix with a blocking inline script (not useEffect).
  • Dynamic content inserted above existing content - ads, cookie banners, toasts at the top of the page
  • Custom fonts that change text size when they load - use next/font with size-adjust to match fallback metrics
  • Skeleton loaders that are a different height than the real content - match skeleton dimensions to actual content
/* Reserve space for dynamic content to prevent CLS */
.ad-slot {
  min-height: 250px;        /* reserve the expected height */
  contain: layout;          /* isolate from rest of document */
}

/* Cookie banner - anchor to bottom so it doesn't push content */
.cookie-banner {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  /* fixed position doesn't cause layout shift */
}

8. The quick wins checklist

  • Run Lighthouse in incognito - extensions add TBT from their scripts
  • Test on a 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 background-image (next/image handles this automatically for <Image>)
  • Check for unused CSS - Tailwind v4 with JIT purges unused styles automatically, but custom CSS may not be
  • Defer non-critical CSS - load print stylesheets and theme alternates with media='print' and update media on load
  • Enable compression in your hosting config - Vercel enables Brotli by default; self-hosted apps need explicit gzip/Brotli config

TheKitBase templates ship with 98 Lighthouse out of the box - next/image on every image, next/font with zero FOUT, no third-party blocking scripts, static generation by default, and flash-free dark mode to eliminate CLS. The Lighthouse score is verified on PageSpeed Insights (not just local Lighthouse) before each template ships.

Next.js templates pre-optimized for Lighthouse 98 - SaaS dashboards, AI landing pages, portfolios, agency sites. Test on PageSpeed Insights before you buy.

Browse Templates