May 2026 · Case study · ~9 min read
How I Cut Load Time by 60% Using Next.js App Router
By Safdar Ali — frontend engineer, Bengaluru
I'm Safdar Ali. In early 2024 I was the frontend lead on a client marketing site for 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. That was the starting point: familiar, shippable, and too slow for a performance-sensitive client.
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 (hero copy, footer, case-study list shell) 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). The remaining client JS was forms, carousel gestures, and analytics — things that actually need the DOM.
Step 2: Replaced all img tags with next/image
Every marketing image went through next/image. I set explicit width / height (or fill with a sized parent) 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. CLS from images also improved because layout boxes were stable.
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 — no extra round trip to Google on the critical path.
// 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, using Next's caching semantics where data could be semi-static.
// 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. Hydration cost shrank because fewer components needed client state. TTI improved without me touching the chart library yet.
Step 5: Added proper caching headers via next.config.js
Static assets under /public and optimized images needed long TTLs at the CDN edge. I added explicit headers in Next config so repeat visits didn't revalidate every asset on each navigation.
// 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 — especially for logo marks, icons, and compressed hero assets. First visit still depends on LCP work above; repeat visits are where caching shines.
The Results — Before vs After
I re-ran the same Lighthouse profile after each deploy, then once more on Friday morning before the client call. Median of three runs:
| 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. That was enough to close the ticket.
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. I'd also start with a bundle analyzer (@next/bundle-analyzer) before touching images, so I could defend priorities with a chart instead of intuition.
Today I'd use Turbopack in Next.js 15 for local dev — our team lost hours to slow HMR on a large client tree. For diagnosing the waterfall, I leaned on Chrome DevTools plus Cursor and Claude to spot render-blocking chains I'd missed (fonts blocking CSS, CSS blocking React). AI didn't write the config for me — it helped me ask better questions about the trace.
If the project were greenfield now, I'd default to App Router and RSC from commit one. Migrating under deadline stress is doable, but not where you want to spend crisis hours.
TL;DR — The 5-Minute Checklist
Ranked by impact ÷ effort — apply in this order on a slow Next.js marketing site:
- Swap @import fonts for next/font — ~1 hour, huge CLS win.
- Replace hero and above-the-fold images with next/image + priority — often halves LCP.
- Push read-only sections to Server Components — cut client JS and kill loading skeletons.
- Move data fetching to the server with
fetch(..., { next: { revalidate } }). - Add long-cache headers for static assets in
next.config— repeat visits feel instant.
App Router migration is higher effort; do it when you can split layouts incrementally, not the night before a demo.
Closing
Performance work is measurable persuasion. The client didn't need to understand RSC — they needed the site to load before they finished their coffee. I teach this workflow on my YouTube channel (70+ tutorials on React, Next.js, and shipping real UI). Background on safdarali.in/about; more builds on safdarali.in/projects.
If your Next.js app is still serving 4-second LCP on marketing pages, reach out via safdarali.in/contact — I'm happy to skim a Lighthouse export and tell you which of these five levers fits your repo first.