AgentSkillsCN

performance

为Next.js应用进行Web性能优化。涵盖Core Web Vitals指标(LCP、INP、CLS)、流式SSR、部分预渲染(PPR)、next/image与next/font优化、Bundle分析、代码分割、动态导入、Edge Runtime运行时,以及各类缓存策略。

SKILL.md
--- frontmatter
name: performance
description: "Web performance optimization for Next.js. Covers Core Web Vitals (LCP, INP, CLS), Streaming SSR, Partial Prerendering (PPR), next/image and next/font optimization, bundle analysis, code splitting, dynamic imports, Edge Runtime, and caching strategies."
license: MIT
metadata:
  author: Balazs Barta
  version: "0.1.0"

Web Performance Optimization for Next.js

Comprehensive guide to optimizing Next.js application performance for Core Web Vitals and user experience.

Core Web Vitals

Google's metrics for measuring real-world user experience. Poor performance on these affects search rankings.

Largest Contentful Paint (LCP)

What it measures: How fast the largest visible element (text block, image, video) appears on the page.

Target: < 2.5 seconds

How to improve:

  1. Optimize images with next/image
  2. Use dynamic imports for large components
  3. Implement code splitting by route
  4. Cache critical resources
  5. Reduce server response time (TTFB)
typescript
// BAD: Large unoptimized image
<img src="/hero.jpg" alt="Hero" />

// GOOD: Using next/image with optimization
import Image from 'next/image'

<Image
  src="/hero.jpg"
  alt="Hero"
  width={1920}
  height={1080}
  priority={true}
  sizes="100vw"
/>

Interaction to Next Paint (INP)

What it measures: How long it takes the page to respond to user interaction (click, tap, key press).

Target: < 200 milliseconds

How to improve:

  1. Reduce JavaScript execution time
  2. Break up long tasks with setTimeout or scheduler
  3. Optimize React render performance
  4. Use Web Workers for heavy computation
  5. Defer non-critical work
typescript
// BAD: Long blocking task
function handleClick() {
  const result = expensiveComputation()
  updateState(result)
}

// GOOD: Defer heavy work
import { startTransition } from 'react'

function handleClick() {
  startTransition(() => {
    const result = expensiveComputation()
    updateState(result)
  })
}

Cumulative Layout Shift (CLS)

What it measures: Unexpected layout changes that occur after the page has loaded.

Target: < 0.1

How to improve:

  1. Always specify dimensions for images and videos
  2. Use font-display: swap for fonts
  3. Insert ads/embeds in containers with reserved space
  4. Avoid inserting content above existing content
  5. Use transform animations instead of position changes
typescript
// BAD: No dimensions, causes layout shift
<Image src="/image.jpg" alt="Test" />

// GOOD: Always include width and height
<Image
  src="/image.jpg"
  alt="Test"
  width={800}
  height={600}
/>

// BAD: Position-based animation
.box { position: relative; top: 0; }
.box.animate { top: 10px; } // Shifts layout

// GOOD: Transform animation
.box { transform: translateY(0); }
.box.animate { transform: translateY(10px); } // No layout shift

Streaming SSR and React Suspense

Streaming SSR allows you to send HTML incrementally while React renders.

How Streaming Works

typescript
// app/page.tsx
import { Suspense } from 'react'
import { ProfileCard } from '@/components/ProfileCard'
import { Loading } from '@/components/Loading'

export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* This chunk sends immediately */}
      <Suspense fallback={<Loading />}>
        {/* This chunk sends once ProfileCard is ready */}
        <ProfileCard />
      </Suspense>
    </div>
  )
}

// Async component
async function ProfileCard() {
  const profile = await fetchProfile()
  return <div>{profile.name}</div>
}

Progressive Enhancement

Users see content progressively as it becomes available:

typescript
export default function ProductPage({ params }) {
  return (
    <>
      {/* Critical content - highest priority */}
      <ProductHeader />

      <div className="grid">
        {/* Medium priority */}
        <Suspense fallback={<ProductSkeleton />}>
          <ProductDetails productId={params.id} />
        </Suspense>

        {/* Lower priority - appears last */}
        <Suspense fallback={<ReviewsSkeleton />}>
          <Reviews productId={params.id} />
        </Suspense>

        {/* Least critical */}
        <Suspense fallback={<RelatedSkeleton />}>
          <RelatedProducts productId={params.id} />
        </Suspense>
      </div>
    </>
  )
}

Partial Prerendering (PPR)

Combines the benefits of static and dynamic rendering. Static shell renders at build time, dynamic holes fill at request time.

When to Use PPR

typescript
// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation'
import { Suspense } from 'react'

export const experimental_ppr = true // Enable PPR for this page

export default function PostPage({ params }) {
  return (
    <article>
      {/* This part is prerendered at build time */}
      <header>
        <h1>Blog Post</h1>
        <nav>Navigation</nav>
      </header>

      {/* This part is dynamic, rendered at request time */}
      <Suspense fallback={<PostSkeleton />}>
        <PostContent postId={params.id} />
      </Suspense>

      {/* Comments may have user-specific data */}
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments postId={params.id} />
      </Suspense>

      {/* Related posts can be prerendered */}
      <aside>
        <RelatedPosts currentId={params.id} />
      </aside>
    </article>
  )
}

async function PostContent({ postId }) {
  // This data is fetched on each request
  const post = await getPost(postId)
  return <div>{post.content}</div>
}

Image Optimization with next/image

The next/image component optimizes images automatically.

Basic Usage

typescript
import Image from 'next/image'
import heroImage from '@/public/hero.jpg'

// Using static import (recommended)
export default function Hero() {
  return (
    <Image
      src={heroImage}
      alt="Hero image"
      // width and height are automatic from import
      priority // Load before other images
    />
  )
}

Responsive Images

typescript
<Image
  src="/responsive-image.jpg"
  alt="Responsive example"
  width={1200}
  height={630}
  sizes="
    (max-width: 640px) 100vw,
    (max-width: 1024px) 90vw,
    (max-width: 1280px) 80vw,
    1200px
  "
/>

Image Formats and Sizes

typescript
export default function Gallery() {
  const images = [
    { id: 1, src: '/image-1.jpg', alt: 'Image 1' },
    { id: 2, src: '/image-2.jpg', alt: 'Image 2' },
  ]

  return (
    <div className="grid">
      {images.map((image) => (
        <Image
          key={image.id}
          src={image.src}
          alt={image.alt}
          width={400}
          height={300}
          quality={80} // Lower quality = smaller file
          placeholder="blur" // Show blur while loading
          blurDataURL="data:image/..." // Optional custom blur
        />
      ))}
    </div>
  )
}

Fill Container

typescript
import Image from 'next/image'

export default function FullScreenImage() {
  return (
    <div className="relative w-full h-screen">
      <Image
        src="/full-screen.jpg"
        alt="Full screen"
        fill
        className="object-cover"
      />
    </div>
  )
}

Font Optimization with next/font

Load fonts without layout shift and with better performance.

Google Fonts

typescript
// app/layout.tsx
import { Inter, Playfair_Display } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  weight: ['400', '500', '700'],
})

const playfair = Playfair_Display({
  subsets: ['latin'],
  weight: ['600', '700'],
  variable: '--font-playfair',
})

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" className={`${inter.className} ${playfair.variable}`}>
      <body>{children}</body>
    </html>
  )
}

CSS Variables for Font Switching

typescript
// globals.css
:root {
  font-family: var(--font-inter);
}

.heading {
  font-family: var(--font-playfair);
  font-weight: 700;
}

.subheading {
  font-family: var(--font-playfair);
  font-weight: 600;
}

Local Fonts

typescript
import localFont from 'next/font/local'

const customFont = localFont({
  src: [
    {
      path: '../fonts/custom-regular.woff2',
      weight: '400',
    },
    {
      path: '../fonts/custom-bold.woff2',
      weight: '700',
    },
  ],
})

Script Optimization

Control when and how third-party scripts load.

Script Strategies

typescript
import Script from 'next/script'

export default function Page() {
  return (
    <>
      {/* Strategy: beforeInteractive - blocks page render */}
      {/* Use for critical scripts only (auth, tracking that must run first) */}
      <Script
        src="https://critical-vendor.com/script.js"
        strategy="beforeInteractive"
      />

      {/* Strategy: afterInteractive - loads after page is interactive (default) */}
      {/* Use for most third-party scripts (analytics, ads) */}
      <Script
        src="https://analytics.example.com/script.js"
        strategy="afterInteractive"
      />

      {/* Strategy: lazyOnload - loads on scroll/interaction */}
      {/* Use for non-critical, heavy scripts (chat widgets, comment systems) */}
      <Script
        src="https://widget.example.com/chat.js"
        strategy="lazyOnload"
      />

      {/* Worker scripts - run in Web Worker, don't block main thread */}
      <Script
        src="https://heavy-computation.com/worker.js"
        worker="heavy-worker"
      />
    </>
  )
}

Bundle Analysis

Understand what's in your JavaScript bundle and optimize it.

Setup @next/bundle-analyzer

bash
npm install -D @next/bundle-analyzer

Configuration

typescript
// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer'

const withAnalyzer = withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})

export default withAnalyzer({
  // your config here
})

Run Analysis

bash
ANALYZE=true npm run build

This generates an interactive treemap showing:

  • Module sizes
  • Dependencies
  • Duplication
  • Opportunities to reduce bundle

Code Splitting Strategies

Automatic Route-Based Splitting

Next.js automatically splits by route. Each page is its own bundle:

typescript
// These are automatically split into separate bundles
// app/page.tsx
// app/about/page.tsx
// app/blog/page.tsx
// app/products/page.tsx

Dynamic Imports

Load components only when needed:

typescript
import dynamic from 'next/dynamic'

const HeavyChart = dynamic(() => import('@/components/Chart'), {
  loading: () => <div>Loading chart...</div>,
  ssr: false, // Optional: disable SSR for client-only components
})

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      {/* Chart only loads when visible */}
      <HeavyChart />
    </div>
  )
}

React.lazy for Code Splitting

typescript
import { lazy, Suspense } from 'react'

const ExpensiveComponent = lazy(
  () => import('@/components/ExpensiveComponent')
)

export default function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ExpensiveComponent />
    </Suspense>
  )
}

Split by Interaction

typescript
let pendingRequest: Promise<typeof import('./HeavyModule')> | null = null

export default function Page() {
  const [showModule, setShowModule] = useState(false)

  const handleClick = async () => {
    // Preload on hover/focus
    if (!pendingRequest) {
      pendingRequest = import('./HeavyModule')
    }

    // Actually load on click
    setShowModule(true)
  }

  return (
    <button onMouseEnter={preload} onClick={handleClick}>
      Open Heavy Module
    </button>
  )
}

function preload() {
  if (!pendingRequest) {
    pendingRequest = import('./HeavyModule')
  }
}

Edge Runtime Optimization

Use Edge Runtime for faster responses and lower latency.

Edge vs Node Runtime

typescript
// app/api/edge-route/route.ts
import { NextRequest } from 'next/server'

// Use Edge Runtime for geographic distribution and lower latency
export const runtime = 'edge'

export async function GET(request: NextRequest) {
  // Access user's location
  const country = request.geo?.country
  const city = request.geo?.city

  // Fast responses: simple transformations, routing, caching
  return new Response(`Hello from ${country}`, {
    headers: {
      'cache-control': 'public, max-age=3600',
    },
  })
}

// app/api/database-route/route.ts
// Keep Node.js runtime for database queries
export const runtime = 'nodejs'

export async function GET(request: NextRequest) {
  // Node.js features: full database access, complex operations
  const data = await db.query('SELECT * FROM users')
  return Response.json(data)
}

Caching Strategies

Data Cache (Server-Side)

typescript
// app/blog/page.tsx

// Cached indefinitely
export const revalidate = false

// Or with tag-based revalidation
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] }
  })
  return posts.map(post => ({ slug: post.slug }))
}

// Revalidate every 1 hour
export const revalidate = 3600

export default async function BlogPage() {
  const posts = await fetch('https://api.example.com/posts', {
    next: {
      revalidate: 3600, // ISR
      tags: ['posts'], // For on-demand revalidation
    },
  })

  return <div>{/* render posts */}</div>
}

HTTP Cache (Browser/CDN)

typescript
// app/api/public-data/route.ts
export async function GET() {
  const data = await getPublicData()

  return Response.json(data, {
    headers: {
      // Cache for 1 year (immutable content)
      'cache-control': 'public, max-age=31536000, immutable',
    },
  })
}

// app/api/user-data/route.ts
export async function GET(request: NextRequest) {
  const user = await getUser(request)

  return Response.json(user, {
    headers: {
      // Private, no CDN cache, browser caches for 1 minute
      'cache-control': 'private, max-age=60, must-revalidate',
    },
  })
}

Performance Measurement

Web Vitals Monitoring

typescript
// app/layout.tsx
'use client'

import { useReportWebVitals } from 'next/web-vitals'

export function WebVitals() {
  useReportWebVitals((metric) => {
    console.log(metric)

    // Send to analytics
    if (metric.label === 'web-vital') {
      fetch('/api/analytics', {
        method: 'POST',
        body: JSON.stringify(metric),
      })
    }
  })

  return null
}

NextSpeed Insights

typescript
// next.config.ts
import { withSpeedInsights } from '@vercel/speed-insights/next'

export default withSpeedInsights({
  // your config
})

Performance Checklist

See references/optimization-checklist.md for a detailed checklist.

Bundle Analysis Guide

See references/bundle-analysis.md for detailed bundle analysis instructions.

References