{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreif6rnf5n6egv636pzzhtdegwx2jud7zzmj4mtxgiyktvaw3djhdmq",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mowoyms7gm52"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreie6wp7zbizaqbq2hqpyna3sk3u25pdgry5vca5krzmb2uoekeiia4"
    },
    "mimeType": "image/webp",
    "size": 178940
  },
  "path": "/aon_infotech_3a1b6ff525fc/optimizing-nextjs-app-router-for-core-web-vitals-a-practical-guide-11if",
  "publishedAt": "2026-06-23T05:33:27.000Z",
  "site": "https://dev.to",
  "tags": [
    "nextjs",
    "performance",
    "webdev",
    "javascript",
    "free AI image generator high quality"
  ],
  "textContent": "Core Web Vitals have been a ranking factor for a while, but App Router introduced new patterns that affect how you optimize for them. Some old approaches don't apply. Some new ones are surprisingly easy to miss.\n\nHere's what actually moves the needle for LCP, CLS, and INP in a Next.js App Router application — from lessons building a production app — the same patterns power the free AI image generator high quality output at pixova.io.\n\n##  The Three Metrics — What Changed in App Router\n\n**LCP (Largest Contentful Paint):** The largest visible element loading fast. In App Router, Server Components help here because content can start streaming before JavaScript loads. But images are still the most common LCP element and need explicit attention.\n\n**CLS (Cumulative Layout Shift):** Visual stability — elements not jumping around as the page loads. App Router's streaming model can actually make CLS worse if you're not careful about skeleton sizing.\n\n**INP (Interaction to Next Paint):** Replaced FID as a metric in 2024. Measures responsiveness to user interactions. React's concurrent rendering in App Router helps here, but heavy Client Components can still block the main thread.\n\n##  LCP — The Image Problem\n\nThe most common LCP failure in Next.js applications: images that load too slowly, or that aren't identified as the LCP element.\n\n**Step 1 — Identify your LCP element**\n\nOpen Chrome DevTools → Performance → record a page load → look for the LCP marker. In most content-heavy pages, it's an image. In App Router applications, it might be a server-rendered text block.\n\n**Step 2 — Add`priority` to your LCP image**\n\n\n\n    // Without priority — image loads lazily (wrong for LCP)\n    <Image src={heroImage} alt=\"Hero\" width={1200} height={630} />\n\n    // With priority — image preloaded immediately (correct for LCP)\n    <Image\n      src={heroImage}\n      alt=\"Hero\"\n      width={1200}\n      height={630}\n      priority  // Only use on above-the-fold LCP images\n    />\n\n\n`priority` tells Next.js to preload this image and skip lazy loading. Only use it on the single LCP element — adding it to multiple images defeats the purpose.\n\n**Step 3 — Preconnect to external image domains**\n\n\n\n    // app/layout.js\n    export default function RootLayout({ children }) {\n      return (\n        <html>\n          <head>\n            <link rel=\"preconnect\" href=\"https://your-cdn.com\" />\n            <link rel=\"dns-prefetch\" href=\"https://your-cdn.com\" />\n          </head>\n          <body>{children}</body>\n        </html>\n      );\n    }\n\n\nThis starts the connection to your image CDN before the image request fires, saving DNS lookup and TLS handshake time.\n\n**Step 4 — Use proper`sizes` attribute**\n\n\n\n    // Wrong — browser downloads full-size image for mobile\n    <Image src={image} alt=\"\" width={1200} height={630} />\n\n    // Right — browser downloads appropriate size for viewport\n    <Image\n      src={image}\n      alt=\"\"\n      fill\n      sizes=\"(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw\"\n    />\n\n\nThe `sizes` attribute tells the browser what size the image will be rendered at, letting it download the appropriately sized version instead of always downloading the full-size image.\n\n##  CLS — The Streaming Problem\n\nApp Router's streaming with Suspense is excellent for performance, but introduces a CLS risk: if Suspense boundaries don't have sized fallbacks, content below them shifts when they resolve.\n\n\n\n    // CLS problem — unsized fallback causes layout shift\n    <Suspense fallback={<div>Loading...</div>}>\n      <UserProfile />\n    </Suspense>\n\n    // CLS fixed — fallback matches expected content size\n    <Suspense fallback={<UserProfileSkeleton />}>\n      <UserProfile />\n    </Suspense>\n\n\n\n    // UserProfileSkeleton — must match UserProfile dimensions exactly\n    function UserProfileSkeleton() {\n      return (\n        <div className=\"flex items-center gap-3 p-4\">\n          <div className=\"w-10 h-10 rounded-full bg-neutral-200 animate-pulse\" />\n          <div className=\"flex flex-col gap-2\">\n            <div className=\"h-4 w-32 bg-neutral-200 rounded animate-pulse\" />\n            <div className=\"h-3 w-24 bg-neutral-200 rounded animate-pulse\" />\n          </div>\n        </div>\n      );\n    }\n\n\n**The rule:** Every Suspense boundary fallback must be dimensionally identical to what it's replacing. Any size difference between fallback and resolved content = CLS.\n\n##  CLS — Font Loading\n\nCustom fonts are another common CLS source. Text renders in the fallback font, then shifts when the custom font loads.\n\n\n\n    // app/layout.js\n    import { Inter } from 'next/font/google';\n\n    const inter = Inter({\n      subsets: ['latin'],\n      display: 'swap',     // Show fallback font immediately\n      preload: true,       // Preload font files\n      // Optional: reduce FOUT with size-adjust\n      adjustFontFallback: true,\n    });\n\n    export default function RootLayout({ children }) {\n      return (\n        <html className={inter.className}>\n          <body>{children}</body>\n        </html>\n      );\n    }\n\n\n`next/font` handles font optimization automatically — it self-hosts Google Fonts, eliminates the network request to Google's servers, and generates `size-adjust` CSS to reduce the visual shift when the font loads.\n\n##  INP — Client Component Optimization\n\nINP measures how quickly the page responds to user input. Long tasks on the main thread cause high INP. In App Router, the main culprits:\n\n**Heavy Client Components loading too early:**\n\n\n\n    // Loads everything upfront — heavy JS bundle blocks INP\n    import { HeavyEditor } from '@/components/HeavyEditor';\n\n    // Better — loads only when needed\n    import dynamic from 'next/dynamic';\n    const HeavyEditor = dynamic(() => import('@/components/HeavyEditor'), {\n      loading: () => <EditorSkeleton />,\n      ssr: false, // Client-only component\n    });\n\n\n**Event handlers doing too much synchronously:**\n\n\n\n    // INP problem — blocks main thread for 200ms\n    function handleClick() {\n      const result = heavyComputation(); // Blocks\n      setResult(result);\n    }\n\n    // Better — defer non-urgent work\n    function handleClick() {\n      // Immediate UI feedback\n      setIsLoading(true);\n\n      // Defer heavy work\n      setTimeout(() => {\n        const result = heavyComputation();\n        setResult(result);\n        setIsLoading(false);\n      }, 0);\n    }\n\n\n##  Measuring Progress\n\nDon't rely on Lighthouse scores alone — they're simulated and don't reflect real user experience. Use these alongside:\n\n**PageSpeed Insights** — runs Lighthouse but also shows field data from Chrome UX Report (real users, not simulation).\n\n**Google Search Console → Core Web Vitals** — actual performance data from your real visitors, segmented by mobile/desktop.\n\n**Web Vitals library** for in-app measurement:\n\n\n\n    import { onLCP, onCLS, onINP } from 'web-vitals';\n\n    onLCP(metric => sendToAnalytics(metric));\n    onCLS(metric => sendToAnalytics(metric));\n    onINP(metric => sendToAnalytics(metric));\n\n\nSimulate → measure in field → fix → repeat. Lab scores that don't match field data usually mean the simulation is missing something about your real environment.\n\n##  The Checklist\n\n  * [ ] LCP image has `priority` prop\n  * [ ] LCP image has correct `sizes` attribute\n  * [ ] CDN domain has `preconnect` link\n  * [ ] All Suspense fallbacks match resolved content dimensions\n  * [ ] Fonts use `next/font` (not `<link>` to Google Fonts)\n  * [ ] Heavy Client Components are dynamically imported\n  * [ ] Event handlers don't block the main thread\n  * [ ] Measuring with field data, not just Lighthouse Most Core Web Vitals improvements come from fixing one or two specific issues, not broad optimization. Identify the actual problem in field data first, then fix that.\n\n\n\n##  Common Mistakes I Made (And Fixed)\n\n**Mistake 1:`priority` on every above-fold image**\n\nI added `priority` to four images thinking \"more is better.\" It broke the preload order — the browser couldn't prioritize when everything claimed priority. Fixed: `priority` on one LCP image only.\n\n**Mistake 2: Suspense fallbacks that were too short**\n\nA Suspense boundary with a one-line \"Loading...\" fallback replaced a card that was 200px tall. The 190px shift when content resolved was catastrophic for CLS. Fixed: skeleton components that exactly match resolved component dimensions.\n\n**Mistake 3: Measuring only with Lighthouse**\n\nLighthouse showed a 90 performance score. Real users on mobile 4G were seeing 4+ second LCP. The simulation doesn't model real network conditions accurately. Fixed: monitoring with Search Console field data as the source of truth.\n\n**Mistake 4: Importing all icons at component level**\n\n\n\n    // Wrong — imports entire icon library\n    import * as Icons from 'react-icons/all';\n\n    // Right — imports only what's needed\n    import { FiDownload, FiShare } from 'react-icons/fi';\n\n\nTree-shaking requires named imports. Default imports of entire libraries bypass tree-shaking and bloat the bundle significantly.\n\n##  Real Numbers\n\nAfter working through this checklist on a production application:\n\n  * LCP improved from 4.2s to 1.8s (mobile, 75th percentile field data)\n  * CLS dropped from 0.18 to 0.02 (CLS threshold is 0.1)\n  * INP improved from 340ms to 95ms The LCP improvement came entirely from `priority` + `preconnect`. The CLS fix was entirely skeleton sizing. The INP improvement was dynamic importing of a heavy client component.\n\n\n\nThree fixes, significant results. Core Web Vitals optimization is usually like this — find the specific bottleneck, fix it precisely, measure the improvement.",
  "title": "Optimizing Next.js App Router for Core Web Vitals — A Practical Guide"
}