- name
- performance-optimization
- description
- Next.js 16 performance — next/image and next/font setup, dynamic imports, bundle analysis, the React Compiler (stable in 16, experimental Rust compiler in 16.3), prefetching and Partial Prefetching, third-party scripts and Core Web Vitals (LCP, INP, CLS). Use when a page is slow or heavy. Caching strategy belongs to data-fetching.
# Performance Optimization
Next.js includes built-in optimizations for images, fonts, scripts, and rendering. Apply these patterns to achieve excellent Core Web Vitals scores.
## Image Optimization with `next/image`
The `<Image>` component automatically optimizes images with lazy loading, responsive sizing, and modern formats (WebP/AVIF).
### Basic Usage
```tsx
import Image from 'next/image'
// Local image (auto width/height detection)
import heroImage from '@/public/hero.jpg'
export default function Hero() {
return (
<Image
src={heroImage}
alt="Hero banner"
placeholder="blur" // Auto blur placeholder for local images
priority // Preload — use for above-the-fold images
/>
)
}
```
### Remote Images
```tsx
<Image
src="https://images.example.com/photo.jpg"
alt="Photo"
width={800}
height={600}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
```
Configure allowed remote sources in `next.config.ts`:
```ts
const nextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'images.example.com' },
],
},
}
```
### Image Best Practices
| Practice | Why |
|----------|-----|
| Always set `priority` on the LCP image | Eliminates lazy-load delay for the largest visible image |
| Use `sizes` prop for responsive images | Tells the browser which size to download at each breakpoint |
| Use `placeholder="blur"` for local images | Shows a low-quality preview while loading |
| Use `fill` prop for unknown dimensions | Image fills its parent container (set `position: relative` on parent) |
| Avoid layout shift | Always provide `width`/`height` or use `fill` |
## Font Optimization with `next/font`
`next/font` self-hosts fonts with zero layout shift. Fonts are loaded at build time and served from the same domain.
### Google Fonts
```tsx
import { Inter, JetBrains_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-sans',
})
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-mono',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<body className="font-sans">{children}</body>
</html>
)
}
```
### Tailwind CSS Integration
Tailwind v4 (the create-next-app default) uses CSS-first configuration — no `tailwind.config.ts` needed for font variables:
```css
/* globals.css */
@import "tailwindcss";
@theme inline {
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
}
```
## Dynamic Imports & Code Splitting
### Component-level Code Splitting
```tsx
import dynamic from 'next/dynamic'
// Lazy load heavy components
const Chart = dynamic(() => import('@/components/chart'), {
loading: () => <p>Loading chart...</p>,
})
// Skip SSR for browser-only components
const Map = dynamic(() => import('@/components/map'), {
ssr: false,
loading: () => <div className="h-96 bg-gray-100 animate-pulse" />,
})
```
### When to Use Dynamic Imports
| Scenario | Approach |
|----------|----------|
| Heavy library (chart, editor, map) | `dynamic(() => import(...))` |
| Browser-only library (uses `window`) | `dynamic(() => import(...), { ssr: false })` |
| Modal or dialog content | `dynamic(() => import(...))` — loads on demand |
| Above-the-fold content | Do NOT dynamic import — use regular import |
### Library-level Code Splitting
```tsx
// Only import what you need
import { format } from 'date-fns' // Tree-shakeable
import { debounce } from 'lodash-es' // ES module version
```
Avoid importing entire libraries (`import _ from 'lodash'`). Use tree-shakeable ES module versions.
## Bundle Size Optimization
### Analyze the Bundle
```bash
# Turbopack bundle analyzer (16.1+): interactive treemap on port 4000
next experimental-analyze
# Write static analysis files instead
next experimental-analyze --output
```
The analyzer filters by route, shows import chains, and provides separate client/server views. The older `@next/bundle-analyzer` (`ANALYZE=true`) is webpack-based and only works with `next build --webpack`.
### Common Bundle Bloaters
| Issue | Fix |
|-------|-----|
| Large icon library | Import individual icons: `import { Search } from 'lucide-react'` |
| Full lodash import | Use `lodash-es` or individual imports: `import debounce from 'lodash/debounce'` |
| Moment.js | Replace with `date-fns` or `dayjs` |
| CSS-in-JS runtime | Use Tailwind CSS or CSS Modules (zero runtime) |
| Heavy markdown parser | Use `next-mdx-remote` or dynamic import |
### Keep Client Components Small
Every component marked `'use client'` adds to the client JavaScript bundle. Strategies:
- Extract only the interactive part into a Client Component
- Keep data fetching and static rendering in Server Components
- Use composition (pass Server Components as `children` to Client Components)
## React Compiler
Automatic memoization without manual `useMemo`/`useCallback`. Stable since Next.js 16, but not enabled by default:
```ts
// next.config.ts
const nextConfig = {
reactCompiler: true, // requires: npm install babel-plugin-react-compiler@latest
}
```
The Babel-based compiler increases build time. Next.js 16.3 adds an **experimental** Rust-based compiler that runs inside Turbopack (34-46% faster dev-to-ready-page vs Babel). It is opt-in and released to gather feedback — do not roll it out to production without testing:
```ts
const nextConfig = {
reactCompiler: true, // still required — the flag below only picks the implementation
experimental: { turbopackRustReactCompiler: true }, // 16.3+, Turbopack only
}
```
Constraints: it requires `reactCompiler: true`, works only with Turbopack (using it with `--webpack` throws an error), and `babel-plugin-react-compiler` is not needed.
`create-next-app` offers a `--react-compiler` flag for new projects.
## Core Web Vitals
### LCP (Largest Contentful Paint)
- Set `priority` on the hero/banner image
- Preload critical fonts with `next/font`
- Avoid layout shifts that delay the largest element
- Use `<Suspense>` to stream non-critical content
### FID/INP (First Input Delay / Interaction to Next Paint)
- Minimize client-side JavaScript
- Use Server Components for non-interactive UI
- Defer heavy computations with `requestIdleCallback` or Web Workers
- Avoid blocking the main thread during hydration
### CLS (Cumulative Layout Shift)
- Always specify image dimensions (`width`/`height` or `fill`)
- Use `next/font` to prevent font-swap layout shifts
- Reserve space for dynamic content with CSS `min-height`
- Avoid injecting content above existing content
## Rendering Strategy Selection
| Content Type | Strategy | Implementation |
|-------------|----------|----------------|
| Marketing pages | Static (SSG) | Default (no dynamic data) |
| Blog posts | ISR | `export const revalidate = 3600` — only without `cacheComponents`; with it: `'use cache'` + `cacheLife('hours')` |
| Product pages | ISR | `export const revalidate = 60` + `revalidateTag` on update — only without `cacheComponents`; with it: `'use cache'` + `cacheLife('minutes')` + `cacheTag()` |
| User dashboard | Dynamic + Streaming | `<Suspense>` boundaries |
| Search results | Dynamic | Access `searchParams` |
| Authenticated pages | Dynamic | Access `cookies()` |
The two ISR rows use the previous model (route segment config). With `cacheComponents: true` the `revalidate`, `dynamic`, `dynamicParams` and `fetchCache` exports are removed and the lifetime moves into the cached function — see the migration table in the `data-fetching` skill's `references/cache-components.md`.
## Script Optimization
```tsx
import Script from 'next/script'
// Analytics — load after page is interactive
<Script src="https://analytics.example.com/script.js" strategy="afterInteractive" />
// Non-critical widget — load when browser is idle
<Script src="https://widget.example.com/embed.js" strategy="lazyOnload" />
// Critical third-party — load before page hydration (rare)
<Script src="https://critical.example.com/script.js" strategy="beforeInteractive" />
```
## Prefetching
Next.js automatically prefetches `<Link>` destinations when they appear in the viewport. Control this behavior:
```tsx
import Link from 'next/link'
// Prefetch enabled (default)
<Link href="/dashboard">Dashboard</Link>
// Disable prefetch for rarely visited pages
<Link href="/terms" prefetch={false}>Terms</Link>
```
Next.js 16 rewrote prefetching: layout deduplication, incremental prefetching, cancellation when links leave the viewport, and hover prioritization all happen automatically. 16.3 can inline small prefetch payloads into the page (`prefetchInlining`), and `<Link prefetch={true}>` participates in Partial Prefetching when `cacheComponents` + `partialPrefetching` are enabled (see the `data-fetching` skill's Cache Components reference).
With Partial Prefetching, per-link prefetching is the knob for a few high-intent links: `<Link href="/search?q=next" prefetch={true}>` resolves the cached content that depends on that URL before the click, while a plain `<Link>` prefetches only the shared App Shell. Do not put `prefetch={true}` on every card in a large grid — each visible link can trigger a server request; prefer hover-triggered prefetch there. A segment can cap the cost with `export const prefetch = 'partial'` or `'force-disabled'` (requires `cacheComponents`).
## View Transitions
React's `<ViewTransition>` is stable as of React 19.3 (2026-09-09) and works in the App Router with no configuration (the App Router already ships the React features it needs). Route navigations are transitions, so wrapping the same-named element on both pages morphs it across the navigation:
```tsx
import { ViewTransition } from 'react'
import Image from 'next/image'
// On both the list page (thumbnail) and the detail page (hero), with the same name:
<ViewTransition name={`photo-${photo.id}`}>
<Image src={photo.src} alt={photo.alt} width={400} height={300} />
</ViewTransition>
```
Animations are triggered by Transitions, `<Suspense>` reveals and `useDeferredValue` — a plain `setState` does not start one. Browsers without the View Transitions API simply skip the animation. Guide: [Designing view transitions](https://nextjs.org/docs/app/guides/view-transitions).
## Production Checklist
- [ ] Hero image has `priority` prop
- [ ] All images have `width`/`height` or `fill`
- [ ] Fonts use `next/font` (no external stylesheet links)
- [ ] Heavy components use `dynamic()` import
- [ ] `'use client'` only on components that need interactivity
- [ ] Bundle analyzed with `next experimental-analyze`
- [ ] No unused dependencies in `package.json`
- [ ] `output: 'standalone'` for Docker deployments
- [ ] Static pages use ISR where appropriate
- [ ] Third-party scripts use appropriate `strategy`
View on GitHub