next-js/skills/skills/website-speed-audit/SKILL.md
Comprehensive website performance audit and optimization skill. Identifies and automatically fixes performance issues including image optimization, video compression, lazy loading, Core Web Vitals, bundle size, and rendering strategy. Uses Lighthouse (via CLI or MCP when available), ffmpeg for media processing, and the project's existing Image component with blur-up lazy loading. Use this skill whenever the user mentions: website speed, page load time, performance audit, Core Web Vitals, Lighthouse, optimize images, compress videos, lazy loading, LCP, CLS, FID, INP, slow website, speed up, performance optimization, image compression, video optimization, blur placeholder, WebP conversion, media audit, bundle size, or wants to improve their website's loading performance. Also trigger when the user says "my site is slow", "optimize for speed", "reduce load time", "improve performance", or asks about image/video optimization in any context.
npx skillsauth add spuneiartur/claude-agent-specs website-speed-auditInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
A three-phase skill: Audit (identify issues) → Fix (automated optimizations) → Report (document changes). Designed for Next.js Pages Router projects with the starter's Image component and lazy-blur system.
Check if Lighthouse CLI or an MCP tool is available:
# Check for Lighthouse CLI
npx lighthouse --version 2>/dev/null || echo "Lighthouse not available"
If available, run:
npx lighthouse http://localhost:3000 --output=json --output-path=/tmp/lighthouse-report.json --chrome-flags="--headless --no-sandbox"
Parse the JSON for Core Web Vitals: LCP, CLS, INP, FCP, TTFB, Speed Index. Read references/lighthouse-checks.md for thresholds and interpretation.
If Lighthouse is not available, proceed with manual codebase audit.
Regardless of whether Lighthouse ran, audit the codebase for these categories:
Scan all components for image usage:
Find images without lazy loading: Search for <img tags or <Image components missing effect="lazy-blur". Every image loaded from the backend (multi-size objects) should use the project's Image component with blur-up.
Find images without placeholders: Search for Image components missing placeholderSrc. Every dynamic image should use getPlaceholderImageUrl(images) from @functions.
Find images without srcSet: Search for Image components missing responsive srcSet. Dynamic images should use createImageSrcSet(images) from @functions.
Check static images in public/: List files in public/images/ and public/ — identify large files (>200KB) that could be compressed or converted to WebP.
Check image formats: Flag JPEG/PNG files that should be WebP for better compression.
public/videos/ and flag files over 5MB.<video> tags — verify they have preload="metadata" (not preload="auto"), muted, playsInline.poster attribute for immediate visual display before load.&display=swap.<link rel="preconnect" href="https://fonts.googleapis.com"> exists in _document.js.package.json for heavy packages that could be dynamically imported (e.g., react-quill, leaflet, framer-motion on pages that don't use them).ssr: false where appropriate.css/index.css — flag unused stylesheets.getServerSideProps when they could use getStaticProps with ISR.<script> tags without async or defer.Present findings grouped by impact:
For every Image component using backend images (multi-size objects), ensure it has the full optimization setup:
import { Image } from '@components';
import { getImageUrl, getPlaceholderImageUrl, createImageSrcSet } from '@functions';
<Image
src={getImageUrl(images, 'medium')}
placeholderSrc={getPlaceholderImageUrl(images)}
srcSet={createImageSrcSet(images)}
sizes="(max-width: 480px) 100vw, (max-width: 768px) 50vw, 33vw"
effect="lazy-blur"
alt="Descriptive alt text"
/>
The blur-up effect is handled by css/lazy-loading-blur-effect.css:
For images in public/ that don't have pre-generated sizes, create two versions using ffmpeg:
#!/bin/bash
# scripts/optimize-images.sh
# Usage: ./scripts/optimize-images.sh public/images/
INPUT_DIR="${1:-.}"
OUTPUT_DIR="${INPUT_DIR}/optimized"
mkdir -p "$OUTPUT_DIR"
for file in "$INPUT_DIR"/*.{jpg,jpeg,png,JPG,JPEG,PNG}; do
[ -f "$file" ] || continue
filename=$(basename "$file" | sed 's/\.[^.]*$//')
# Generate tiny placeholder (40px wide, heavy JPEG compression, ~1-2KB)
ffmpeg -i "$file" -vf "scale=40:-2" -q:v 10 "$OUTPUT_DIR/${filename}-placeholder.jpg" -y 2>/dev/null
# Generate optimized WebP version
ffmpeg -i "$file" -vf "scale='min(1920,iw)':-2" -quality 80 "$OUTPUT_DIR/${filename}.webp" -y 2>/dev/null
echo "Optimized: $filename"
done
echo "Done! Optimized images in $OUTPUT_DIR"
Then update components to use the optimized versions:
<Image
src="/images/optimized/hero.webp"
placeholderSrc="/images/optimized/hero-placeholder.jpg"
effect="lazy-blur"
alt="Hero image"
/>
If sharp is available (Node.js), generate optimized versions programmatically:
# Install sharp
npm install sharp --save-dev
// scripts/optimize-images.js
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
const inputDir = process.argv[2] || 'public/images';
const outputDir = path.join(inputDir, 'optimized');
fs.mkdirSync(outputDir, { recursive: true });
const files = fs.readdirSync(inputDir).filter(f => /\.(jpg|jpeg|png)$/i.test(f));
for (const file of files) {
const name = path.parse(file).name;
const input = path.join(inputDir, file);
// Tiny placeholder (~1KB)
sharp(input).resize(40).jpeg({ quality: 20 }).toFile(path.join(outputDir, `${name}-placeholder.jpg`));
// Optimized WebP
sharp(input).resize(1920, null, { withoutEnlargement: true }).webp({ quality: 80 }).toFile(path.join(outputDir, `${name}.webp`));
console.log(`Optimized: ${name}`);
}
# Web-optimized MP4 (H.264, CRF 28, faststart for streaming)
ffmpeg -i input.mp4 -vcodec libx264 -crf 28 -preset slow -movflags +faststart -an output-compressed.mp4
# Create 720p version for mobile
ffmpeg -i input.mp4 -vcodec libx264 -crf 28 -preset slow -movflags +faststart -vf "scale=-2:720" -an output-720p.mp4
# Create 1080p version for desktop
ffmpeg -i input.mp4 -vcodec libx264 -crf 26 -preset slow -movflags +faststart -vf "scale=-2:1080" -an output-1080p.mp4
# Generate poster frame (first frame as JPEG)
ffmpeg -i input.mp4 -vframes 1 -q:v 2 poster.jpg
# Generate poster frame as WebP
ffmpeg -i input.mp4 -vframes 1 -quality 80 poster.webp
Read references/video-optimization.md for the full command reference.
For hero/background videos, implement play-only-when-visible:
import { useEffect, useRef, useState } from 'react';
const VideoHero = ({ src, poster }) => {
const videoRef = useRef(null);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
video.play().catch(() => {});
} else {
video.pause();
}
},
{ threshold: 0.25 }
);
observer.observe(video);
return () => observer.disconnect();
}, []);
return (
<div className="relative w-full h-screen overflow-hidden">
{/* Poster image shown until video loads */}
{!isLoaded && poster && (
<img src={poster} alt="" className="absolute inset-0 w-full h-full object-cover" />
)}
<video
ref={videoRef}
src={src}
poster={poster}
muted
loop
playsInline
preload="metadata"
onLoadedData={() => setIsLoaded(true)}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-700 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
/>
</div>
);
};
This pattern is already used in the project's ContactHero.jsx — reference it.
For above-fold videos, add preload hints in _document.js:
<link rel="preload" as="video" type="video/mp4" href="/videos/hero-compressed.mp4" />
For below-fold videos, use preload="none" to prevent any data transfer until the user scrolls near them.
import dynamic from 'next/dynamic';
// Only load RichText editor when needed
const RichText = dynamic(() => import('@components/Fields/RichText'), { ssr: false });
// Only load map component when needed
const Map = dynamic(() => import('@components/Map'), {
ssr: false,
loading: () => <div className="h-64 bg-gray-100 animate-pulse rounded-lg" />,
});
Ensure _document.js has proper font loading:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
Verify the CSS includes font-display: swap (Google Fonts adds this via the &display=swap parameter).
Generate a summary of all findings and fixes. List:
Present inline in the conversation — no need for an HTML report unless the user requests one.
Read these for detailed command references and patterns:
references/image-optimization.md — ffmpeg and sharp commands, blur-up implementation detailsreferences/video-optimization.md — ffmpeg commands for all video scenarios, scroll-play patternsreferences/lighthouse-checks.md — Core Web Vitals thresholds, scoring, common fixesThese files are central to the project's existing performance infrastructure:
components/Image.jsx — Custom lazy-blur Image componentfunctions/image-utils.js — getImageUrl(), getPlaceholderImageUrl(), createImageSrcSet()css/lazy-loading-blur-effect.css — Blur transition CSS (15px blur → 0 with 0.3s transition)components/Contact/ContactHero.jsx — Reference implementation of video with poster fade-innext.config.js — Next.js configuration_document.js — Font loading, preload hintsIf the project doesn't have the Image component and lazy-blur system yet (e.g., fresh starter), set it up before optimizing:
Install: npm install react-lazy-load-image-component
Create components/Image.jsx:
import { classnames } from '@lib';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import 'react-lazy-load-image-component/src/effects/black-and-white.css';
import 'react-lazy-load-image-component/src/effects/opacity.css';
const Image = ({ alt, src, srcSet, sizes, placeholderSrc, effect, className, wrapperClassName, ...rest }) => (
<LazyLoadImage
alt={alt} effect={effect} placeholderSrc={placeholderSrc} src={src} srcSet={srcSet}
className={classnames('w-full h-full', className)}
wrapperClassName={classnames('w-full h-full', wrapperClassName)}
sizes={sizes} {...rest}
/>
);
export default Image;
css/lazy-loading-blur-effect.css:.lazy-load-image-background.lazy-blur {
filter: blur(15px);
}
.lazy-load-image-background.lazy-blur.lazy-load-image-loaded {
filter: blur(0);
transition: filter .3s;
}
Add @import 'lazy-loading-blur-effect.css'; to css/index.css
Create functions/image-utils.js with getImageUrl, getPlaceholderImageUrl, createImageSrcSet — see the component-factory skill for the full implementation.
Add barrel exports in components/index.js and functions/index.js.
tools
Replace with description of the skill and when Claude should use it.
tools
Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.
tools
Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts.
tools
Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.