Originally published at safdarali.in
I'm Safdar Ali. In early 2024 I was the frontend lead on a client marketing site at Adsclique Media, a US digital agency. The stack was Next.js, but the site behaved like a 2019 SPA: heavy client bundles, hero images served as full-resolution PNGs, and fonts pulled in through @import in global CSS. Lighthouse on production hovered around the mid-50s. Nobody cared until the client opened the site on hotel Wi-Fi before a board review and asked, bluntly, why the homepage took five seconds to become usable.
That message landed in our Slack with a screen recording attached. My PM didn't want a roadmap — they wanted numbers by Friday. I had three days to move Core Web Vitals without rewriting the product. This post is the exact sequence I used: what was slow, what I changed in the repo, and the before/after metrics. No generic "use a CDN" advice. Just the diffs that mattered.
The Problem — What Was Actually Slow
I started with a reproducible baseline, not vibes. I ran Lighthouse in an incognito Chrome window against the production URL (throttled 4G, Moto G Power emulation) and saved the JSON. Then I opened DevTools → Performance, recorded a cold load with cache disabled, and exported the trace. The numbers were consistent across three runs:
- Largest Contentful Paint (LCP): 4.2s — the hero image and a client-side chart both competed for "largest" depending on run.
- Cumulative Layout Shift (CLS): 0.18 — web fonts swapping after paint and images without dimensions.
- Time to Interactive (TTI): 6.1s — too much JavaScript hydrating before the page felt ready.
The waterfall told the story. A 180KB vendor chunk downloaded before first paint because the root layout was a client component tree. Two Google Font families loaded via CSS @import, each blocking render for ~400ms. The hero was a 2.1MB PNG with no width / height, so the layout jumped when it arrived. API calls for case-study cards fired in useEffect, meaning users saw skeletons, then content, then more layout shift. Repeat visits were almost as slow as first visits because static assets had weak cache headers. The site wasn't "broken" — it was accidentally optimized for developer convenience, not user latency.
The Stack Before My Changes
The project shipped on Next.js 13 Pages Router. Almost every page used getServerSideProps or client fetching, but the UI layer was overwhelmingly client components: marketing sections, carousels, modals, analytics wrappers. _app.tsx imported Framer Motion, a chart library, and a toast system globally — so every route paid for features only the homepage needed.
Images were plain <img> tags pointing at S3 URLs. No WebP, no responsive srcSet, no lazy loading below the fold. Typography used Inter and a display face via @import url(...) inside globals.css. There was no intentional code splitting beyond Next's defaults — and because so many entry components were marked "use client", the default boundaries didn't help much.
What I Changed — The Exact Steps
Step 1: Migrated from Pages Router to App Router
I didn't rewrite every screen at once. I created app/ alongside pages/, moved the marketing homepage and shared layout first, and left admin routes on Pages until the end. The win was React Server Components (RSC): read-only sections render on the server and ship zero bytes of component JavaScript to the browser.
// app/page.tsx — server component (default)
export default async function HomePage() {
const stats = await getPublicStats(); // runs on server only
return (
<>
<Hero headline="..." />
<StatsRow data={stats} />
</>
);
}
// components/NewsletterForm.tsx
"use client";
export function NewsletterForm() {
// only this island hydrates
}
After the split, the main route's client bundle dropped by roughly 35% (340KB → 220KB parsed on the wire).
Step 2: Replaced all img tags with next/image
Every marketing image went through next/image. I set explicit width / height so the browser reserved space before decode. Below-the-fold images used default lazy loading; the hero got priority.
// Before
<img src="https://cdn.example.com/hero.png" alt="Campaign hero" />
// After
import Image from "next/image";
<Image
src="/hero.webp"
alt="Campaign hero"
width={1200}
height={630}
priority
sizes="(max-width: 768px) 100vw, 1200px"
/>
Next served WebP/AVIF variants automatically. LCP fell from 4.2s to 2.1s on the first deploy — the single biggest visual win.
Step 3: Used next/font instead of @import
I removed font @import from CSS and loaded Inter through next/font/google in the root layout. Fonts self-host at build time, use display: swap, and apply via a CSS variable.
// app/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.variable}>
<body className={inter.className}>{children}</body>
</html>
);
}
CLS dropped from 0.18 to 0.04. In hindsight this was the fastest fix per line of code — I should have done it on day one.
Step 4: Converted data-fetching to Server Components
Case studies and testimonial lists previously fetched in client effects. I moved them to async server components with fetch() in the RSC layer.
// app/work/page.tsx
async function CaseStudyGrid() {
const res = await fetch("https://api.example.com/case-studies", {
next: { revalidate: 3600 },
});
const items = await res.json();
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
}
Users received HTML with real content on first paint — no spinner parade.
Step 5: Added proper caching headers via next.config.js
// next.config.mjs
async headers() {
return [
{
source: "/:all*(svg|jpg|jpeg|png|webp|avif|woff2)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
];
}
After Vercel's CDN picked this up, repeat views felt near-instant.
The Results — Before vs After
| Metric | Before | After | Change |
|---|---|---|---|
| LCP | 4.2s | 1.7s | −60% |
| CLS | 0.18 | 0.04 | −78% |
| TTI | 6.1s | 2.4s | −61% |
| Lighthouse Performance | 54 | 91 | +68% |
| Bundle size (main route) | 340kb | 187kb | −45% |
The client cared about LCP and the green Lighthouse badge. We kept monitoring in Vercel Analytics for two weeks — p75 LCP stayed under 2.5s on 4G.
What I'd Do Differently
I would ship next/font first — it took under an hour and fixed CLS immediately. I spent day one on App Router folder structure, which was necessary but invisible to the client until day three.
Today I'd use Turbopack in Next.js 15 for local dev. For diagnosing the waterfall, I leaned on Chrome DevTools plus Cursor and Claude to spot render-blocking chains I'd missed. AI didn't write the config for me — it helped me ask better questions about the trace.
TL;DR — The 5-Minute Checklist
Ranked by impact ÷ effort:
- Swap @import fonts for next/font — ~1 hour, huge CLS win
- Replace hero images with next/image + priority — often halves LCP
- Push read-only sections to Server Components — cut client JS
-
Move data fetching to the server with
fetch(..., { next: { revalidate } }) -
Add long-cache headers for static assets in
next.config
Closing
Performance work is measurable persuasion. I teach this workflow on my YouTube channel (70+ tutorials on React, Next.js, and shipping real UI).
- 🌐 Portfolio: safdarali.in
- 📖 Background: safdarali.in/about
- 🛠 More builds: safdarali.in/projects
- 📩 Contact: safdarali.in/contact
If your Next.js app is still serving 4-second LCP on marketing pages, reach out — happy to skim a Lighthouse export and tell you which of these five levers fits your repo first.
Talk is cheap. Show me the code. — Linus Torvalds
Top comments (0)