All articles
Performance

Modern Next.js optimization techniques you might be missing

Deep dive into App Router caching strategies, edge edge-runtime patterns, and reducing First Load JS to achieve a perfect 100 Lighthouse score.

12 min read

The 100 Lighthouse Score Chase

Next.js gives you incredible performance out of the box. The framework handles code splitting, image optimization, and font preloading automatically. However, as applications grow, large dependency chains, third-party scripts, and aggressive data fetching can easily drag your Lighthouse score down to the 60s.

When I started profiling some enterprise Next.js App Router applications, I noticed a few recurring anti-patterns. Let's look at the modern optimization techniques that move the needle from "good enough" to "blazing fast".

1. Mastering the Data Cache & Route Cache

The App Router introduced a fundamentally different caching paradigm compared to the Pages router. Understanding how to selectively invalidate cache is the key to both performance and data freshness.

Using fetch natively in Next.js automatically dedupes requests and caches them on the server by default.

// The old way (getServerSideProps equivalent - slow, blocks render)
export const dynamic = 'force-dynamic';

export default async function Page() {
  const data = await fetch('https://api.example.com/data');
  return <Layout>{/* ... */}</Layout>
}

// The optimized way (Time-based revalidation)
export default async function Page() {
  // Fetches once, serves cached HTML globally, regenerates every 60s in background
  const res = await fetch('https://api.example.com/data', {
    next: { revalidate: 60 }
  });
  const data = await res.json();
  return <Layout>{/* ... */}</Layout>
}

The true power comes from On-Demand Revalidation. Instead of arbitrary timers, you generate your pages statically and only rebuild them when data actually changes via a webhook calling revalidateTag().

// On your page component:
const res = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] }
});

// On your API webhook (e.g., when CMS is updated):
import { revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  // Validate webhook secret...
  revalidateTag('products')
  return Response.json({ revalidated: true, now: Date.now() })
}

This gives you static-site speed with dynamic-site freshness.

2. Dynamic Imports for Heavy Components

The most common cause of high First Load JS is importing heavy client components at the top levels of your React tree. Even if a user hasn't clicked the "Show Chart" button, the chart library's 200KB bundle is downloaded in the initial payload.

You can fix this easily with next/dynamic:

import dynamic from 'next/dynamic'
import { useState } from 'react'

// This chunk is separated from the main bundle and only loaded when rendered
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
  loading: () => <p className="animate-pulse">Loading visualization...</p>,
  ssr: false // Optional: disable SSR if it requires browser APIs
})

export default function Dashboard() {
  const [showMetrics, setShowMetrics] = useState(false)

  return (
    <div>
      <button onClick={() => setShowMetrics(true)}>
        View Advanced Metrics
      </button>
      
      {/* HeavyChart JS is ONLY downloaded over the network when this evaluates to true */}
      {showMetrics && <HeavyChart />}
    </div>
  )
}

3. Controlling Third-Party Scripts

Analytics, chat widgets, and ad units are notorious performance killers. A standard Intercom or Hubspot widget will immediately flag a "Reduce main-thread work" penalty in Lighthouse.

Next.js provides next/script equipped with loading strategies. Never put a third party script in a standard <script> tag.

import Script from 'next/script'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        
        {/* 'worker' strategy offloads the script to a web worker using Partytown */}
        {/* 'lazyOnload' waits until the browser is completely done with everything else */}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"
          strategy="worker" 
        />
        <Script id="google-analytics" strategy="worker">
          {`
            // Config code
          `}
        </Script>
      </body>
    </html>
  )
}

Note: Using the worker strategy requires installing the @next/third-parties package to enable Partytown.

4. Nuanced Server Components vs. Client Components

The biggest mistake developers make transitioning to the App Router is marking layout files or high-level wrappers with "use client".

If you write "use client" at the top of a file, you form a client boundary. Every single component imported inside that file inherently becomes part of the client bundle unless it is passed as a children prop.

// Anti-pattern ❌
"use client"
import Navbar from './Navbar'
import StaticHeader from './StaticHeader' // Oops, this static header is now shipping JS!

export default function Layout({ children }) {
  return (
    <body>
      <Navbar />
      <StaticHeader />
      {children}
    </body>
  )
}

The Fix: Push interactivity to the leaves of your component tree. Your layouts should be Server Components. <Navbar> should be a Server Component that renders a tiny <HamburgerMenu "use client"> component only where event listeners are needed.

The Finish Line

Performance isn't a one-time feature; it's a culture of continuous measurement.

Whenever you add a new page or component, run npm run build locally. Next.js prints out the route sizes. If a route suddenly spikes from 75KB to 180KB, you know exactly which commit is to blame. Combine these strategies with standard Vercel Edge caching, and you'll find those elusive perfect Lighthouse scores.