A client forwarded me a PageSpeed Insights screenshot of the site I'd built for him, with a one-word message: "Why?"
Mobile score: 52. Orange and red circles everywhere. And here's the embarrassing part — this was a Next.js site. The framework whose whole pitch is performance. The framework I chose because of performance. It turns out Next.js gives you excellent tools and absolutely will not stop you from ignoring all of them, which is exactly what I had done.
Two evenings of work later the score was 94. Here is every change, in order of impact, with the ones that turned out to be a waste of time at the end.
Step zero: measure before touching anything
I opened Lighthouse in an incognito window (extensions skew results — my password manager alone was costing points) and PageSpeed Insights for real-world field data. The report told me exactly where the pain was: Largest Contentful Paint at 4.8 seconds, a pile of unused JavaScript, and layout shift from fonts.
The single most useful thing Lighthouse tells you is which element is your LCP. Mine was the hero image. That focused everything that followed — no guessing.
Fix 1: Images, done properly this time (biggest win)
I was using next/image, technically. But the hero image was a 2400px-wide photo being displayed at 800px, without the priority prop, so the browser discovered it late and downloaded far too much of it.
Three changes:
<Image
src={hero}
alt="Dashboard preview"
priority // preloads the LCP image
sizes="(max-width: 768px) 100vw, 800px"
className="rounded-xl"
/>
The priority prop tells Next.js this image matters now. The sizes prop tells the browser which resolution to actually download instead of defaulting to something huge. And I re-exported the source image at a sane resolution because no sizes attribute fixes a 4MB original.
LCP went from 4.8s to 2.1s. One fix, half the problem gone.
Fix 2: Fonts with next/font (killed the layout shift)
The site loaded Google Fonts the old way — a <link> tag in the head. That's a render-blocking request to another domain, plus the classic flash of shifting text when the font arrives.
next/font self-hosts the font files and injects proper fallbacks:
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
Cumulative Layout Shift dropped from 0.21 to effectively zero, and one external request disappeared entirely. Ten minutes of work. I now do this in the first hour of every project.
Fix 3: The "use client" audit (the Next.js-specific win)
This was the eye-opener. I grepped for "use client" and found it at the top of components that had no interactivity at all — including, somehow, the footer. Early in the App Router era I'd sprinkled it around whenever an error mentioned hooks, and every one of those components was shipping its JavaScript to the browser for no reason.
The rule I follow now: push "use client" to the leaves. A page stays a Server Component; the one interactive button inside it becomes a tiny client component. After the audit, first-load JavaScript dropped from roughly 380KB to 210KB. The framework was right all along; I had just been overriding it.
Fix 4: Dynamic imports for below-the-fold weight
The pricing page had a chart. The chart library was loading on every page because it sat in a shared component. Two lines fixed it:
const RevenueChart = dynamic(() => import('./RevenueChart'), {
loading: () => <ChartSkeleton />,
});
Same treatment for a testimonial carousel and an embedded map. None of these are visible in the first viewport, so there's no reason they should compete with the hero for bandwidth.
Fix 5: What the bundle analyzer confessed
@next/bundle-analyzer draws you a map of your JavaScript, and mine had two shameful continents: a full date library imported for exactly one "3 days ago" label, and an icon package imported in a way that dragged in far more than the six icons I used.
The date library got replaced with Intl.RelativeTimeFormat — built into the browser, zero KB. The icons got proper named imports. Every project I've analyzed since has had at least one dependency like this: huge, barely used, and invisible until you look.
Fix 6: Third-party scripts on a leash
Analytics and a chat widget were loading eagerly in the head. next/script with strategy="lazyOnload" pushes them to after everything meaningful:
<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />
Nobody needs the chat bubble in the first 500ms. Nobody has ever needed the chat bubble in the first 500ms.
What didn't move the needle
Honesty corner. I spent an evening wrapping components in memo and useMemo — no measurable difference; that's for re-render problems, not load time. Obsessive preconnect tags for domains that loaded lazily anyway: nothing. Micro-optimizing Tailwind class counts: please, no. The lesson that stuck: optimize what the measurement points at, not what feels clever.
The mistake worth confessing
At one point I lazy-loaded everything, including the hero image. The score went down. Lazy-loading your LCP element is like putting your front door behind a queue — the whole point is that it arrives first. priority on the LCP, lazy on the rest. That distinction is half of image performance.
Where it landed
Mobile 52 → 94, desktop 98. But the number I actually care about came from analytics a month later: bounce rate on mobile dropped noticeably, and the client stopped sending screenshots. The site didn't get new features or a redesign. It just stopped making people wait — which, on the modern web, is a feature.
Top comments (0)