Next.js Image Optimization Tutorial: Build Faster React Apps with next/image
Learn Next.js image optimization with next/image: responsive sizes, AVIF/WebP, blur placeholders, CDN loaders, caching, and production checklist.
Image used for representation purposes only.
Why image optimization matters for React apps
Images are the largest, most frequent assets on modern websites. Poorly handled, they inflate page weight, delay Largest Contentful Paint (LCP), and burn bandwidth on mobile. Next.js ships with a production-grade Image component that tackles these problems with responsive sizing, modern formats (AVIF/WebP), lazy loading, caching, and placeholders—without you hand-crafting every srcset.
This tutorial shows how to use next/image effectively with both the App Router (app/) and the Pages Router (pages/), plus advanced configuration, CDN integration, and troubleshooting.
Prerequisites
- Node 18+ and a recent Next.js (13+; examples use App Router but include Pages notes)
- Basic React/TypeScript familiarity
- A running project created via:
npx create-next-app@latest my-optimized-app
cd my-optimized-app
npm run dev
The core idea: next/image at a glance
The Image component automatically:
- Generates multiple sizes and a srcset for your device breakpoints
- Serves modern formats (AVIF/WebP) when supported, with PNG/JPEG fallbacks
- Lazy-loads offscreen images
- Provides blur placeholders to prevent layout shifts
- Caches and reuses transformed variants via an optimization endpoint (or your CDN via custom loaders)
You get speed, better Core Web Vitals, and fewer manual pitfalls.
Your first optimized image (App Router)
Place an image in /public or import a local file. Then use next/image:
// app/page.tsx
import Image from 'next/image'
export default function Home() {
return (
<main style={{ maxWidth: 900, margin: '0 auto', padding: 24 }}>
<h1>Next.js Image Demo</h1>
<Image
src='/hero.jpg' // image in /public
alt='A scenic mountain view at sunrise'
width={1600}
height={900}
priority
// priority preloads the LCP image; great for above-the-fold heroes
/>
</main>
)
}
Notes for the Pages Router: identical usage in any file in pages/; for example in pages/index.tsx.
Fill layout and responsive sizing (the right way)
When you know the exact rendered width, pass width/height and let the browser downscale. When the image must fill a responsive container, use fill and sizes:
// app/page.tsx (hero banner example)
import Image from 'next/image'
export default function Home() {
return (
<section style={{ position: 'relative', width: '100%', height: '60vh' }}>
<Image
src='/hero-wide.jpg'
alt='Ocean cliff with lighthouse'
fill
// fill makes the image absolutely positioned to cover its parent
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 90vw, 1200px'
style={{ objectFit: 'cover' }}
priority
/>
<h1 style={{ position: 'absolute', bottom: 24, left: 24, color: 'white' }}>Welcome</h1>
</section>
)
}
- sizes is critical. It tells the browser how wide the image will render at different breakpoints, so it can choose the smallest appropriate candidate from srcset. Without it, the browser may over-download.
- style.objectFit replaces the older objectFit prop.
Local file imports with automatic metadata
Static imports provide width, height, and an embedded blurDataURL out of the box:
import Image from 'next/image'
import avatar from '../public/avatar.jpg' // typed metadata: width, height, blurDataURL
export default function Profile() {
return (
<Image
src={avatar}
alt='Portrait of the author smiling'
placeholder='blur' // uses the auto-generated blurDataURL
sizes='(max-width: 600px) 160px, 240px'
style={{ borderRadius: '50%' }}
/>
)
}
Benefits: you cannot forget width/height, and you get blur placeholders for free.
Remote images: allowlists and patterns
To safely optimize remote images, configure next.config.js (or .ts):
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
pathname: '/**', // allow all paths
},
],
formats: ['image/avif', 'image/webp'],
// optional tuning
deviceSizes: [320, 420, 640, 768, 1024, 1280, 1536, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 60,
},
}
module.exports = nextConfig
Then use:
<Image
src='https://images.example.com/product/1234.jpg'
alt='Lightweight running shoes in blue'
width={800}
height={800}
placeholder='blur'
blurDataURL='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...'
/>
Tip: For remote images, provide your own blurDataURL (base64 tiny preview) or use a helper like plaiceholder during build.
Modern formats and quality
- formats in config enables AVIF/WebP negotiation automatically.
- quality prop lets you fine-tune trade-offs (1–100). Defaults are reasonable; start with 75–85 and measure.
<Image
src='/gallery/shot.jpg'
alt='Street photography scene at dusk'
width={1200}
height={800}
quality={80}
sizes='(max-width: 768px) 100vw, 800px'
/>
Loading strategy and LCP
- Images load=‘lazy’ by default. Keep it for non-critical images.
- Use priority for the single LCP image above the fold. You can also set fetchPriority=‘high’ explicitly for that hero.
<Image
src='/hero.jpg'
alt='Hero cityscape at sunrise'
width={1600}
height={900}
priority
fetchPriority='high'
/>
Prevent layout shift (CLS)
- Always provide width/height (or use fill with a fixed-height container) so the browser reserves space.
- Use placeholders for perceived stability: placeholder=‘blur’.
Accessibility essentials
- Write meaningful alt text. If an image is decorative, pass alt=’’ and include role=‘presentation’.
- Never stuff keywords into alt; describe the content and intent succinctly.
CDN and custom loaders (Imgix, Cloudinary, Akamai)
If you already use an image CDN, wire a custom loader so the CDN handles transforms, caching, and delivery close to users. When a loader is provided, Next.js defers to it and skips the built-in optimization server.
// lib/cloudinary-loader.ts
export function cloudinaryLoader({ src, width, quality }: { src: string; width: number; quality?: number }) {
const q = quality || 'auto'
return `https://res.cloudinary.com/demo/image/upload/f_auto,q_${q},w_${width}/${src.replace(/^\//, '')}`
}
// usage
import Image from 'next/image'
import { cloudinaryLoader } from '@/lib/cloudinary-loader'
export default function ProductCard() {
return (
<Image
loader={cloudinaryLoader}
src='/products/shoe-123.jpg'
alt='Blue running shoe, quarter view'
width={800}
height={600}
sizes='(max-width: 768px) 100vw, 400px'
/>
)
}
When using a custom loader, confirm URLs return optimized variants (format/quality/size) and set long cache headers at your CDN.
Background images and art direction
The Image component optimizes content images. For CSS backgrounds that require art direction or gradients, consider:
- Converting background images to foreground
elements when possible (improves LCP/CLS and allows formats/srcset). - If you must keep a CSS background, generate multiple sizes server-side and swap via media queries.
// Using <Image> to simulate a 'background'
<section style={{ position: 'relative', height: '50vh' }}>
<Image src='/poster.jpg' alt='' role='presentation' fill sizes='100vw' style={{ objectFit: 'cover' }} />
<div style={{ position: 'relative', zIndex: 1, padding: 24 }}>Call to Action</div>
</section>
Static export caveat
If you build a fully static site (output: ’export’), the built-in optimization endpoint is unavailable. Options:
- Set images.unoptimized = true to serve images as-is (no dynamic resizing)
- Or use a custom loader/CDN so optimization happens off-platform
// next.config.js (static export)
const nextConfig = {
output: 'export',
images: {
unoptimized: true,
},
}
module.exports = nextConfig
Measuring success (Lighthouse and Web Vitals)
- Run Lighthouse in Chrome DevTools. Look for the audits:
- Properly size images
- Efficiently encode images
- Serve images in next-gen formats
- Track Core Web Vitals (LCP, CLS, INP). Your hero image should become the LCP element; ensure it’s prioritized and not blocked by render.
- In DevTools Network tab, verify the chosen candidate (e.g., 800w WebP) matches the current viewport and DPR.
Production caching details
- Each unique (src, width, quality, format) combination is cached after the first request.
- Use a CDN (e.g., Vercel’s edge network) to cache variants at the edge. Set minimumCacheTTL in config to hint revalidation.
- Don’t append cache-busting query strings unless necessary; they create new variants.
Common pitfalls and fixes
- Missing sizes with fill: always provide a sizes string so the browser picks the right candidate.
- Oversized width/height: if the rendered box is 400px wide, don’t request 1600px unless needed for 4x DPR art.
- Decorative images with alt text: use alt=’’ for purely decorative images.
- Legacy props (layout, objectFit/objectPosition): use fill and style instead.
- Next/Image inside display:none containers: lazy loading may never trigger; ensure visibility or use priority where appropriate.
- Using large inline SVGs as images: prefer inline
Putting it all together: a responsive gallery
// app/gallery/page.tsx
import Image from 'next/image'
const photos = [
{ src: '/gallery/1.jpg', alt: 'Rocky shore at golden hour', w: 1200, h: 800 },
{ src: '/gallery/2.jpg', alt: 'Forest trail with morning fog', w: 1200, h: 800 },
{ src: '/gallery/3.jpg', alt: 'Snow-capped peak under stars', w: 1200, h: 800 },
]
export default function Gallery() {
return (
<main style={{ maxWidth: 1200, margin: '0 auto', padding: 24 }}>
<h1>Gallery</h1>
<ul style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: 16, listStyle: 'none', padding: 0, margin: 0 }}>
{photos.map((p) => (
<li key={p.src}>
<Image
src={p.src}
alt={p.alt}
width={p.w}
height={p.h}
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
placeholder='blur'
// If imported statically, the blur is automatic
/>
</li>
))}
</ul>
</main>
)
}
Checklist for production
- Critical hero image uses priority and correct sizes
- All images have width/height or fill within a sized container
- Remote hosts are configured in images.remotePatterns
- Placeholders used where appropriate (blur preferred)
- Lighthouse: next-gen formats, properly sized images passes
- CDN cache is warm for common variants; no unnecessary cache-busting
Conclusion
Next.js makes image optimization straightforward, but details matter. Provide correct sizes, use priority for the LCP image, prefer static imports for built-in blur placeholders, and lean on an image CDN via custom loaders when you need advanced transforms and global caching. Measure with Lighthouse and Web Vitals, iterate, and enjoy a faster, more resilient React app.
Related Posts
React Form Libraries Comparison (2026): The Practical Buyer’s Guide
React form libraries in 2026 compared: React Hook Form, Formik, React Final Form, TanStack Form, RJSF/Uniforms/JSON Forms, and Conform with Server Actions.
Building Performant Infinite Scroll in React: Patterns, Pitfalls, and Production Tips
Build performant React infinite scroll using IntersectionObserver, React Query, and virtualization, with production tips and code examples.
React Jotai: A Practical Guide to Atomic State Management
A practical guide to React Jotai atomic state: concepts, patterns, async atoms, performance, TypeScript, SSR, testing, and pitfalls.