# Image Optimization

> Image optimization strategies: next/image, WebP/AVIF conversion, CDN serving, responsive images, and lazy loading. Triggers on: next/image, Image optimization, WebP, AVIF, blur placeholder, srcSet, image CDN, sharp, LCP image.

- Skill: `ultroncore/image-optimization` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add ultroncore/image-optimization`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ultroncore/image-optimization/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: UltronCore (https://skillmd.com/u/ultroncore)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ultroncore/image-optimization

---


# Image Optimization

## When to Use
Use when LCP is slow, images are too large, or you're setting up a CDN pipeline. Critical for Core Web Vitals.

---

## Core Rules
- LCP image must have `priority` — never lazy-load the above-the-fold image
- Always set `sizes` to match your CSS layout — default `100vw` causes over-fetching
- Use `fill` + wrapper div for unknown dimensions (e.g. CMS images)
- WebP is the safe default; AVIF gives ~20% better compression but slower encoding
- Blur placeholder (`placeholder="blur"`) requires `blurDataURL` for external images
- Never use `<img>` for content images in Next.js — use `<Image />`

---

## next/image — Core Props

```tsx
import Image from 'next/image';

// Fixed size (known dimensions)
<Image
  src="/hero.jpg"
  alt="Hero banner showing product features"  // meaningful alt text always
  width={1200}
  height={600}
  priority             // LCP image — preload, no lazy load
  quality={85}         // default 75; 80-85 is usually the sweet spot
/>

// Responsive (full width, proportional height)
<Image
  src="/banner.jpg"
  alt="Banner"
  width={1200}
  height={600}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  // ^ Tells browser: mobile=full width, tablet=half, desktop=third
  style={{ width: '100%', height: 'auto' }}
/>

// Fill container (parent must have position: relative and defined size)
<div style={{ position: 'relative', width: '100%', height: '400px' }}>
  <Image
    src="/cover.jpg"
    alt="Cover"
    fill
    sizes="(max-width: 768px) 100vw, 50vw"
    style={{ objectFit: 'cover' }}    // or 'contain'
  />
</div>

// Lazy load (default — no changes needed)
<Image
  src="/below-fold.jpg"
  alt="Below fold image"
  width={800}
  height={400}
  // loading="lazy" is the default — omit priority for lazy
/>

// Blur placeholder
<Image
  src="/photo.jpg"
  alt="Photo"
  width={800}
  height={600}
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
  // blurDataURL for local images: auto-generated by Next.js at build time
  // blurDataURL for external images: generate manually (see below)
/>
```

---

## sizes Attribute Guide

The `sizes` attribute tells the browser which rendered width to use when picking from `srcSet`. Wrong values = over-fetching.

```tsx
// Layout examples with correct sizes

// Full-width hero
sizes="100vw"

// Sidebar layout: 2/3 content, 1/3 sidebar
sizes="(max-width: 768px) 100vw, 66vw"

// 3-column grid
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"

// Fixed max-width container (e.g. container at max-w-4xl = 896px)
sizes="(max-width: 896px) 100vw, 896px"

// Card in a 4-column grid with 16px gap
sizes="(max-width: 640px) calc(100vw - 32px), (max-width: 1024px) calc(50vw - 24px), calc(25vw - 28px)"
```

---

## External Image Domains

```javascript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'res.cloudinary.com',
        pathname: '/my-account/**',
      },
      {
        protocol: 'https',
        hostname: '**.supabase.co',
        pathname: '/storage/v1/object/public/**',
      },
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
      },
    ],
    // Custom loader for image CDN (bypasses next/image optimization)
    // loader: 'custom',
    // loaderFile: './lib/image-loader.js',
  },
};
```

---

## Blur Placeholder Generation

```typescript
// Generate blurDataURL for external images
// Install: npm install sharp

import sharp from 'sharp';

async function getBlurDataUrl(imageUrl: string): Promise<string> {
  const response = await fetch(imageUrl);
  const buffer = Buffer.from(await response.arrayBuffer());

  const resized = await sharp(buffer)
    .resize(10, 10, { fit: 'inside' })  // tiny 10x10 pixel image
    .toBuffer();

  const base64 = resized.toString('base64');
  const mimeType = 'image/jpeg';
  return `data:${mimeType};base64,${base64}`;
}

// For static data in getStaticProps / generateStaticParams
export async function generateStaticParams() {
  const posts = await getPosts();
  return Promise.all(
    posts.map(async (post) => ({
      ...post,
      blurDataURL: post.coverImage ? await getBlurDataUrl(post.coverImage) : undefined,
    }))
  );
}
```

---

## Image CDN Integration

### Cloudflare Images
```typescript
// lib/image-loader.ts (custom Next.js image loader)
export default function cloudflareLoader({
  src,
  width,
  quality,
}: {
  src: string;
  width: number;
  quality?: number;
}) {
  // Cloudflare Images URL format
  const url = new URL(src, 'https://imagedelivery.net');
  url.pathname = `/account-hash/${src.replace(/^\//, '')}/w=${width},q=${quality ?? 75}`;
  return url.toString();
}

// Usage in component
<Image
  src="image-id-from-cloudflare"
  width={800}
  height={600}
  alt="..."
/>
```

### Imgix
```typescript
// lib/imgix-loader.ts
import ImgixClient from '@imgix/js-core';

const client = new ImgixClient({
  domain: 'my-account.imgix.net',
  secureURLToken: process.env.IMGIX_TOKEN,
});

export default function imgixLoader({ src, width, quality }: ImageLoaderProps) {
  return client.buildURL(src, {
    w: width,
    q: quality ?? 75,
    auto: 'compress,format', // auto WebP/AVIF
    fit: 'max',
  });
}
```

### Supabase Storage
```typescript
// Supabase Storage has built-in image transformation
import { supabase } from '@/lib/supabase';

function getOptimizedImageUrl(path: string, width: number, height: number) {
  const { data } = supabase.storage
    .from('images')
    .getPublicUrl(path, {
      transform: {
        width,
        height,
        resize: 'cover',
        format: 'webp',
        quality: 80,
      },
    });
  return data.publicUrl;
}
```

---

## WebP / AVIF Conversion with sharp CLI

```bash
# Install sharp CLI
npm install -g sharp-cli

# Convert single file
sharp -i input.jpg -o output.webp --webp-quality 80
sharp -i input.jpg -o output.avif --avif-quality 60

# Batch convert directory
for f in public/images/*.jpg; do
  sharp -i "$f" -o "${f%.jpg}.webp" --webp-quality 80
done

# Resize + convert
sharp -i hero.jpg -o hero-800.webp --resize 800 --webp-quality 80
```

```typescript
// sharp in Node.js / Bun scripts
import sharp from 'sharp';
import { readdir } from 'fs/promises';
import path from 'path';

const INPUT_DIR = './public/images/raw';
const OUTPUT_DIR = './public/images/optimized';

const files = await readdir(INPUT_DIR);

await Promise.all(
  files
    .filter((f) => /\.(jpg|jpeg|png)$/i.test(f))
    .map(async (file) => {
      const name = path.parse(file).name;
      const input = path.join(INPUT_DIR, file);

      await sharp(input)
        .resize({ width: 1200, withoutEnlargement: true })
        .webp({ quality: 80 })
        .toFile(path.join(OUTPUT_DIR, `${name}.webp`));

      await sharp(input)
        .resize({ width: 1200, withoutEnlargement: true })
        .avif({ quality: 60 })
        .toFile(path.join(OUTPUT_DIR, `${name}.avif`));

      console.log(`Processed: ${file}`);
    })
);
```

---

## Lazy Loading — Native vs Intersection Observer

```tsx
// Next.js Image — lazy by default (no code needed)
<Image src="/..." alt="..." width={800} height={600} />
// loading="lazy" is implicit when priority is not set

// Native HTML img lazy loading (non-Next.js contexts)
<img src="/image.jpg" loading="lazy" decoding="async" alt="..." />

// Intersection Observer (for custom lazy loading or background images)
import { useRef, useEffect, useState } from 'react';

function useLazyLoad() {
  const ref = useRef<HTMLDivElement>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect(); // load once, then stop observing
        }
      },
      { rootMargin: '200px' } // start loading 200px before visible
    );

    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);

  return { ref, isVisible };
}

// Usage
function LazySection() {
  const { ref, isVisible } = useLazyLoad();
  return (
    <div ref={ref}>
      {isVisible && <HeavyComponent />}
    </div>
  );
}
```

---

## LCP Optimization Checklist

```tsx
// ✓ 1. Mark LCP image with priority
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority />

// ✓ 2. Correct sizes for your layout
<Image ... sizes="(max-width: 768px) 100vw, 50vw" />

// ✓ 3. Serve from CDN (not same-origin)
// Use Cloudflare, Vercel Edge Network, or Imgix

// ✓ 4. Preload in HTML <head> (for images not using next/image)
// Next.js does this automatically when priority is set

// ✓ 5. Use modern format
// next/image serves WebP/AVIF automatically
// For <img>: use <picture> with source fallbacks

// ✓ 6. Don't CLS — always set width/height or aspect-ratio
<Image width={1200} height={600} ... />     // explicit
<div style={{ aspectRatio: '16/9' }}> ... </div>  // CSS aspect-ratio
```

---

## Quick Reference

| Task | Solution |
|---|---|
| LCP image | `priority` prop |
| Responsive image | `sizes` + `style={{ width: '100%', height: 'auto' }}` |
| Fill container | `fill` + parent `position: relative` |
| External domain | `remotePatterns` in next.config.js |
| Blur placeholder | `placeholder="blur"` + `blurDataURL` |
| WebP conversion | `sharp` package or CLI |
| Image CDN | Custom loader in next.config.js |
| Lazy load | Default behavior (no `priority`) |
| Intersection Observer | Custom hook for non-image lazy loading |
| Supabase images | Storage transform API (width/height/format) |

## Related Skills
- `core-web-vitals` — performance impact
- `nextjs-performance` — Next.js image optimization
- `ffmpeg-media` — media processing

## GitNexus Index
This skill is indexed by GitNexus for knowledge graph traversal.
Index path: /Users/localuser/.claude/skills/image-optimization/.gitnexus
Last indexed: 2026-05-23

