- 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>
695 lines
19 KiB
Markdown
695 lines
19 KiB
Markdown
# Core Web Vitals Optimization
|
|
|
|
**Meta-Description:** Core Web Vitals 2026 optimieren. LCP, INP, CLS verbessern mit praktischen Strategien für Next.js und React Anwendungen.
|
|
|
|
**Keywords:** Core Web Vitals, LCP, INP, CLS, Web Performance, Page Speed, User Experience, Next.js Performance
|
|
|
|
---
|
|
|
|
## Einführung
|
|
|
|
**Core Web Vitals** sind Googles wichtigste Metriken für User Experience. Mit dem **INP Update 2024** (ersetzt FID) und neuen **2026 Benchmarks** ist Performance-Optimierung wichtiger denn je. Dieser Guide zeigt praktische Strategien für moderne Web-Apps.
|
|
|
|
---
|
|
|
|
## Core Web Vitals Overview
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ CORE WEB VITALS 2026 │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ LCP (Largest Contentful Paint): │
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
│ │ Loading Performance │ │
|
|
│ │ ├── Good: ≤ 2.5s │ │
|
|
│ │ ├── Needs Improvement: 2.5s - 4.0s │ │
|
|
│ │ └── Poor: > 4.0s │ │
|
|
│ │ │ │
|
|
│ │ Typical LCP Elements: │ │
|
|
│ │ ├── <img> (Hero Images) │ │
|
|
│ │ ├── <video> poster │ │
|
|
│ │ ├── Background images (CSS) │ │
|
|
│ │ └── Block-level text elements │ │
|
|
│ └─────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ INP (Interaction to Next Paint): │
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
│ │ Interactivity (replaced FID in 2024) │ │
|
|
│ │ ├── Good: ≤ 200ms │ │
|
|
│ │ ├── Needs Improvement: 200ms - 500ms │ │
|
|
│ │ └── Poor: > 500ms │ │
|
|
│ │ │ │
|
|
│ │ Measured Interactions: │ │
|
|
│ │ ├── Click / Tap │ │
|
|
│ │ ├── Keyboard input │ │
|
|
│ │ └── Touch interactions │ │
|
|
│ └─────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ CLS (Cumulative Layout Shift): │
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
│ │ Visual Stability │ │
|
|
│ │ ├── Good: ≤ 0.1 │ │
|
|
│ │ ├── Needs Improvement: 0.1 - 0.25 │ │
|
|
│ │ └── Poor: > 0.25 │ │
|
|
│ │ │ │
|
|
│ │ Common Causes: │ │
|
|
│ │ ├── Images without dimensions │ │
|
|
│ │ ├── Ads, embeds, iframes │ │
|
|
│ │ ├── Dynamic content injection │ │
|
|
│ │ └── Web fonts (FOUT/FOIT) │ │
|
|
│ └─────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## LCP Optimization
|
|
|
|
```typescript
|
|
// next.config.js - Image Optimization
|
|
/** @type {import('next').NextConfig} */
|
|
const nextConfig = {
|
|
images: {
|
|
// Moderne Formate priorisieren
|
|
formats: ['image/avif', 'image/webp'],
|
|
|
|
// Device Sizes für responsive images
|
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
|
|
|
|
// Image Sizes für kleinere Bilder
|
|
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
|
|
|
// Remote Patterns
|
|
remotePatterns: [
|
|
{
|
|
protocol: 'https',
|
|
hostname: 'images.example.com',
|
|
pathname: '/**'
|
|
}
|
|
],
|
|
|
|
// Minimale Cache-Zeit
|
|
minimumCacheTTL: 60 * 60 * 24 * 30 // 30 Tage
|
|
}
|
|
};
|
|
|
|
module.exports = nextConfig;
|
|
```
|
|
|
|
```tsx
|
|
// components/HeroImage.tsx - Optimized LCP Element
|
|
import Image from 'next/image';
|
|
|
|
interface HeroImageProps {
|
|
src: string;
|
|
alt: string;
|
|
}
|
|
|
|
export function HeroImage({ src, alt }: HeroImageProps) {
|
|
return (
|
|
<div className="relative w-full h-[60vh]">
|
|
<Image
|
|
src={src}
|
|
alt={alt}
|
|
fill
|
|
priority // Kritisch für LCP!
|
|
sizes="100vw"
|
|
style={{ objectFit: 'cover' }}
|
|
// Placeholder für bessere UX
|
|
placeholder="blur"
|
|
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Preload kritischer Ressourcen
|
|
// app/layout.tsx
|
|
export default function RootLayout({ children }) {
|
|
return (
|
|
<html>
|
|
<head>
|
|
{/* LCP Image Preload */}
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
href="/hero-image.webp"
|
|
imageSrcSet="/hero-640.webp 640w, /hero-1200.webp 1200w"
|
|
imageSizes="100vw"
|
|
/>
|
|
|
|
{/* Critical Font Preload */}
|
|
<link
|
|
rel="preload"
|
|
as="font"
|
|
type="font/woff2"
|
|
href="/fonts/inter-var.woff2"
|
|
crossOrigin="anonymous"
|
|
/>
|
|
</head>
|
|
<body>{children}</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// lib/critical-css.ts - Critical CSS Extraction
|
|
import { PurgeCSS } from 'purgecss';
|
|
import critical from 'critical';
|
|
|
|
async function generateCriticalCSS(html: string, css: string): Promise<string> {
|
|
const result = await critical.generate({
|
|
html,
|
|
css,
|
|
width: 1300,
|
|
height: 900,
|
|
inline: false,
|
|
extract: true,
|
|
penthouse: {
|
|
blockJSRequests: false
|
|
}
|
|
});
|
|
|
|
return result.css;
|
|
}
|
|
|
|
// Inline Critical CSS
|
|
export function InlineCriticalCSS({ css }: { css: string }) {
|
|
return (
|
|
<style
|
|
dangerouslySetInnerHTML={{ __html: css }}
|
|
data-critical="true"
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## INP Optimization
|
|
|
|
```typescript
|
|
// hooks/useOptimizedCallback.ts - Debounced Interactions
|
|
import { useCallback, useRef } from 'react';
|
|
|
|
export function useOptimizedCallback<T extends (...args: any[]) => void>(
|
|
callback: T,
|
|
delay: number = 100
|
|
) {
|
|
const timeoutRef = useRef<NodeJS.Timeout>();
|
|
const lastCallRef = useRef<number>(0);
|
|
|
|
return useCallback((...args: Parameters<T>) => {
|
|
const now = Date.now();
|
|
|
|
// Sofortige Ausführung wenn genug Zeit vergangen
|
|
if (now - lastCallRef.current >= delay) {
|
|
lastCallRef.current = now;
|
|
callback(...args);
|
|
return;
|
|
}
|
|
|
|
// Debounce für schnelle Aufrufe
|
|
clearTimeout(timeoutRef.current);
|
|
timeoutRef.current = setTimeout(() => {
|
|
lastCallRef.current = Date.now();
|
|
callback(...args);
|
|
}, delay - (now - lastCallRef.current));
|
|
}, [callback, delay]);
|
|
}
|
|
|
|
// hooks/useIdleCallback.ts - Non-blocking Operations
|
|
export function useIdleCallback() {
|
|
const scheduleIdleTask = useCallback((
|
|
task: () => void,
|
|
options?: IdleRequestOptions
|
|
) => {
|
|
if ('requestIdleCallback' in window) {
|
|
return requestIdleCallback(() => task(), options);
|
|
}
|
|
// Fallback
|
|
return setTimeout(task, 1) as unknown as number;
|
|
}, []);
|
|
|
|
const cancelIdleTask = useCallback((id: number) => {
|
|
if ('cancelIdleCallback' in window) {
|
|
cancelIdleCallback(id);
|
|
} else {
|
|
clearTimeout(id);
|
|
}
|
|
}, []);
|
|
|
|
return { scheduleIdleTask, cancelIdleTask };
|
|
}
|
|
```
|
|
|
|
```tsx
|
|
// components/OptimizedList.tsx - Virtualized List für große Datenmengen
|
|
'use client';
|
|
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { useRef, useCallback, useTransition } from 'react';
|
|
|
|
interface Item {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
}
|
|
|
|
interface OptimizedListProps {
|
|
items: Item[];
|
|
onItemClick: (item: Item) => void;
|
|
}
|
|
|
|
export function OptimizedList({ items, onItemClick }: OptimizedListProps) {
|
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: items.length,
|
|
getScrollElement: () => parentRef.current,
|
|
estimateSize: () => 80, // Geschätzte Item-Höhe
|
|
overscan: 5 // Extra Items für smooth scrolling
|
|
});
|
|
|
|
// Non-blocking click handler
|
|
const handleClick = useCallback((item: Item) => {
|
|
// Sofortiges visuelles Feedback
|
|
startTransition(() => {
|
|
onItemClick(item);
|
|
});
|
|
}, [onItemClick]);
|
|
|
|
return (
|
|
<div
|
|
ref={parentRef}
|
|
className="h-[600px] overflow-auto"
|
|
style={{ contain: 'strict' }} // CSS Containment für Performance
|
|
>
|
|
<div
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
width: '100%',
|
|
position: 'relative'
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
|
const item = items[virtualItem.index];
|
|
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => handleClick(item)}
|
|
className="absolute top-0 left-0 w-full p-4 border-b cursor-pointer hover:bg-gray-50"
|
|
style={{
|
|
height: `${virtualItem.size}px`,
|
|
transform: `translateY(${virtualItem.start}px)`,
|
|
// Vermeidet Layout Thrashing
|
|
contain: 'layout style paint'
|
|
}}
|
|
>
|
|
<h3 className="font-semibold">{item.title}</h3>
|
|
<p className="text-gray-600 truncate">{item.description}</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{isPending && (
|
|
<div className="absolute top-2 right-2">
|
|
<span className="text-sm text-gray-500">Loading...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// lib/web-worker.ts - Offload Heavy Computation
|
|
// workers/calculation.worker.ts
|
|
self.onmessage = (event: MessageEvent<{ data: number[] }>) => {
|
|
const { data } = event.data;
|
|
|
|
// Schwere Berechnung im Worker
|
|
const result = data.reduce((sum, val) => {
|
|
// Simuliere komplexe Berechnung
|
|
for (let i = 0; i < 1000; i++) {
|
|
sum += Math.sqrt(val * i);
|
|
}
|
|
return sum;
|
|
}, 0);
|
|
|
|
self.postMessage({ result });
|
|
};
|
|
|
|
// hooks/useWebWorker.ts
|
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
|
|
|
export function useCalculationWorker() {
|
|
const workerRef = useRef<Worker>();
|
|
const [result, setResult] = useState<number | null>(null);
|
|
const [isCalculating, setIsCalculating] = useState(false);
|
|
|
|
useEffect(() => {
|
|
workerRef.current = new Worker(
|
|
new URL('../workers/calculation.worker.ts', import.meta.url)
|
|
);
|
|
|
|
workerRef.current.onmessage = (event) => {
|
|
setResult(event.data.result);
|
|
setIsCalculating(false);
|
|
};
|
|
|
|
return () => workerRef.current?.terminate();
|
|
}, []);
|
|
|
|
const calculate = useCallback((data: number[]) => {
|
|
setIsCalculating(true);
|
|
workerRef.current?.postMessage({ data });
|
|
}, []);
|
|
|
|
return { calculate, result, isCalculating };
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## CLS Optimization
|
|
|
|
```tsx
|
|
// components/ResponsiveImage.tsx - Prevent Layout Shift
|
|
import Image from 'next/image';
|
|
|
|
interface ResponsiveImageProps {
|
|
src: string;
|
|
alt: string;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export function ResponsiveImage({ src, alt, width, height }: ResponsiveImageProps) {
|
|
// Aspect Ratio für Layout Reservierung
|
|
const aspectRatio = (height / width) * 100;
|
|
|
|
return (
|
|
<div
|
|
className="relative w-full"
|
|
style={{ paddingBottom: `${aspectRatio}%` }}
|
|
>
|
|
<Image
|
|
src={src}
|
|
alt={alt}
|
|
fill
|
|
sizes="(max-width: 768px) 100vw, 50vw"
|
|
style={{ objectFit: 'cover' }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// components/Skeleton.tsx - Content Placeholders
|
|
interface SkeletonProps {
|
|
className?: string;
|
|
variant?: 'text' | 'rectangular' | 'circular';
|
|
width?: string | number;
|
|
height?: string | number;
|
|
}
|
|
|
|
export function Skeleton({
|
|
className = '',
|
|
variant = 'rectangular',
|
|
width,
|
|
height
|
|
}: SkeletonProps) {
|
|
const baseStyles = 'animate-pulse bg-gray-200';
|
|
|
|
const variantStyles = {
|
|
text: 'rounded',
|
|
rectangular: 'rounded-md',
|
|
circular: 'rounded-full'
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`${baseStyles} ${variantStyles[variant]} ${className}`}
|
|
style={{ width, height }}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Verwendung für Layout Stability
|
|
export function ArticleCardSkeleton() {
|
|
return (
|
|
<div className="p-4 border rounded-lg">
|
|
<Skeleton variant="rectangular" height={200} className="mb-4" />
|
|
<Skeleton variant="text" height={24} className="mb-2" />
|
|
<Skeleton variant="text" height={16} width="60%" />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
```tsx
|
|
// components/FontOptimization.tsx - Prevent FOUT/FOIT
|
|
// next.config.js
|
|
const nextConfig = {
|
|
optimizeFonts: true
|
|
};
|
|
|
|
// app/layout.tsx - Font Preloading
|
|
import { Inter, Roboto_Mono } from 'next/font/google';
|
|
|
|
const inter = Inter({
|
|
subsets: ['latin'],
|
|
display: 'swap', // Verhindert FOIT
|
|
variable: '--font-inter',
|
|
preload: true,
|
|
fallback: ['system-ui', 'arial']
|
|
});
|
|
|
|
const robotoMono = Roboto_Mono({
|
|
subsets: ['latin'],
|
|
display: 'swap',
|
|
variable: '--font-roboto-mono',
|
|
preload: true
|
|
});
|
|
|
|
export default function RootLayout({ children }) {
|
|
return (
|
|
<html lang="de" className={`${inter.variable} ${robotoMono.variable}`}>
|
|
<body className="font-sans">{children}</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
// CSS Variable Usage
|
|
// globals.css
|
|
/*
|
|
:root {
|
|
--font-sans: var(--font-inter), system-ui, sans-serif;
|
|
--font-mono: var(--font-roboto-mono), monospace;
|
|
}
|
|
|
|
body {
|
|
font-family: var(--font-sans);
|
|
/* Font-size-adjust verhindert Layout Shift */
|
|
font-size-adjust: 0.5;
|
|
}
|
|
*/
|
|
```
|
|
|
|
```tsx
|
|
// components/DynamicContent.tsx - Reservierter Platz
|
|
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
interface DynamicContentProps {
|
|
fetchContent: () => Promise<string>;
|
|
minHeight: number; // Minimale Höhe reservieren
|
|
}
|
|
|
|
export function DynamicContent({ fetchContent, minHeight }: DynamicContentProps) {
|
|
const [content, setContent] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
fetchContent()
|
|
.then(setContent)
|
|
.finally(() => setIsLoading(false));
|
|
}, [fetchContent]);
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
minHeight,
|
|
// CSS Containment für Layout Isolation
|
|
contain: 'layout'
|
|
}}
|
|
>
|
|
{isLoading ? (
|
|
<Skeleton height={minHeight} />
|
|
) : (
|
|
<div dangerouslySetInnerHTML={{ __html: content || '' }} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Ad Container mit reserviertem Platz
|
|
export function AdContainer({
|
|
width,
|
|
height
|
|
}: {
|
|
width: number;
|
|
height: number
|
|
}) {
|
|
return (
|
|
<div
|
|
style={{
|
|
width,
|
|
height,
|
|
minWidth: width,
|
|
minHeight: height,
|
|
contain: 'strict',
|
|
backgroundColor: '#f0f0f0'
|
|
}}
|
|
data-ad-slot="123456"
|
|
>
|
|
{/* Ad loads here */}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Monitoring
|
|
|
|
```typescript
|
|
// lib/web-vitals.ts - Real User Monitoring
|
|
import { onLCP, onINP, onCLS, Metric } from 'web-vitals';
|
|
|
|
interface VitalsReport {
|
|
metric: string;
|
|
value: number;
|
|
rating: 'good' | 'needs-improvement' | 'poor';
|
|
navigationType: string;
|
|
url: string;
|
|
}
|
|
|
|
function sendToAnalytics(report: VitalsReport) {
|
|
// An Analytics senden
|
|
fetch('/api/vitals', {
|
|
method: 'POST',
|
|
body: JSON.stringify(report),
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
// Oder an Google Analytics
|
|
if (typeof gtag !== 'undefined') {
|
|
gtag('event', report.metric, {
|
|
value: Math.round(report.value),
|
|
event_category: 'Web Vitals',
|
|
event_label: report.rating,
|
|
non_interaction: true
|
|
});
|
|
}
|
|
}
|
|
|
|
function getRating(metric: Metric): 'good' | 'needs-improvement' | 'poor' {
|
|
const thresholds = {
|
|
LCP: [2500, 4000],
|
|
INP: [200, 500],
|
|
CLS: [0.1, 0.25]
|
|
};
|
|
|
|
const [good, poor] = thresholds[metric.name as keyof typeof thresholds] || [0, 0];
|
|
|
|
if (metric.value <= good) return 'good';
|
|
if (metric.value <= poor) return 'needs-improvement';
|
|
return 'poor';
|
|
}
|
|
|
|
export function initWebVitals() {
|
|
const reportMetric = (metric: Metric) => {
|
|
sendToAnalytics({
|
|
metric: metric.name,
|
|
value: metric.value,
|
|
rating: getRating(metric),
|
|
navigationType: metric.navigationType || 'unknown',
|
|
url: window.location.href
|
|
});
|
|
};
|
|
|
|
onLCP(reportMetric);
|
|
onINP(reportMetric);
|
|
onCLS(reportMetric);
|
|
}
|
|
|
|
// app/providers.tsx
|
|
'use client';
|
|
|
|
import { useEffect } from 'react';
|
|
import { initWebVitals } from '@/lib/web-vitals';
|
|
|
|
export function WebVitalsProvider({ children }: { children: React.ReactNode }) {
|
|
useEffect(() => {
|
|
initWebVitals();
|
|
}, []);
|
|
|
|
return <>{children}</>;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Optimization Checklist
|
|
|
|
| Metrik | Optimierung | Impact |
|
|
|--------|-------------|--------|
|
|
| **LCP** | Image priority, preload, responsive | Hoch |
|
|
| **LCP** | Critical CSS inline | Mittel |
|
|
| **LCP** | Server-side rendering | Hoch |
|
|
| **INP** | Event handler optimization | Hoch |
|
|
| **INP** | Web Workers für heavy tasks | Mittel |
|
|
| **INP** | useTransition für non-blocking | Mittel |
|
|
| **CLS** | Feste Dimensionen für Media | Hoch |
|
|
| **CLS** | Font display: swap | Mittel |
|
|
| **CLS** | Skeleton Placeholders | Mittel |
|
|
|
|
---
|
|
|
|
## Fazit
|
|
|
|
Core Web Vitals Optimization erfordert:
|
|
|
|
1. **LCP**: Prioritize above-fold content, optimize images
|
|
2. **INP**: Non-blocking interactions, Web Workers
|
|
3. **CLS**: Reserved space, stable layouts
|
|
4. **Monitoring**: Real User Monitoring für kontinuierliche Verbesserung
|
|
|
|
Performance ist kein Feature, sondern Grundvoraussetzung.
|
|
|
|
---
|
|
|
|
## Bildprompts
|
|
|
|
1. "Core Web Vitals dashboard showing green metrics, performance monitoring"
|
|
2. "Page loading timeline visualization, LCP element highlighted"
|
|
3. "Layout shift animation showing before and after optimization"
|
|
|
|
---
|
|
|
|
## Quellen
|
|
|
|
- [Web.dev Core Web Vitals](https://web.dev/vitals/)
|
|
- [Google Search Central Page Experience](https://developers.google.com/search/docs/appearance/page-experience)
|
|
- [Next.js Performance Optimization](https://nextjs.org/docs/app/building-your-application/optimizing)
|
|
- [Chrome DevTools Performance](https://developer.chrome.com/docs/devtools/performance/)
|