How to Optimize Next.js for 100/100 Google Lighthouse Scores
Stop letting bad performance kill your conversions. A deep dive into optimizing Next.js, images, fonts, and scripts to hit that green 100/100 Lighthouse score.
You check your dashboard. Traffic is coming in, but bounce rates are high. You open PageSpeed Insights, drop in your URL, and wait. The wheel spins.
Then you see it. A big, ugly red 48.
Panic sets in. You thought moving from legacy stacks to Next.js was the magic bullet. You thought React was fast by default. It is, but it's also incredibly easy to make slow. I’ve been there. Back when I was churning out WordPress sites, I relied on caching plugins to mask bad code. Now, as I dig deeper into Cloud Engineering and build my own SaaS products, I realize performance isn’t just about plugins. It’s about architecture.
Getting a 100/100 Google Lighthouse score isn't just vanity. It's money. It's user retention. And quite frankly, it’s a flex that proves you know your tools.
This isn't a generic "make images smaller" listicle. We are going to tear apart a Next.js application and rebuild it for speed. I’m talking about specific techniques I use when building with the T3 stack or Next/Supabase combos.
Here is how you actually optimize Next.js.
The Core Web Vitals Reality Check
Google cares about three things mainly.
- LCP (Largest Contentful Paint): How fast does the main stuff load?
- INP (Interaction to Next Paint): Does the site freeze when I click a button? (This replaced FID).
- CLS (Cumulative Layout Shift): Does stuff jump around while I'm reading?
If you fail these, your SEO tanks. I don't care how good your keyword research is. If your LCP is 4 seconds, you are invisible.
1. The Image Monster (And How to Tame It)
The next/image component is powerful. But most people use it wrong. They treat it like a standard HTML <img> tag and wonder why their LCP is trash.
Use Priority Loading
Your LCP element is usually the hero image. By default, Next.js lazy loads images. This is great for the footer, but terrible for the header. If you lazy load your hero image, the browser waits until the JS executes to fetch it.
Force the browser to grab that image immediately.
import Image from 'next/image'; import heroPic from '../public/hero.png'; export default function Hero() { return ( <div className="relative h-96 w-full"> <Image src={heroPic} alt="SaaS Dashboard Interface" fill priority={true} // This is the secret sauce className="object-cover" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" /> </div> ); }
The sizes Prop Matters
See that sizes prop above? Do not ignore it.
If you don't define sizes, Next.js doesn't know how wide the image will be on the screen. It might serve a 2000px wide image to a mobile phone. That is massive overkill.
The sizes prop tells the browser: "Hey, on mobile this takes up the full width, but on desktop, it's only half the screen." The browser then downloads the appropriately sized variant generated by the Vercel (or custom) image optimization API.
Formats
Next.js automatically serves WebP. But you should enable AVIF if you can. It compresses better. Update your next.config.js:
/** @type {import('next').NextConfig} */ const nextConfig = { images: { formats: ['image/avif', 'image/webp'], }, }; module.exports = nextConfig;
This small change can drop image payload sizes by 20-30%.
2. Font Optimization: Stop the Shift
I see this all the time. A page loads, the text appears in Times New Roman, and then flicker, it snaps into Inter or Roboto.
That creates a Layout Shift (CLS).
Since Next.js 13, next/font has been a game changer. It automatically optimizes your fonts (including Google Fonts) and removes external network requests for improved privacy and performance. It downloads the font file at build time and hosts it with your other static assets.
Here is how to set it up correctly with Tailwind CSS:
// app/layout.tsx import { Inter } from 'next/font/google'; import './globals.css'; const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter', }); export default function RootLayout({ children }) { return ( <html lang="en" className={inter.variable}> <body>{children}</body> </html> ); }
Then in your tailwind.config.js, you map it:
theme: { extend: { fontFamily: { sans: ['var(--font-inter)'], }, }, }
Using display: 'swap' is critical. It ensures text remains visible (using a fallback) while the web font loads, preventing the "Flash of Invisible Text" (FOIT). But next/font goes a step further by using size-adjust to make the fallback font take up the exact same space as the loaded font. Zero layout shift. It is beautiful.
3. Script Loading Strategies
Marketing teams love scripts. Google Analytics, Facebook Pixel, Chat widgets, Hotjar.
Engineers hate them.
These third-party scripts are the number one reason for high Total Blocking Time (TBT). They clog up the main thread. If you drop a standard <script> tag in your <head>, parsing pauses until that script is downloaded and executed.
Next.js gives us next/script to control this. You have three main strategies:
beforeInteractive: Use for critical bots detection or cookie consent managers. Loads before hydration.afterInteractive: The default. Good for analytics.lazyOnload: The heavy lifters. Chat widgets, feedback forms, social media embeds.
Here is a real example from a project I'm working on:
import Script from 'next/script'; export default function Layout() { return ( <> <Script src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID" strategy="afterInteractive" /> <Script id="google-analytics" strategy="afterInteractive"> {` window.dataLayer = window.dataLayer || []; function gtag(){window.dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'GA_MEASUREMENT_ID'); `} </Script> {/* Load the heavy chat widget only when idle */} <Script src="https://some-heavy-chat-widget.com/main.js" strategy="lazyOnload" /> </> ); }
If you really want to go hardcore, look into Partytown. It offloads third-party scripts to a Web Worker, freeing up the main thread entirely. Next.js has experimental support for this. It’s complex to set up, but for heavy marketing sites, it’s nuclear-grade optimization.
4. The Bundle: Tree Shaking and Dynamic Imports
You are probably shipping code the user doesn't need.
If you have a massive dashboard with heavy charts (like Recharts or Chart.js) or a rich text editor, do not load that JavaScript on the initial page load. The user hasn't scrolled there yet. They might not even log in.
Use next/dynamic to lazy load components.
import dynamic from 'next/dynamic'; // This component won't be included in the initial JS bundle const HeavyChart = dynamic(() => import('../components/HeavyChart'), { loading: () => <p>Loading Chart...</p>, ssr: false, // Disable SSR if the component relies on window/document }); export default function Dashboard() { return ( <div> <h1>My Stats</h1> <HeavyChart /> </div> ); }
This splits your JS bundle. The browser only downloads the chunk for HeavyChart when the component is actually rendered.
Analyze Your Bundle
How do you know what to optimize? You need data. Install @next/bundle-analyzer.
npm install @next/bundle-analyzer cross-env
Configure it in your next.config.js, run your build, and look at the visual map. You will often find massive libraries like lodash or moment.js that you didn't realize were bloating your app. Swap moment for date-fns or dayjs. Import specific lodash functions (import debounce from 'lodash/debounce') instead of the whole library.
5. Rendering Strategies: Static vs. Dynamic
Coming from my Cloud Computing studies, I think a lot about where compute happens.
Server-Side Rendering (SSR) - getServerSideProps in the old Pages router, or just fetching data with no-store in the App router - is expensive. The server has to build the HTML for every single request. If your database (Supabase, Postgres) is slow, your TTFB (Time to First Byte) suffers.
If the data doesn't change every second, don't use SSR.
Static Site Generation (SSG) is the fastest. HTML is built at build time. It is served instantly via CDN.
Incremental Static Regeneration (ISR) is the sweet spot. It serves static HTML but rebuilds it in the background after a set interval.
In the App Router, you control this via the revalidate segment config:
// app/blog/[slug]/page.tsx export const revalidate = 3600; // Revalidate at most every hour export default async function BlogPost({ params }) { const post = await getPost(params.slug); return <article>{post.content}</article>; }
This keeps your Lighthouse performance high because the server responds instantly with cached HTML, while keeping content relatively fresh.
6. The Backend Bottleneck
You can have the most optimized React code in the world, but if your database query takes 2 seconds, your site is slow.
I use Supabase heavily. It’s fantastic, but you have to be smart.
- Select only what you need. Don't do
select *. - Indexes. If you are filtering by
user_id, ensure that column is indexed in Postgres. - Region. If your Vercel function is in
us-east-1(Virginia) and your Supabase database is ineu-central-1(Frankfurt), you are fighting the speed of light. Every database call has a transatlantic round trip. Keep your compute and data in the same region.
7. Reduce DOM Size with Shadcn & Tailwind
Lighthouse complains about "Excessive DOM size". This happens when you have HTML nested 30 layers deep.
Legacy CSS frameworks often required wrapper divs for layout. Tailwind allows you to style elements directly, reducing the need for wrappers.
However, be careful with Shadcn UI or other component libraries. They are great, but verify you aren't rendering invisible components. For example, if you have a mobile navigation drawer, ensure it is conditionally rendered or uses visibility: hidden properly so accessibility readers don't get stuck, but also so the browser doesn't have to paint a thousand invisible nodes.
The "Good Enough" Philosophy
Look, I chased the 100/100 score for this post. I got it on my personal site.
But in the real world, sometimes 95 is okay. If getting from 98 to 100 requires refactoring your entire architecture and delays your launch by two weeks, don't do it.
I’m an indie hacker now. Shipping is oxygen.
Optimize for the user, not just the bot. But usually, what's good for the bot is good for the user. Fast LCP means the user sees value sooner. Low CLS means the user doesn't get annoyed.
Start with images. Fix your fonts. Lazy load your heavy scripts. Check your bundle size. If you do just those four things, you will likely hit 90+.
Now go fix your sizes prop.
Read Next
Typography Matters: Why I Chose 'Seriously Nostalgic'
Why I ditched standard sans-serifs for a font with soul in my latest Next.js build. A deep dive into branding, psychology, and the technical implementation of custom fonts.
ReadCoding for SEO: How I Rank My Single-Page Applications
Moving from WordPress to Next.js tanked my SEO, so I fixed it. Here is a deep dive into SSR, Metadata APIs, pSEO, and JSON-LD to rank SPAs on Google.
Read