← Home/Blog/Optimizing Next.js App Router Performance: From 60 to 98 on PageSpeed
DevOps#nextjs#performance#core-web-vitals#optimization

Optimizing Next.js App Router Performance: From 60 to 98 on PageSpeed

A battle-tested checklist for improving Core Web Vitals in Next.js 15+: image optimization, font loading, bundle analysis, caching strategy, and monitoring with Plausible. Includes real before/after numbers.

CP

CHAEI PUEI Tech

12 min read

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:

ToolUsed forLink
PageSpeed InsightsOverall score, CWVpagespeed.web.dev
Vercel Speed InsightsReal user dataBuilt-in
Chrome DevToolsNetwork waterfallF12 → Network
next-bundle-analyzerBundle size breakdownnpm package
PlausibleReal 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:

💻tsx
import 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/font downloads 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

💻bash
npm 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
});
💻bash
ANALYZE=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

💻tsx
import 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:

💻bash
npm 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

MetricBeforeAfterImprovement
PageSpeed Mobile6198+37
LCP4.2s1.1s-74%
CLS0.180.02-89%
FID180ms12ms-93%
JS Bundle420KB180KB-57%
Page Weight3.2MB890KB-72%

Quick Checklist

Found this useful?

Subscribe to get the latest technical articles and reviews from CHAEI PUEI Tech.

Subscribe for free