The Real Problem
One of my Next.js projects scored 61/100 on PageSpeed mobile after launch. After two days of systematic optimization, it hit 98/100. This post documents exactly what I did.
Measurement Tools
Before optimizing, you need to know where things are slow:
| Tool | Used for | Link |
|---|---|---|
| PageSpeed Insights | Overall score, CWV | pagespeed.web.dev |
| Vercel Speed Insights | Real user data | Built-in |
| Chrome DevTools | Network waterfall | F12 → Network |
| next-bundle-analyzer | Bundle size breakdown | npm package |
| Plausible | Real user performance | $9/month |
1. Image Optimization — Biggest Impact
Images account for 50–80% of page weight if left unoptimized. Next.js's Image component handles everything:
💻tsximport Image from "next/image"; // ❌ Wrong — using plain <img> <img src="/hero.png" style={{ width: "100%" }} /> // ✅ Right — next/image <Image src="/hero.png" alt="Hero image" width={1200} height={630} priority // LCP image: load ngay quality={85} // 85 thay vì 100 default placeholder="blur" // Blur placeholder trong khi load blurDataURL="data:image/jpeg;base64,..." />
Result: Hero image went from 2.3MB down to 180KB.
Remote Images from a CDN
📘typescript// next.config.ts export default { images: { remotePatterns: [ { protocol: "https", hostname: "images.unsplash.com" }, { protocol: "https", hostname: "cdn.yoursite.com" }, ], formats: ["image/avif", "image/webp"], // Modern formats }, };
2. Font Loading — Eliminating CLS
Cumulative Layout Shift (CLS) often comes from fonts loading late:
💻tsx// app/layout.tsx import { Inter, JetBrains_Mono } from "next/font/google"; // next/font self-hosts fonts — no requests to Google const inter = Inter({ subsets: ["latin"], display: "swap", // Show text immediately with system font, swap when Inter loads preload: true, }); const mono = JetBrains_Mono({ subsets: ["latin"], display: "swap", variable: "--font-mono", });
💡
next/fontdownloads fonts at build time and serves them from your own domain — eliminating all external font requests. No CORS, no latency.
3. Bundle Analysis — Finding Heavy Components
💻bashnpm install @next/bundle-analyzer
📘typescript// next.config.ts import bundleAnalyzer from "@next/bundle-analyzer"; const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true", }); export default withBundleAnalyzer({ // ...config });
💻bashANALYZE=true npm run build
A visual treemap opens in your browser — you can immediately see which packages are eating the most bundle space.
Common Culprits
💻tsx// ❌ Importing the whole library import _ from "lodash"; // 71KB import * as icons from "react-icons"; // All icons // ✅ Named imports / tree-shaking import { debounce } from "lodash-es"; // Just 2KB import { FiGithub } from "react-icons/fi"; // Just the icon you need
4. Server Components vs Client Components
App Router defaults to Server Components — heavy logic runs on the server and never ships to the client:
💻tsx// ✅ Server Component — zero JS on the client export default async function BlogList() { const posts = await db.posts.findMany(); // Chạy trên server return <ul>{posts.map(p => <li>{p.title}</li>)}</ul>; } // ❌ Không cần 'use client' chỉ vì dùng useState // Refactor: tách interactive part ra component riêng "use client"; export function SearchInput({ onSearch }: { onSearch: (q: string) => void }) { // Only this part needs to be a client component }
Rule of thumb: Push "use client" down to the smallest leaf components possible.
5. Caching Strategy
💻tsx// Fetch with caching async function getData() { // Cache for 1 hour, revalidate on a timer const res = await fetch("https://api.example.com/data", { next: { revalidate: 3600 }, }); return res.json(); } // Or tag-based revalidation const res = await fetch("https://api.example.com/posts", { next: { tags: ["posts"] }, }); // Invalidate from a server action import { revalidateTag } from "next/cache"; await revalidateTag("posts");
6. Lazy Loading Components
💻tsximport dynamic from "next/dynamic"; // Load the component only when needed const HeavyChart = dynamic( () => import("@/components/HeavyChart"), { loading: () => <div className="animate-pulse h-64 rounded-xl bg-zinc-800" />, ssr: false, // If the component uses browser APIs } ); // In JSX — only loads when the component mounts <HeavyChart data={chartData} />
7. Vercel Speed Insights
If you're deploying on Vercel, enable Speed Insights to monitor real-user performance:
💻bashnpm install @vercel/speed-insights
💻tsx// app/layout.tsx import { SpeedInsights } from "@vercel/speed-insights/next"; export default function RootLayout({ children }) { return ( <html> <body> {children} <SpeedInsights /> </body> </html> ); }
The Vercel dashboard shows LCP, CLS, and FID broken down by route — so you know exactly which pages are slow.
8. Plausible — Lightweight Performance Monitoring
Swap Google Analytics (45KB) for Plausible (<1KB):
💻tsx<Script defer data-domain="yourdomain.com" src="https://plausible.io/js/script.js" strategy="afterInteractive" />
No cookie banner needed, doesn't block rendering, has zero impact on TTI.
Results Summary
| Metric | Before | After | Improvement |
|---|---|---|---|
| PageSpeed Mobile | 61 | 98 | +37 |
| LCP | 4.2s | 1.1s | -74% |
| CLS | 0.18 | 0.02 | -89% |
| FID | 180ms | 12ms | -93% |
| JS Bundle | 420KB | 180KB | -57% |
| Page Weight | 3.2MB | 890KB | -72% |
Quick Checklist
- All images use
next/imagewithpriorityon the LCP image - Fonts use
next/font/google(no CSS imports from Google) - No
"use client"at the top-level layout - Bundle analysis run, no unnecessary heavy dependencies
- Dynamic imports for components > 50KB
- Vercel Speed Insights or Plausible active
-
next.config.tshas image formats: avif, webp
Related Articles
How I Build a Landing Page from Zero to Live in One Day
The real stack I use to ship landing pages fast: Next.js + shadcn/ui + Vercel. From setup to deploy, with a list of UI tools and hosting options I actually trust.
8 min read →
DevOpsHow to Deploy Next.js App Router to a Ubuntu VPS with Docker and Nginx
A step-by-step guide: multi-stage Dockerfile, Docker Compose, Nginx reverse proxy, free SSL with Certbot, and automated CI/CD with GitHub Actions.
12 min read →
Found this useful?
Subscribe to get the latest technical articles and reviews from CHAEI PUEI Tech.
Subscribe for free