The Wake-Up Call Nobody Wants
The first time I ran a Lighthouse audit on my Next.js app, I genuinely thought something was broken. Eight seconds. Eight full seconds before the page was usable. I'd been building this thing for months, convinced that because I was using Next.js — a framework literally built for performance — I was getting performance for free. I wasn't. I was getting a fast car with the emergency brake on.
The thing is, Next.js gives you incredible tools out of the box, but it doesn't force you to use them correctly. It's like getting a professional chef's knife and then using it to open a bag of chips. The potential is there. The results aren't. After weeks of digging, auditing, and embarrassingly basic fixes, I got that 8-second LCP (Largest Contentful Paint — basically the time until the main content on your page loads) down to 0.9 seconds. Here's exactly what I did and why it worked.
The Culprit You're Probably Ignoring: Your Images
I'm going to say something that sounds obvious but apparently isn't: unoptimized images are the number one killer of Next.js app performance. In my case, I had a hero section with a 4MB PNG file sitting right at the top of the page. The browser had to fully download that image before it could finish rendering. It was like trying to run a sprint while dragging a suitcase.
Next.js ships with a built-in <Image> component that handles lazy loading (only loading images when they're about to enter the viewport), automatic format conversion to WebP (a much smaller file format than JPEG or PNG), and responsive sizing. Swapping my raw <img> tags for next/image cut my image payload by over 70%. But the component only helps if you configure it properly.
The mistake I kept making early on was not setting the priority prop on above-the-fold images — meaning images the user sees immediately when the page loads. Without it, Next.js lazily loads every image, which sounds smart but actually delays the most important image on the page. Here's the pattern that made the biggest difference:
import Image from 'next/image';
export default function HeroSection() {
return (
<section className="hero">
{/* priority tells Next.js to preload this image — don't skip this */}
<Image
src="/hero-banner.jpg"
alt="Hero banner"
width={1200}
height={600}
priority
quality={80}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
/>
</section>
);
}The placeholder="blur" part is worth calling out separately. It shows a blurry low-resolution version of the image while the real one loads, which doesn't technically make your page faster, but it feels faster. Perceived performance is real performance in terms of user experience.
Bundle Size: The Invisible Weight Your App Carries
Your JavaScript bundle is every single line of code your browser needs to download, parse, and execute before your app can work. Think of it like a backpack. The heavier it is, the slower you move. Most Next.js apps I've seen (including my own) are carrying bricks they don't need.
The first thing I did was install @next/bundle-analyzer to actually see what was in my bundle. This tool generates a visual map of every dependency and how much space it takes up. Running it was genuinely shocking. I had moment.js — a date formatting library — taking up 67KB of compressed code just to format a few timestamps. I swapped it for date-fns, which lets you import only the specific functions you need, and that single change shaved off nearly 60KB.
npm install @next/bundle-analyzer
# Add to next.config.js and run:
ANALYZE=true npm run build// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// your existing next config here
});Once you see the visual map, start looking for two things: large single libraries that could be replaced with smaller alternatives, and libraries that are being imported at the top level when they're only needed on one page. That second problem is where dynamic imports come in.
Dynamic Imports: Only Load What the User Actually Needs
Dynamic imports are one of those features that sounds complicated but is actually straightforward once you get it. Normally, when your app loads, it bundles everything together — every component, every library, every page. Dynamic imports let you say "only load this code when it's actually needed." It's like a restaurant that preps your meal when you order it instead of cooking every dish on the menu the moment you walk in.
I had a rich text editor component built with react-quill that was loaded on every page, even pages that had no editor at all. That editor was pulling in over 200KB of JavaScript. Moving it to a dynamic import meant that code only loaded when a user actually navigated to a page that needed it.
import dynamic from 'next/dynamic';
// This component is now only loaded when it renders on screen
const RichTextEditor = dynamic(() => import('../components/RichTextEditor'), {
loading: () => <p>Loading editor...</p>,
ssr: false, // Disable server-side rendering for this component
});
export default function BlogPostPage() {
return (
<div>
<h1>Write Your Post</h1>
<RichTextEditor />
</div>
);
}The ssr: false option tells Next.js not to render this component on the server at all. For components that depend heavily on browser APIs — things that need window or document, which don't exist on the server — this prevents crashes and also means you're not sending that component's code in the initial HTML response. The result in my app was a 40% reduction in the JavaScript loaded on most pages.
Fonts Were Quietly Destroying My Performance
This one genuinely surprised me. I had Google Fonts loaded via a <link> tag in my _document.js file, which is the old-school way of doing it. What I didn't realize is that this creates what's called a "render-blocking request" — the browser stops everything and waits for that font file to download before it finishes painting the page. It's a full stop.
Next.js 13 and above introduced next/font, which is a game changer. It downloads fonts at build time (when you're compiling your app, not when a user visits), eliminates the external network request entirely, and automatically applies font-display: swap — which means the browser shows text in a fallback font immediately and then swaps in your custom font when it's ready. The user sees text instantly instead of waiting.
I've found that most developers either don't know about next/font or know about it but haven't bothered switching. It took me about 20 minutes to migrate and knocked 400ms off my Time to First Byte in some cases.
// app/layout.js (or pages/_app.js for the pages router)
import { Inter, Playfair_Display } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
const playfair = Playfair_Display({
subsets: ['latin'],
display: 'swap',
variable: '--font-playfair',
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${playfair.variable}`}>
<body>{children}</body>
</html>
);
}Server Components vs. Client Components: Stop Defaulting to Client
With the App Router (Next.js 13+), everything is a Server Component by default unless you add 'use client' at the top of the file. A Server Component runs on the server and sends plain HTML to the browser. No JavaScript for that component gets shipped to the client. A Client Component is the traditional React model — it ships JavaScript and runs in the browser.
In my experience, the single biggest architectural mistake in Next.js apps is marking components as client components when they don't need to be. Every time you add 'use client', you're adding JavaScript to the bundle. Every unnecessary JavaScript addition slows down your page. I went through my entire component tree and asked one question about each component: does this need interactivity or browser-specific APIs? If the answer was no, I removed 'use client'.
A navigation bar that shows links? Server Component. A dropdown menu that opens and closes? Client Component. A product listing that fetches data? Server Component. A filter that responds to user clicks? Client Component. It's that distinction that matters. I ended up removing 'use client' from about 60% of the components that had it, and the difference in client-side JavaScript was dramatic.
Third-Party Scripts: The Performance Vampires
Analytics tools, chat widgets, A/B testing libraries, ad scripts — these are all third-party scripts, and most developers just paste the provided snippet into their <head> tag without thinking about it. Each one is a separate network request, often to a slow external server, and many of them are render-blocking.
Next.js has a <Script> component in next/script that gives you control over when third-party scripts load. The strategy prop is everything here. Setting strategy="lazyOnload" tells Next.js to load that script only after the entire page has finished loading. Setting strategy="afterInteractive" loads it after the page becomes interactive. Neither of these strategies delays your critical rendering path.
I was loading five third-party scripts with the default behavior (which is essentially the worst behavior). Switching them all to either lazyOnload or afterInteractive was a five-minute change that removed several hundred milliseconds from my initial load time. The analytics still worked. The chat widget still appeared. The users couldn't tell the difference. My Lighthouse score absolutely could.
Caching and Data Fetching: Stop Asking for the Same Thing Twice
One of the more subtle performance issues I hit was redundant data fetching. I had multiple components on the same page each making their own API calls to fetch the same user data. It's like four coworkers independently emailing the same person to ask the same question instead of just sharing the answer.
Next.js's fetch API has built-in deduplication in the App Router — if you make the same fetch request in multiple Server Components during a single render, it only hits the network once. But you need to understand when to use cache: 'force-cache' (cache this forever until you manually revalidate) versus next: { revalidate: 60 } (refetch this data every 60 seconds). For mostly static data like product categories or navigation items, aggressive caching is your friend.
The shift in mindset that helped me most was thinking about how often does this data actually change? Blog posts? Cache for an hour. User profile? Don't cache at all — it needs to be fresh. Pricing data? Cache for 10 minutes. Once I started matching my caching strategy to my actual data requirements instead of just fetching everything fresh every time, my database hit count dropped by over half and page loads became consistently fast, not just occasionally fast.
Putting It All Together: The Audit Loop
The thing about performance optimization is that it isn't a one-time project. It's a habit. Every new dependency you add is potential weight. Every new component could be the wrong type. Every new image could be unoptimized. The developers who maintain fast apps aren't necessarily more skilled — they just check more often.
The loop I've settled into is simple: run Lighthouse after every significant feature addition, check the bundle analyzer whenever you add a new library, and always ask whether a new component needs to be a Client Component or can stay as a Server Component. Those three habits catch about 90% of the performance regressions before they ship.
Going from 8 seconds to 0.9 seconds wasn't one brilliant insight. It was eight or nine individually small fixes that compounded. Images, fonts, bundle size, dynamic imports, caching, third-party scripts — none of those changes alone would have gotten me there. Together, they completely transformed the experience. The app feels different now. Snappy in a way that cheap tricks can't fake. And that's the part nobody warns you about: doing it right isn't harder than doing it wrong. It's just more intentional.