Images are typically 40-80% of a web page's total byte weight. Before I optimized matthewswong.com's images, the hero section was serving a 1.4MB JPEG on mobile. After converting to AVIF with Next.js's built-in optimizer, it dropped to 180KB — an 87% reduction with no visible quality loss. That single change moved my LCP from 3.1s to 1.8s on mobile. Modern image formats and lazy loading are the highest-leverage performance wins available in a Next.js project, and the tooling makes them almost free to implement.
WebP delivers 25-40% file size savings over JPEG at equivalent quality, with near-universal browser support (96%+ as of 2025). AVIF goes further — 45-65% savings over JPEG — but with slightly lower browser support (90%+ as of 2025, with Chrome, Firefox, and Safari all shipping AVIF support). Next.js handles the format decision automatically: it checks the Accept header from the browser, serves AVIF if supported, falls back to WebP, then falls back to the original format. You configure the priority order in next.config.ts and Next.js does the rest.
The next/image component intercepts image requests and runs them through Next.js's image optimization pipeline. On first request, Next.js resizes the image to the requested size, converts it to the optimal format for the requesting browser, and caches the result on disk. Subsequent requests for the same image at the same size hit the cache and return instantly. The optimization happens at runtime (not build time), which means it works with dynamic images and remote URLs. For Vercel deployments, the optimization runs on Vercel's edge network globally; for self-hosted deployments, it runs on your Node.js server.
The single most impactful change for LCP is adding the `priority` prop to your hero image. Without it, Next.js adds an Intersection Observer to lazy-load the image — which means it doesn't start downloading until the image enters the viewport. For a hero image that's visible on first load, this is catastrophic for LCP. The `priority` prop emits `fetchpriority='high'` on the img tag and removes the lazy-loading behavior, letting the browser start fetching the image immediately during HTML parse. Never lazy-load your LCP image.
Format Comparison (same image, equivalent quality):
─────────────────────────────────────────────────────
JPEG (original) 1,400 KB Baseline
WebP 860 KB -39% vs JPEG
AVIF 180 KB -87% vs JPEG ✓ Best
Browser Support (2025):
AVIF: Chrome 85+, Firefox 93+, Safari 16+ → ~90% global
WebP: Chrome 32+, Firefox 65+, Safari 14+ → ~96% global
Next.js format selection (Accept header):
Browser sends: Accept: image/avif,image/webp,*/*
Next.js serves: AVIF → WebP → original
(most compressed format the browser accepts)
next.config.ts:
images: {
formats: ['image/avif', 'image/webp'], // AVIF preferred
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
minimumCacheTTL: 2592000, // 30 days
}From my experience optimizing matthewswong.com: use `placeholder='blur'` with a `blurDataURL` on all hero and above-fold images. You can generate the blurDataURL automatically with the `plaiceholder` package at build time. This fills the space with a low-resolution, blurred preview while the full image loads, eliminating CLS from image loading and improving perceived performance significantly — especially on slow connections where the full image takes 1-2 seconds.
After iterating through several projects, I've landed on a production next.config.ts that covers the main image optimization knobs. The key settings are: formats (AVIF first, then WebP), deviceSizes (matching common breakpoints), imageSizes (for smaller thumbnails), and minimumCacheTTL (set to 30 days for static assets). I also set unoptimized: false explicitly to be clear about intent, and configure remotePatterns to only allow image optimization from trusted domains.
If you're loading images from external URLs (a CMS, S3 bucket, or CDN), you need to configure remotePatterns in next.config.ts. This is a security feature — without it, someone could construct a request that makes your server download and optimize arbitrary URLs, creating a potential SSRF vector or just wasting your compute budget. Configure the hostname and optional path pattern for each trusted source. If you're using a CMS like Contentful or Sanity, their image URLs have a consistent hostname that you add once.
// next.config.ts — production image optimization config
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days
remotePatterns: [
{
protocol: "https",
hostname: "assets.contentful.com", // your CMS
},
],
},
}
export default nextConfig
// Hero component — LCP-critical image
import Image from "next/image"
import { getPlaiceholder } from "plaiceholder" // npm i plaiceholder
// At build time, generate blur placeholder:
const { base64 } = await getPlaiceholder(heroImageBuffer)
export function HeroSection() {
return (
<Image
src="/hero.jpg"
alt="Portfolio hero — Matthews Wong"
width={1200}
height={630}
priority // fetchpriority="high" — never lazy-load LCP
placeholder="blur"
blurDataURL={base64} // low-res preview while loading
sizes="100vw" // hero spans full viewport width
/>
)
}
// Below-fold blog card image — lazy-load correctly
export function BlogCard({ image, title }: { image: string; title: string }) {
return (
<Image
src={image}
alt={title}
width={600}
height={315}
// No priority — lazy-load is correct for below-fold images
sizes="(min-width: 768px) 600px, 100vw"
/>
)
}The `sizes` prop on next/image tells the browser how wide the image will be at different viewport widths, so it can pick the right srcset entry. Without an accurate `sizes` prop, the browser defaults to 100vw — which means it downloads a full-viewport-width image even for a 300px thumbnail. For a grid of blog cards that are 300px wide on desktop but 100vw on mobile, the correct sizes string is `(min-width: 768px) 300px, 100vw`. Getting this right can reduce image payload by 30-50% for below-fold images.
Next.js's Image component does not optimize SVG or GIF files — it serves them as-is. For SVGs, this is usually correct (SVGs are already vector and often small). For GIFs, it's a problem: animated GIFs are notoriously large. If you're serving animated content, convert to WebM or AVIF video format instead and use a video element. A 5MB animated GIF can become a 300KB WebM. If you must serve GIF, at least compress it with a tool like gifsicle before placing it in /public.
Next.js's default approach is runtime optimization — images are processed on first request. This is fine for most cases, but for static exports (`output: 'export'`) Next.js cannot do runtime optimization since there's no server. In that case, you need to pre-optimize images during the build using tools like sharp, imagemin, or Squoosh. The alternative is to use a third-party image CDN (Cloudinary, Imgix, Cloudflare Images) as the optimization layer and configure Next.js to use it via the loader option.
After enabling AVIF and adding priority to the hero image on matthewswong.com, I measured the change with Lighthouse and CrUX. Lab LCP improved from 2.8s to 1.4s. Field LCP improved from 3.1s to 1.9s over the following 28 days. Total image payload on the homepage dropped from 1.8MB to 340KB. The bundle size didn't change — image optimization is handled by the server, not the JS bundle. The lesson: image optimization is the highest ROI performance work you can do on most content-heavy sites.