Optimization & Performance

Core Web Vitals, Performance Patterns & Production Best Practices

Presentation logo

Core Web Vitals

Why Performance Matters

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.

Core Idea: Performance is not a nice-to-have. Users abandon pages that load slowly, and search engines penalize them. These three metrics define what "fast" means.
MetricMeasuresGoodNext.js Helps With
LCPLargest Contentful Paint — loading speed< 2.5spriority on hero image, next/font, SSR/streaming
INPInteraction to Next Paint — responsiveness< 200msServer Components, useTransition, less client JS
CLSCumulative Layout Shift — visual stability< 0.1next/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>
Best Practice: Measure in production with real user metrics using Vercel Analytics or useReportWebVitals, not just lab scores from Lighthouse.

Image Optimization

Automatic Optimization with next/image

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.

Rule: Always use <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 LCP
  • sizes — Tell the browser how wide the image will be at each breakpoint. Prevents oversized downloads
  • fill — Fill the parent container when dimensions are unknown. Pair with sizes for correct responsive behavior
  • placeholder="blur" — Show a blur placeholder while loading. Automatic for local images, needs blurDataURL for remote
Quality guide: 75 for thumbnails, 80-85 for hero images, 90+ is usually overkill for web.

Font Optimization

Zero Layout Shift with next/font

next/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.

Golden Rule: Never use <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:

  • Creating font instances inside components (creates duplicates on each render)
  • Forgetting subsets: ['latin'] (loads all character sets, massive files)
  • Using static weight imports instead of variable fonts (multiple files vs one)
Best Practice: Export font instances from a shared lib/fonts.ts file. Use CSS variables for maximum flexibility across your styles.

Third-party Script Loading

Optimal Loading Strategies

Third-party scripts (analytics, widgets, ads) can significantly impact your page performance. The next/script component provides strategic loading options to minimize this impact.

Key Concept: Use @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>
  )
}
StrategyWhen to UseExample
afterInteractive90% of scripts: analytics, chat, paymentsGoogle Analytics, Intercom
lazyOnloadNon-essential featuresSocial embeds, surveys
beforeInteractiveCritical initialization (root layout only)Polyfills, consent managers
workerHeavy computation off main threadAnalytics processing
Best Practice: Use @next/third-parties for Google services (Analytics, Tag Manager, Maps) instead of manual script tags. Always add an id to inline scripts.

Eliminating Data Waterfalls

The #1 Performance Killer

Data waterfalls occur when requests depend on previous responses, creating a sequential chain. This is the single most impactful performance issue in web applications.

Critical: A page with 3 sequential 200ms fetches takes 600ms. With 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:

  • Promise.all(): Parallelize independent fetches in the same component
  • Suspense streaming: Each component fetches its own data, content appears progressively
  • Preload pattern: Use React.cache() with fire-and-forget calls to start fetches early
Next.js 16.3: With Cache Components, static and cached UI plus Suspense fallbacks form a prerendered App Shell. That shell can appear immediately while uncached dynamic content streams from the server.

Link Prefetching Behavior

Let the Router Choose, Then Opt In Deliberately

The prefetch prop on <Link> has three behaviors, but the default payload depends on whether the app uses Cache Components and Partial Prefetching.

Key Concept: Start with the router default. An explicit prefetch={true} can do more work—and with Runtime Prefetching it can invoke the server once per visible link.
Breaking Change from Pages Router: prefetch={false} no longer prefetches on hover in App Router. In Pages Router it still prefetched on hover — now it completely disables prefetching.
ValueStandard App RouterPartial Prefetching
auto / null (default)Full static route, or a dynamic route down to its loading boundaryOne shared App Shell per route
trueFull route for both static and dynamic destinationsApp Shell plus per-link URL data and cached content
falseCompletely 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>
New in Next.js 16.3: With cacheComponents and partialPrefetching enabled, the default also prefetches a reusable App Shell for dynamic routes, and prefetch={true} resolves URL-specific cached content.
Best Practice: Keep the default for navigation and large lists. Use prefetch={true} only when the additional destination content materially improves a likely click.

Design an Instant App Shell

Cache, Stream, or Block

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>
  )
}
  • Cache: Stable UI can ship in the shell.
  • Stream: Put dynamic reads behind Suspense with a useful fallback.
  • Block: Uncached work outside Suspense delays the entire navigation.
SPA-like feel, server-rendered app: You can make every click feel instant — persistent layout, no blank reloads — without moving everything to Client Components. Keep fetching and authorization on the server, preserve client state in shared layouts, and use transitions plus optimistic updates for mutations.

Key-Based Suspense

Re-triggering Loading States on Search Param Changes

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 Concept: React uses the 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:

  • The key prop change makes React treat the Suspense boundary as a new instance
  • React unmounts the old content and shows the fallback while the new data loads
  • This works for any same-page navigation where only search params or dynamic data changes
Best Practice: Always add a key prop containing the changing search parameter to Suspense boundaries on search/filter pages.

Protect Instant Navigations

Inspect in Development, Assert in Playwright

  • Instant Insights: Next.js DevTools automatically identifies navigations with no immediate UI.
  • Navigation Inspector: Pause at the prefetched shell to see exactly what appears before server data arrives.
  • 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()
})
Prefetching is disabled during normal development. Use the Navigation Inspector to visualize shells, then test production behavior with instant().

Bundle Size Optimization

Ship Less JavaScript to the Client

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).

Golden Rule: Ship as little JavaScript to the client as possible. Use Server Components by default, Client Components only when needed.
// 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:

  • Direct imports: Avoid barrel files, import from specific paths
  • Dynamic imports: Code-split heavy components with next/dynamic
  • Analyze bundles: Run next experimental-analyze to inspect module sizes
  • Minimize NEXT_PUBLIC_: Environment variables prefixed with NEXT_PUBLIC_ are inlined into the client bundle
Analyze your bundle: Run npx next experimental-analyze to see exactly what's contributing to your bundle size and identify optimization targets.

Server Component Performance

Maximizing RSC Efficiency

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.

Key Concept: The boundary between Server and Client Components is a serialization boundary. Only JSON-serializable data can cross it.
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:

  • React.cache(): Deduplicates identical calls within a single request
  • Minimize serialization: Only pass the data Client Components actually need
  • after(): Defer non-critical work (emails, logs, analytics) to after the response

Client Component Boundaries

Keep Them Small and Focused

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.

Mental Model: Think of '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>
  )
}
Composition Pattern: Use the "children" pattern to wrap Server Components with Client Components. The children are rendered on the server and passed as pre-rendered content.

Metadata & SEO

Configuring Page Metadata

Next.js provides powerful metadata APIs for SEO optimization. You can define metadata statically or generate it dynamically based on your content.

Key Concept: Metadata flows from root layout to page, merging at each level. Child metadata overrides parent metadata for the same fields.
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:

  • Metadata is merged from root layout down to page
  • Child metadata overrides parent metadata
  • Arrays are replaced, not merged (e.g., keywords)
  • OpenGraph objects are deeply merged
Pro tip: Use React.cache() when the same data is needed for both generateMetadata and the page component to avoid duplicate fetches.

Metadata File Conventions & OG Images

File-Based Metadata & Dynamic OG Generation

Next.js supports file-based metadata conventions. Place special files in your route directories and they're automatically picked up.

Key Concept: File conventions let you define metadata without writing code — just place the right file in the right folder. For dynamic content, use code-based generation.
FilePurposeLocation
favicon.icoBrowser tab iconapp/ root
opengraph-image.pngSocial share imageAny route segment
sitemap.tsSearch engine sitemapapp/ root
robots.tsCrawler instructionsapp/ 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 }
  )
}
Best Practice: Use next/og (not @vercel/og) for OG image generation. Prefer route params over searchParams for cache efficiency.

Re-render Optimization

Keeping Client Components Efficient

When Client Components do need interactivity, optimize their rendering behavior. Unnecessary re-renders waste CPU cycles, cause janky UIs, and directly impact INP scores.

Key Concept: The fastest render is the one that never happens. Derive values during render instead of storing them in state, and separate urgent from non-urgent updates.
// 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:

  • Derive state during render: Don't use useState + useEffect for computed values
  • useTransition: Separate urgent updates (input) from non-urgent (search results)
  • Functional setState: Use setState(prev => ...) for stable callbacks
  • Lazy state init: Pass a function to useState for expensive initial values

Production Optimization Checklist

Quick Reference

Use this checklist before shipping to production to ensure your application is fully optimized across all categories.

Approach: Start with Critical items first — they have the highest impact on performance. Then work through High and Medium priority items.
CategoryActionPriority
ComponentsServer Components by default, Client Components only for interactivityCritical
DataEliminate waterfalls with Promise.all or Suspense streamingCritical
BundleDirect imports, dynamic imports for heavy componentsCritical
Imagesnext/image with sizes, priority for LCP imagesHigh
Fontsnext/font with subsets and CSS variablesHigh
MetadatagenerateMetadata + OG images + sitemapHigh
Scriptsnext/script with proper strategy, @next/third-partiesMedium
NavigationDefault Link prefetching; targeted runtime prefetching only when URL data justifies itHigh
RenderingDerive state during render, useTransition for async updatesMedium
SerializationPass minimal data across Server/Client boundaryMedium
AnalysisInstant Insights, instant() tests, bundle analysis, and Core Web VitalsMedium
0/12 — Getting started. Work through each category systematically.0%
Images
Fonts
Bundle
Data
SEO
Scripts
Monitoring

Measuring & Monitoring

Continuous Performance Tracking

Performance optimization is not a one-time task. You need ongoing monitoring to catch regressions, validate improvements, and understand real-world user experience.

Key Concept: Lab metrics (Lighthouse) tell you about potential issues. Field metrics (real user data) tell you about actual user experience. You need both.
// 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:

  • Development: Use Lighthouse and next experimental-analyze to identify issues early
  • CI/CD: Run Lighthouse CI in pull requests to catch regressions before they ship
  • Production: Use Vercel Analytics or useReportWebVitals to track real user metrics continuously
Remember: Optimize the critical path first, monitor Core Web Vitals in production, analyze bundles regularly, and treat performance as an ongoing practice — not a one-time fix.
1 / 17
Slide 1 of 17