Core Web Vitals are Google's metrics for measuring real-world user experience. They directly impact search rankings, user engagement, and conversion rates. Every optimization technique in this module targets one or more of these metrics.
| Metric | Measures | Good | Next.js Helps With |
|---|---|---|---|
| LCP | Largest Contentful Paint — loading speed | < 2.5s | priority on hero image, next/font, SSR/streaming |
| INP | Interaction to Next Paint — responsiveness | < 200ms | Server Components, useTransition, less client JS |
| CLS | Cumulative Layout Shift — visual stability | < 0.1 | next/image dimensions, next/font, skeleton loaders |
// Hero image: priority + responsive sizes
<Image
src={hero}
alt="Hero"
priority // Preloads the image
sizes="100vw" // Correct size for viewport
placeholder="blur" // Instant visual while loading
/>
// Font: self-hosted, no network waterfall
const inter = Inter({ subsets: ['latin'] })
// SSR/Streaming: content arrives fast
<Suspense fallback={<HeroSkeleton />}>
<HeroContent />
</Suspense>useReportWebVitals, not just lab scores from Lighthouse.Images are often the largest assets on web pages. The next/image component provides automatic format conversion (WebP/AVIF), responsive sizing, and lazy loading out of the box.
<Image> from next/image instead of native <img> tags. It optimizes images automatically and prevents layout shifts.import Image from 'next/image'
import heroImg from '@/public/hero.jpg' // Import = auto dimensions
export function Hero() {
return (
<Image
src={heroImg}
alt="Hero showcasing our product"
placeholder="blur" // Auto blur placeholder for local images
priority // Preload - above the fold (improves LCP)
sizes="100vw"
/>
)
}Key props to know:
priority — Preload above-the-fold images (hero, banner). Directly improves LCPsizes — Tell the browser how wide the image will be at each breakpoint. Prevents oversized downloadsfill — Fill the parent container when dimensions are unknown. Pair with sizes for correct responsive behaviorplaceholder="blur" — Show a blur placeholder while loading. Automatic for local images, needs blurDataURL for remotenext/font self-hosts fonts, eliminates network waterfalls, and prevents layout shift (CLS). It downloads font files at build time and serves them alongside your static assets.
<link href="fonts.googleapis.com"> or @import url() in CSS. Always use next/font.import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'], // Only load Latin characters
display: 'swap', // Show fallback while loading
variable: '--font-inter', // CSS variable for Tailwind
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
)
}Common mistakes to avoid:
subsets: ['latin'] (loads all character sets, massive files)lib/fonts.ts file. Use CSS variables for maximum flexibility across your styles.Third-party scripts (analytics, widgets, ads) can significantly impact your page performance. The next/script component provides strategic loading options to minimize this impact.
@next/third-parties for Google services instead of manual script tags — it applies optimized loading strategies automatically.// app/layout.tsx
import { GoogleAnalytics } from '@next/third-parties/google'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
<GoogleAnalytics gaId="G-XXXXXXXXXX" />
</html>
)
}
// Send events from any component
import { sendGAEvent } from '@next/third-parties/google'
export function BuyButton({ product }) {
return (
<button onClick={() => {
sendGAEvent('event', 'purchase', {
value: product.price,
})
}}>
Buy Now
</button>
)
}| Strategy | When to Use | Example |
|---|---|---|
afterInteractive | 90% of scripts: analytics, chat, payments | Google Analytics, Intercom |
lazyOnload | Non-essential features | Social embeds, surveys |
beforeInteractive | Critical initialization (root layout only) | Polyfills, consent managers |
worker | Heavy computation off main thread | Analytics processing |
@next/third-parties for Google services (Analytics, Tag Manager, Maps) instead of manual script tags. Always add an id to inline scripts.Data waterfalls occur when requests depend on previous responses, creating a sequential chain. This is the single most impactful performance issue in web applications.
Promise.all, it takes 200ms — 3x faster.export default async function DashboardPage() {
// Each fetch waits for the previous one
const user = await getUser() // 200ms
const posts = await getPosts() // 200ms (waits for user)
const comments = await getComments() // 200ms (waits for posts)
// Total: ~600ms
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
<CommentList comments={comments} />
</div>
)
}Three strategies to eliminate waterfalls:
React.cache() with fire-and-forget calls to start fetches earlyThe prefetch prop on <Link> has three behaviors, but the default payload depends on whether the app uses Cache Components and Partial Prefetching.
prefetch={true} can do more work—and with Runtime Prefetching it can invoke the server once per visible link.prefetch={false} no longer prefetches on hover in App Router. In Pages Router it still prefetched on hover — now it completely disables prefetching.| Value | Standard App Router | Partial Prefetching |
|---|---|---|
auto / null (default) | Full static route, or a dynamic route down to its loading boundary | One shared App Shell per route |
true | Full route for both static and dynamic destinations | App Shell plus per-link URL data and cached content |
false | Completely disables prefetching (including hover) | Same: no route data prefetched |
import Link from 'next/link'
// Default: let Next.js choose the useful prefetched payload
<Link href="/about">About</Link>
// With Partial Prefetching, resolve this URL's cached results
// before the click. Use selectively: it can invoke the server.
<Link href="/search?q=nextjs" prefetch={true}>
Search for Next.js
</Link>
// Disable prefetching entirely, including hover
<Link href="/admin/reports" prefetch={false}>
Reports
</Link>cacheComponents and partialPrefetching enabled, the default also prefetches a reusable App Shell for dynamic routes, and prefetch={true} resolves URL-specific cached content.prefetch={true} only when the additional destination content materially improves a likely click.A navigation only feels instant when the browser already has useful UI to show. Classify each part of the destination route before adding more client-side state or aggressive prefetching.
import { Suspense } from 'react'
export default function ProductPage({ params }) {
return (
<main>
{/* Static: included in the prefetched shell */}
<h1>Product details</h1>
{/* Dynamic: fallback is included in the shell */}
<Suspense fallback={<InventorySkeleton />}>
<Inventory params={params} />
</Suspense>
</main>
)
}When navigating on the same page with only search parameter changes, Suspense boundaries do not re-trigger their loading states by default. The UI appears frozen during data refetching.
key prop to determine component identity. Changing the key forces React to unmount the old instance and mount a new one, re-triggering Suspense.// app/search/page.tsx
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>
}) {
const { q } = await searchParams
return (
<Suspense fallback={<LoadingSpinner />}>
{/* Suspense does NOT re-trigger when ?q changes */}
<SearchResults query={q} />
</Suspense>
)
}How it works:
key prop change makes React treat the Suspense boundary as a new instancekey prop containing the changing search parameter to Suspense boundaries on search/filter pages.instant(): Assert which UI is available without waiting for the network, preventing shell regressions.import { expect, test } from '@playwright/test'
import { instant } from '@next/playwright'
test('product shell is ready immediately', async ({ page }) => {
await page.goto('/products')
await instant(page, async () => {
await page.getByRole('link', { name: 'Desk Lamp' }).click()
await expect(page.getByRole('heading')).toContainText('Desk Lamp')
await expect(page.getByText('Checking inventory...')).toBeVisible()
})
await expect(page.getByText('12 in stock')).toBeVisible()
})instant().Every kilobyte of JavaScript sent to the client must be downloaded, parsed, and executed. Reducing bundle size directly improves Time to Interactive (TTI) and Interaction to Next Paint (INP).
// This pulls in ALL exports from the barrel file
// even if you only use Button
import { Button } from '@/components'
// The barrel file (components/index.ts) exports everything:
// export { Button } from './Button'
// export { Modal } from './Modal' <- included in bundle
// export { DataGrid } from './DataGrid' <- included in bundle
// export { Chart } from './Chart' <- included in bundle'use client'
import { Chart } from 'chart-library' // 200kb added to initial bundle
export function Dashboard() {
const [showChart, setShowChart] = useState(false)
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
{showChart && <Chart data={data} />}
</div>
)
}Bundle size checklist:
next/dynamicnext experimental-analyze to inspect module sizesnpx next experimental-analyze to see exactly what's contributing to your bundle size and identify optimization targets.Server Components run only on the server. They can fetch data directly without API round-trips, access backend resources, and send zero JavaScript to the client. But you need to use them correctly.
import { cache } from 'react'
// Deduplicate across the same request
const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } })
})
// Called in generateMetadata
export async function generateMetadata({ params }) {
const user = await getUser(params.id) // 1st call: hits DB
return { title: user.name }
}
// Called in the page - same request, same cache
export default async function UserPage({ params }) {
const user = await getUser(params.id) // 2nd call: uses cache
return <UserProfile user={user} />
}Server Component patterns:
The 'use client' directive creates a boundary. Everything imported by a Client Component becomes client-side code. The key is keeping this boundary as small as possible.
'use client' as a fence. Everything inside the fence ships JavaScript to the browser. Keep the fence around as little code as possible.'use client'
import { useState } from 'react'
import { BlogPosts } from './BlogPosts' // Now client-side
import { Sidebar } from './Sidebar' // Now client-side
import { Footer } from './Footer' // Now client-side
export function Dashboard() {
const [open, setOpen] = useState(false)
return (
<div>
<button onClick={() => setOpen(!open)}>Toggle</button>
<Sidebar /> {/* Can't fetch data on server */}
<BlogPosts /> {/* Can't fetch data on server */}
<Footer /> {/* Unnecessarily in client bundle */}
</div>
)
}Next.js provides powerful metadata APIs for SEO optimization. You can define metadata statically or generate it dynamically based on your content.
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'About Our Company',
description: 'Learn about our mission and values',
openGraph: {
title: 'About Our Company',
description: 'Learn about our mission and values',
images: ['/og-about.jpg'],
},
}
export default function AboutPage() {
return <h1>About Us</h1>
}Metadata inheritance and merging:
React.cache() when the same data is needed for both generateMetadata and the page component to avoid duplicate fetches.Next.js supports file-based metadata conventions. Place special files in your route directories and they're automatically picked up.
| File | Purpose | Location |
|---|---|---|
favicon.ico | Browser tab icon | app/ root |
opengraph-image.png | Social share image | Any route segment |
sitemap.ts | Search engine sitemap | app/ root |
robots.ts | Crawler instructions | app/ root |
import { ImageResponse } from 'next/og'
import { getBlogPost } from '@/lib/api'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function OGImage({
params,
}: {
params: { slug: string }
}) {
const post = await getBlogPost(params.slug)
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: 80,
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
}}
>
<h1 style={{ fontSize: 64 }}>{post.title}</h1>
<p style={{ fontSize: 28, opacity: 0.8 }}>{post.excerpt}</p>
</div>
),
{ ...size }
)
}next/og (not @vercel/og) for OG image generation. Prefer route params over searchParams for cache efficiency.When Client Components do need interactivity, optimize their rendering behavior. Unnecessary re-renders waste CPU cycles, cause janky UIs, and directly impact INP scores.
// Using useEffect to derive state (extra re-render)
function FilteredList({ items, query }) {
const [filtered, setFiltered] = useState(items)
useEffect(() => {
setFiltered(items.filter(i => i.name.includes(query)))
}, [items, query])
// Problem: renders twice - once with stale data, once with filtered
return <List items={filtered} />
}
// Derive during render - no extra state or effect needed
function FilteredList({ items, query }) {
const filtered = items.filter(i => i.name.includes(query))
// Renders once with correct data
return <List items={filtered} />
}Re-render optimization rules:
useState + useEffect for computed valuessetState(prev => ...) for stable callbacksuseState for expensive initial valuesUse this checklist before shipping to production to ensure your application is fully optimized across all categories.
| Category | Action | Priority |
|---|---|---|
| Components | Server Components by default, Client Components only for interactivity | Critical |
| Data | Eliminate waterfalls with Promise.all or Suspense streaming | Critical |
| Bundle | Direct imports, dynamic imports for heavy components | Critical |
| Images | next/image with sizes, priority for LCP images | High |
| Fonts | next/font with subsets and CSS variables | High |
| Metadata | generateMetadata + OG images + sitemap | High |
| Scripts | next/script with proper strategy, @next/third-parties | Medium |
| Navigation | Default Link prefetching; targeted runtime prefetching only when URL data justifies it | High |
| Rendering | Derive state during render, useTransition for async updates | Medium |
| Serialization | Pass minimal data across Server/Client boundary | Medium |
| Analysis | Instant Insights, instant() tests, bundle analysis, and Core Web Vitals | Medium |
Performance optimization is not a one-time task. You need ongoing monitoring to catch regressions, validate improvements, and understand real-world user experience.
// app/layout.tsx
import { useReportWebVitals } from 'next/web-vitals'
export function WebVitals() {
useReportWebVitals((metric) => {
// Send to your analytics service
console.log(metric.name, metric.value)
switch (metric.name) {
case 'LCP':
if (metric.value > 2500) {
console.warn('LCP exceeds threshold:', metric.value)
}
break
case 'INP':
if (metric.value > 200) {
console.warn('INP exceeds threshold:', metric.value)
}
break
case 'CLS':
if (metric.value > 0.1) {
console.warn('CLS exceeds threshold:', metric.value)
}
break
}
})
return null
}Monitoring strategy:
next experimental-analyze to identify issues earlyuseReportWebVitals to track real user metrics continuously