Files
Portfolio/blog-posts/77-image-optimization-web.md
T
damjan_savicandClaude Opus 4.5 43484c5023 Add blog posts, cleanup unused files, update components
- Add 100 blog posts covering AI, development, and tech topics
- Add .env.example for environment configuration
- Add accessibility and lighthouse audit scripts
- Remove obsolete SEO reports and temporary files
- Remove dev-dist build artifacts and backup files
- Remove unused portrait images (moved/consolidated elsewhere)
- Update contact form and component improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 11:42:11 +01:00

20 KiB

Image Optimization für Web

Meta-Description: Bilder für Web optimieren. Next.js Image, AVIF/WebP, Responsive Images und Lazy Loading für beste Performance.

Keywords: Image Optimization, WebP, AVIF, Next.js Image, Responsive Images, Lazy Loading, srcset, Performance


Einführung

Bilder machen oft 50-70% der Seitengröße aus. Mit Next.js Image Component, modernen Formaten (AVIF, WebP) und Responsive Images kann die Ladezeit drastisch reduziert werden. Dieser Guide zeigt Best Practices für optimale Bildperformance.


Image Optimization Overview

┌─────────────────────────────────────────────────────────────┐
│              IMAGE OPTIMIZATION PIPELINE                     │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Format Comparison:                                         │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Original PNG (1200x800):     2.4 MB                │   │
│  │  ├── JPEG (quality 80):       180 KB  (-92%)        │   │
│  │  ├── WebP (quality 80):       120 KB  (-95%)        │   │
│  │  └── AVIF (quality 70):       80 KB   (-97%)        │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  Browser Support (2026):                                    │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  AVIF:  Chrome ✓  Firefox ✓  Safari ✓  Edge ✓      │   │
│  │  WebP:  Chrome ✓  Firefox ✓  Safari ✓  Edge ✓      │   │
│  │  JPEG:  Universal ✓                                 │   │
│  │  PNG:   Universal ✓                                 │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  Responsive Strategy:                                       │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Mobile (< 640px):    640w   - 40KB                │   │
│  │  Tablet (< 1024px):   1024w  - 80KB                │   │
│  │  Desktop (< 1920px):  1920w  - 150KB               │   │
│  │  Retina (2x):         2x scaling                    │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  Loading Strategies:                                        │
│  ├── eager:  Above-the-fold, LCP images                   │
│  ├── lazy:   Below-the-fold, defer loading                │
│  └── blur:   Placeholder während Loading                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Next.js Image Component

// components/OptimizedImage.tsx
import Image from 'next/image';

interface OptimizedImageProps {
  src: string;
  alt: string;
  width?: number;
  height?: number;
  priority?: boolean;
  fill?: boolean;
  sizes?: string;
  className?: string;
  quality?: number;
}

export function OptimizedImage({
  src,
  alt,
  width,
  height,
  priority = false,
  fill = false,
  sizes,
  className,
  quality = 80
}: OptimizedImageProps) {
  // Blur Placeholder generieren (optional)
  const blurDataURL = `data:image/svg+xml;base64,${toBase64(shimmer(width || 700, height || 475))}`;

  return (
    <Image
      src={src}
      alt={alt}
      width={fill ? undefined : width}
      height={fill ? undefined : height}
      fill={fill}
      priority={priority}
      quality={quality}
      sizes={sizes || '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'}
      className={className}
      placeholder="blur"
      blurDataURL={blurDataURL}
      style={fill ? { objectFit: 'cover' } : undefined}
    />
  );
}

// Shimmer Placeholder Generator
const shimmer = (w: number, h: number) => `
<svg width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <linearGradient id="g">
      <stop stop-color="#f6f7f8" offset="0%"/>
      <stop stop-color="#edeef1" offset="20%"/>
      <stop stop-color="#f6f7f8" offset="40%"/>
      <stop stop-color="#f6f7f8" offset="100%"/>
    </linearGradient>
  </defs>
  <rect width="${w}" height="${h}" fill="#f6f7f8"/>
  <rect width="${w}" height="${h}" fill="url(#g)">
    <animate attributeName="x" from="-${w}" to="${w}" dur="1s" repeatCount="indefinite"/>
  </rect>
</svg>`;

const toBase64 = (str: string) =>
  typeof window === 'undefined'
    ? Buffer.from(str).toString('base64')
    : window.btoa(str);
// components/ResponsiveHero.tsx - Hero Image mit verschiedenen Breakpoints
import Image from 'next/image';

interface ResponsiveHeroProps {
  desktopSrc: string;
  mobileSrc: string;
  alt: string;
}

export function ResponsiveHero({ desktopSrc, mobileSrc, alt }: ResponsiveHeroProps) {
  return (
    <div className="relative w-full h-[50vh] md:h-[70vh]">
      {/* Mobile Image */}
      <Image
        src={mobileSrc}
        alt={alt}
        fill
        priority
        sizes="100vw"
        className="object-cover md:hidden"
      />

      {/* Desktop Image */}
      <Image
        src={desktopSrc}
        alt={alt}
        fill
        priority
        sizes="100vw"
        className="hidden md:block object-cover"
      />
    </div>
  );
}

// components/ArtDirectedImage.tsx - Art Direction mit Picture Element
export function ArtDirectedImage({
  sources,
  fallback,
  alt
}: {
  sources: Array<{ srcSet: string; media: string }>;
  fallback: string;
  alt: string;
}) {
  return (
    <picture>
      {sources.map((source, i) => (
        <source key={i} srcSet={source.srcSet} media={source.media} />
      ))}
      <img src={fallback} alt={alt} loading="lazy" />
    </picture>
  );
}

Next.js Image Configuration

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    // Moderne Formate (AVIF hat Priorität)
    formats: ['image/avif', 'image/webp'],

    // Device Sizes für srcset
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],

    // Image Sizes für kleinere Bilder (Icons, Thumbnails)
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],

    // Remote Patterns für externe Bilder
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
        pathname: '/**'
      },
      {
        protocol: 'https',
        hostname: 'cdn.example.com',
        pathname: '/images/**'
      },
      {
        protocol: 'https',
        hostname: '*.cloudinary.com'
      }
    ],

    // Minimum Cache Time (30 Tage)
    minimumCacheTTL: 60 * 60 * 24 * 30,

    // Deaktiviert static imports für Images
    disableStaticImages: false,

    // Content-Disposition Header
    contentDispositionType: 'inline',

    // Dangerously Allow SVG (Vorsicht!)
    dangerouslyAllowSVG: false,

    // Unoptimized für Static Export
    // unoptimized: true
  }
};

module.exports = nextConfig;

Image Processing with Sharp

// scripts/optimize-images.ts
import sharp from 'sharp';
import { glob } from 'glob';
import path from 'path';
import fs from 'fs/promises';

interface OptimizationOptions {
  quality: number;
  formats: ('webp' | 'avif' | 'jpeg')[];
  sizes: number[];
  outputDir: string;
}

async function optimizeImage(
  inputPath: string,
  options: OptimizationOptions
) {
  const image = sharp(inputPath);
  const metadata = await image.metadata();
  const baseName = path.basename(inputPath, path.extname(inputPath));

  const results: string[] = [];

  for (const format of options.formats) {
    for (const width of options.sizes) {
      // Skip wenn größer als Original
      if (metadata.width && width > metadata.width) continue;

      const outputName = `${baseName}-${width}w.${format}`;
      const outputPath = path.join(options.outputDir, outputName);

      let pipeline = image.clone().resize(width, null, {
        withoutEnlargement: true,
        fit: 'inside'
      });

      switch (format) {
        case 'webp':
          pipeline = pipeline.webp({
            quality: options.quality,
            effort: 6 // 0-6, higher = slower + better
          });
          break;
        case 'avif':
          pipeline = pipeline.avif({
            quality: options.quality,
            effort: 6 // 0-9
          });
          break;
        case 'jpeg':
          pipeline = pipeline.jpeg({
            quality: options.quality,
            mozjpeg: true
          });
          break;
      }

      await pipeline.toFile(outputPath);
      results.push(outputPath);
    }
  }

  return results;
}

// Batch Processing
async function batchOptimize(inputDir: string, outputDir: string) {
  const files = await glob(`${inputDir}/**/*.{jpg,jpeg,png,webp}`);

  await fs.mkdir(outputDir, { recursive: true });

  const options: OptimizationOptions = {
    quality: 80,
    formats: ['avif', 'webp', 'jpeg'],
    sizes: [640, 1024, 1920, 2560],
    outputDir
  };

  console.log(`Processing ${files.length} images...`);

  for (const file of files) {
    const results = await optimizeImage(file, options);
    console.log(`${path.basename(file)}: Generated ${results.length} variants`);
  }
}

// Blur Placeholder Generation
async function generateBlurPlaceholder(imagePath: string): Promise<string> {
  const buffer = await sharp(imagePath)
    .resize(10, 10, { fit: 'inside' })
    .blur()
    .toBuffer();

  return `data:image/jpeg;base64,${buffer.toString('base64')}`;
}

// LQIP (Low Quality Image Placeholder)
async function generateLQIP(imagePath: string): Promise<{
  base64: string;
  width: number;
  height: number;
}> {
  const metadata = await sharp(imagePath).metadata();

  const buffer = await sharp(imagePath)
    .resize(20, null, { fit: 'inside' })
    .jpeg({ quality: 20 })
    .toBuffer();

  return {
    base64: `data:image/jpeg;base64,${buffer.toString('base64')}`,
    width: metadata.width || 0,
    height: metadata.height || 0
  };
}

Responsive Images Pattern

// components/ResponsiveImage.tsx
'use client';

import { useState } from 'react';

interface ImageSource {
  src: string;
  width: number;
  format: 'avif' | 'webp' | 'jpeg';
}

interface ResponsiveImageProps {
  sources: ImageSource[];
  alt: string;
  sizes: string;
  priority?: boolean;
  className?: string;
  blurDataURL?: string;
}

export function ResponsiveImage({
  sources,
  alt,
  sizes,
  priority = false,
  className,
  blurDataURL
}: ResponsiveImageProps) {
  const [isLoaded, setIsLoaded] = useState(false);

  // Gruppiere nach Format
  const avifSources = sources.filter(s => s.format === 'avif');
  const webpSources = sources.filter(s => s.format === 'webp');
  const jpegSources = sources.filter(s => s.format === 'jpeg');

  const generateSrcSet = (sources: ImageSource[]) =>
    sources.map(s => `${s.src} ${s.width}w`).join(', ');

  const fallbackSrc = jpegSources[jpegSources.length - 1]?.src || '';

  return (
    <div className={`relative ${className}`}>
      {/* Blur Placeholder */}
      {blurDataURL && !isLoaded && (
        <img
          src={blurDataURL}
          alt=""
          aria-hidden="true"
          className="absolute inset-0 w-full h-full object-cover blur-lg scale-110"
        />
      )}

      <picture>
        {/* AVIF Sources */}
        {avifSources.length > 0 && (
          <source
            type="image/avif"
            srcSet={generateSrcSet(avifSources)}
            sizes={sizes}
          />
        )}

        {/* WebP Sources */}
        {webpSources.length > 0 && (
          <source
            type="image/webp"
            srcSet={generateSrcSet(webpSources)}
            sizes={sizes}
          />
        )}

        {/* JPEG Fallback */}
        <img
          src={fallbackSrc}
          srcSet={generateSrcSet(jpegSources)}
          sizes={sizes}
          alt={alt}
          loading={priority ? 'eager' : 'lazy'}
          decoding="async"
          onLoad={() => setIsLoaded(true)}
          className={`w-full h-full object-cover transition-opacity duration-300 ${
            isLoaded ? 'opacity-100' : 'opacity-0'
          }`}
        />
      </picture>
    </div>
  );
}

Lazy Loading Strategies

// components/LazyImage.tsx - Intersection Observer
'use client';

import { useState, useRef, useEffect } from 'react';

interface LazyImageProps {
  src: string;
  alt: string;
  width: number;
  height: number;
  threshold?: number;
  rootMargin?: string;
}

export function LazyImage({
  src,
  alt,
  width,
  height,
  threshold = 0.1,
  rootMargin = '100px'
}: LazyImageProps) {
  const [isInView, setIsInView] = useState(false);
  const [isLoaded, setIsLoaded] = useState(false);
  const imgRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsInView(true);
          observer.disconnect();
        }
      },
      { threshold, rootMargin }
    );

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

    return () => observer.disconnect();
  }, [threshold, rootMargin]);

  const aspectRatio = (height / width) * 100;

  return (
    <div
      ref={imgRef}
      className="relative overflow-hidden bg-gray-100"
      style={{ paddingBottom: `${aspectRatio}%` }}
    >
      {isInView && (
        <img
          src={src}
          alt={alt}
          onLoad={() => setIsLoaded(true)}
          className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-500 ${
            isLoaded ? 'opacity-100' : 'opacity-0'
          }`}
        />
      )}

      {!isLoaded && (
        <div className="absolute inset-0 animate-pulse bg-gray-200" />
      )}
    </div>
  );
}

// hooks/useLazyImages.ts - Batch Lazy Loading
export function useLazyImages() {
  useEffect(() => {
    if ('loading' in HTMLImageElement.prototype) {
      // Native lazy loading supported
      const images = document.querySelectorAll<HTMLImageElement>('img[data-src]');
      images.forEach(img => {
        img.src = img.dataset.src!;
        img.removeAttribute('data-src');
      });
    } else {
      // Fallback to Intersection Observer
      const observer = new IntersectionObserver(
        (entries) => {
          entries.forEach(entry => {
            if (entry.isIntersecting) {
              const img = entry.target as HTMLImageElement;
              img.src = img.dataset.src!;
              img.removeAttribute('data-src');
              observer.unobserve(img);
            }
          });
        },
        { rootMargin: '50px' }
      );

      document.querySelectorAll('img[data-src]').forEach(img => {
        observer.observe(img);
      });
    }
  }, []);
}

// components/ImageGallery.tsx
'use client';

import { useState } from 'react';
import Image from 'next/image';

interface GalleryImage {
  src: string;
  alt: string;
  width: number;
  height: number;
  thumbnail?: string;
}

interface ImageGalleryProps {
  images: GalleryImage[];
  columns?: 2 | 3 | 4;
}

export function ImageGallery({ images, columns = 3 }: ImageGalleryProps) {
  const [selectedIndex, setSelectedIndex] = useState<number | null>(null);

  const columnClass = {
    2: 'grid-cols-2',
    3: 'grid-cols-2 md:grid-cols-3',
    4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
  }[columns];

  return (
    <>
      <div className={`grid ${columnClass} gap-4`}>
        {images.map((image, index) => (
          <button
            key={index}
            onClick={() => setSelectedIndex(index)}
            className="relative aspect-square overflow-hidden rounded-lg group"
          >
            <Image
              src={image.thumbnail || image.src}
              alt={image.alt}
              fill
              sizes="(max-width: 768px) 50vw, 33vw"
              className="object-cover transition-transform group-hover:scale-105"
            />
          </button>
        ))}
      </div>

      {/* Lightbox */}
      {selectedIndex !== null && (
        <Lightbox
          images={images}
          currentIndex={selectedIndex}
          onClose={() => setSelectedIndex(null)}
          onNavigate={setSelectedIndex}
        />
      )}
    </>
  );
}

// Lightbox Component
function Lightbox({
  images,
  currentIndex,
  onClose,
  onNavigate
}: {
  images: GalleryImage[];
  currentIndex: number;
  onClose: () => void;
  onNavigate: (index: number) => void;
}) {
  const current = images[currentIndex];

  const handlePrev = () => {
    onNavigate(currentIndex === 0 ? images.length - 1 : currentIndex - 1);
  };

  const handleNext = () => {
    onNavigate(currentIndex === images.length - 1 ? 0 : currentIndex + 1);
  };

  // Keyboard Navigation
  useEffect(() => {
    const handleKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
      if (e.key === 'ArrowLeft') handlePrev();
      if (e.key === 'ArrowRight') handleNext();
    };

    window.addEventListener('keydown', handleKey);
    return () => window.removeEventListener('keydown', handleKey);
  }, [currentIndex]);

  return (
    <div
      className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
      onClick={onClose}
    >
      <button
        onClick={(e) => { e.stopPropagation(); handlePrev(); }}
        className="absolute left-4 p-2 text-white hover:bg-white/10 rounded"
      >
        
      </button>

      <div
        className="relative max-w-4xl max-h-[80vh]"
        onClick={(e) => e.stopPropagation()}
      >
        <Image
          src={current.src}
          alt={current.alt}
          width={current.width}
          height={current.height}
          className="max-h-[80vh] w-auto"
          priority
        />
      </div>

      <button
        onClick={(e) => { e.stopPropagation(); handleNext(); }}
        className="absolute right-4 p-2 text-white hover:bg-white/10 rounded"
      >
        
      </button>

      <button
        onClick={onClose}
        className="absolute top-4 right-4 p-2 text-white hover:bg-white/10 rounded"
      >
        
      </button>

      <div className="absolute bottom-4 text-white">
        {currentIndex + 1} / {images.length}
      </div>
    </div>
  );
}

Format Comparison

Format Compression Quality Browser Support Best For
AVIF Beste Excellent Modern browsers Photos, Complex
WebP Sehr gut Very Good All modern General use
JPEG Gut Good Universal Photos fallback
PNG Keine Lossless Universal Transparenz
SVG N/A Vector Universal Icons, Logos

Fazit

Image Optimization umfasst:

  1. Moderne Formate: AVIF/WebP mit Fallbacks
  2. Responsive Images: srcset und sizes
  3. Lazy Loading: Native oder Intersection Observer
  4. Blur Placeholders: Bessere UX während Loading

Optimierte Bilder sind der Schlüssel zu schnellen Websites.


Bildprompts

  1. "Image optimization pipeline diagram, format conversion flow"
  2. "Before and after image compression comparison, file size reduction"
  3. "Responsive images loading on different devices, mobile to desktop"

Quellen