Image Optimization Assistant
When to use this skill
-
User asks about image optimization
-
User mentions WebP, AVIF, or modern formats
-
User wants responsive images or srcset
-
User asks about Next.js Image or picture element
-
User wants to reduce page weight from images
Workflow
-
Audit current images
-
Identify optimization opportunities
-
Convert to modern formats
-
Generate responsive markup
-
Implement lazy loading
-
Validate improvements
Instructions
Step 1: Audit Current Images
Find large images:
find public -type f ( -name ".jpg" -o -name ".png" -o -name "*.gif" ) -size +100k -exec ls -lh {} ;
Check image dimensions:
Requires ImageMagick
find public -type f ( -name ".jpg" -o -name ".png" ) -exec identify -format "%f: %wx%h (%b)\n" {} ;
Detect unoptimized in HTML:
grep -rn --include=".tsx" --include=".html" '<img' src/ | grep -v 'loading='
Step 2: Format Selection
Format Use Case Browser Support
WebP Photos, general use 97%+
AVIF Best compression, photos 92%+
PNG Transparency, icons 100%
SVG Icons, logos, illustrations 100%
JPEG Legacy fallback 100%
Compression comparison (typical):
Original WebP AVIF
500KB JPEG ~300KB (-40%) ~200KB (-60%)
200KB PNG ~80KB (-60%) ~50KB (-75%)
Step 3: Convert Images
Using Sharp (Node.js):
npm install sharp
// scripts/optimize-images.ts import sharp from "sharp"; import { glob } from "glob"; import path from "path";
const QUALITY = { webp: 80, avif: 65 }; const SIZES = [640, 750, 828, 1080, 1200, 1920];
async function optimizeImage(inputPath: string): Promise<void> { const dir = path.dirname(inputPath); const name = path.basename(inputPath, path.extname(inputPath));
for (const width of SIZES) { const image = sharp(inputPath).resize(width);
// WebP
await image
.webp({ quality: QUALITY.webp })
.toFile(path.join(dir, `${name}-${width}.webp`));
// AVIF
await image
.avif({ quality: QUALITY.avif })
.toFile(path.join(dir, `${name}-${width}.avif`));
}
console.log(Optimized: ${inputPath});
}
async function main() { const images = await glob("public/images/**/*.{jpg,jpeg,png}"); await Promise.all(images.map(optimizeImage)); }
main();
Using CLI tools:
WebP conversion
npx @aspect/image-optimize --format webp --quality 80 public/images/*.jpg
Using cwebp directly
cwebp -q 80 input.jpg -o output.webp
AVIF with avif-cli
npx avif --input public/images/*.jpg --quality 65
Step 4: Responsive Image Markup
HTML picture element:
<picture> <!-- AVIF for browsers that support it --> <source type="image/avif" srcset=" /images/hero-640.avif 640w, /images/hero-1080.avif 1080w, /images/hero-1920.avif 1920w " sizes="(max-width: 768px) 100vw, 50vw" /> <!-- WebP fallback --> <source type="image/webp" srcset=" /images/hero-640.webp 640w, /images/hero-1080.webp 1080w, /images/hero-1920.webp 1920w " sizes="(max-width: 768px) 100vw, 50vw" /> <!-- JPEG fallback for old browsers --> <img src="/images/hero-1080.jpg" alt="Hero image description" width="1920" height="1080" loading="lazy" decoding="async" /> </picture>
Sizes attribute guide:
Layout Sizes Value
Full width 100vw
Half width on desktop (min-width: 768px) 50vw, 100vw
Fixed width 300px
Grid (3 columns) (min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw
Step 5: Framework Integration
Next.js Image:
import Image from 'next/image';
// Basic usage - automatic optimization <Image src="/images/hero.jpg" alt="Hero description" width={1920} height={1080} priority // For above-the-fold images />
// Fill container <div className="relative h-64"> <Image src="/images/hero.jpg" alt="Hero description" fill className="object-cover" sizes="(max-width: 768px) 100vw, 50vw" /> </div>
// With placeholder blur import heroImage from '@/public/images/hero.jpg';
<Image src={heroImage} alt="Hero description" placeholder="blur" priority />
next.config.js for external images:
module.exports = { images: { remotePatterns: [ { protocol: "https", hostname: "cdn.example.com", }, ], formats: ["image/avif", "image/webp"], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], }, };
Nuxt Image:
<template> <NuxtImg src="/images/hero.jpg" alt="Hero description" width="1920" height="1080" format="webp" loading="lazy" sizes="sm:100vw md:50vw lg:33vw" />
<!-- With picture for art direction --> <NuxtPicture src="/images/hero.jpg" alt="Hero description" sizes="sm:100vw md:50vw" :imgAttrs="{ class: 'rounded-lg' }" /> </template>
// nuxt.config.ts export default defineNuxtConfig({ modules: ["@nuxt/image"], image: { format: ["avif", "webp"], screens: { sm: 640, md: 768, lg: 1024, xl: 1280, }, }, });
Step 6: Lazy Loading
Native lazy loading:
<img src="/image.jpg" alt="Description" loading="lazy" decoding="async" />
Intersection Observer (custom):
// hooks/useLazyImage.ts import { useEffect, useRef, useState } from "react";
export function useLazyImage(src: string) { const [isLoaded, setIsLoaded] = useState(false); const [isInView, setIsInView] = useState(false); const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setIsInView(true); observer.disconnect(); } }, { rootMargin: "50px" }, );
if (imgRef.current) observer.observe(imgRef.current);
return () => observer.disconnect();
}, []);
return { imgRef, isInView, isLoaded, setIsLoaded }; }
Above-the-fold images (don't lazy load):
// Hero images, LCP candidates <Image src="/hero.jpg" alt="Hero" priority />
// Or in HTML <img src="/hero.jpg" alt="Hero" fetchpriority="high" />
Step 7: Responsive Breakpoints
Calculate optimal breakpoints:
// scripts/calculate-breakpoints.ts const VIEWPORT_WIDTHS = [320, 375, 414, 768, 1024, 1280, 1440, 1920]; const DEVICE_PIXEL_RATIOS = [1, 2, 3];
function calculateBreakpoints( imageWidth: number, containerRatio: number = 1, // 1 = full width, 0.5 = half width ): number[] { const breakpoints = new Set<number>();
for (const viewport of VIEWPORT_WIDTHS) { for (const dpr of DEVICE_PIXEL_RATIOS) { const width = Math.round(viewport * containerRatio * dpr); if (width <= imageWidth) { breakpoints.add(width); } } }
return Array.from(breakpoints).sort((a, b) => a - b); }
// Example: 1920px image at half viewport console.log(calculateBreakpoints(1920, 0.5)); // [160, 188, 207, 320, 375, 384, 414, 512, 621, 640, 768, 960]
Step 8: Build Pipeline Integration
Vite plugin:
// vite.config.ts import { imagetools } from "vite-imagetools";
export default { plugins: [ imagetools({ defaultDirectives: new URLSearchParams({ format: "webp;avif;jpg", w: "640;1280;1920", quality: "80", }), }), ], };
// Usage with query params import heroSrcset from "./hero.jpg?w=640;1280;1920&format=webp&as=srcset"; import heroAvif from "./hero.jpg?w=1280&format=avif";
GitHub Action for optimization:
name: Optimize Images
on: pull_request: paths: - "public/images/**"
jobs: optimize: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Optimize images
uses: calibreapp/image-actions@main
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
jpegQuality: "80"
webpQuality: "80"
Optimization Checklist
Check Target
Format WebP/AVIF with JPEG fallback
Dimensions No larger than 2x display size
File size < 200KB for hero, < 100KB for cards
Lazy loading All images below fold
Explicit dimensions width/height on all images
Alt text Descriptive on all images
Validation
Before completing:
-
Images converted to WebP/AVIF
-
Responsive srcset generated
-
Lazy loading on below-fold images
-
Priority on LCP image
-
Width/height prevent layout shift
-
Total image weight reduced
Check image sizes
npx @unlighthouse/cli --site http://localhost:3000
Lighthouse
npx lighthouse http://localhost:3000 --only-categories=performance
Error Handling
-
Sharp installation fails: Install build tools; use prebuilt binaries.
-
AVIF encoding slow: Use lower effort setting or limit to key images.
-
CDN not serving modern formats: Check Accept header handling and cache config.
-
Layout shift on load: Always include width/height or aspect-ratio.
Resources
-
Squoosh - Browser-based comparison
-
Sharp Documentation
-
Next.js Image Optimization
-
web.dev Image Optimization