Build an Image Optimization Pipeline with Claude Code
Use Claude Code to automate WebP/AVIF conversion, responsive images, and CI image budget checks.
Image optimization is not just a final compression step. Once a site has hero images, product thumbnails, article screenshots, diagrams, and social previews, manual resizing becomes unreliable. One forgotten PNG can undo a week of careful performance work, especially when that image becomes the Largest Contentful Paint element.
This guide turns Claude Code into a practical implementation partner for a repeatable image pipeline. We will generate AVIF, WebP, and JPEG variants with sharp, serve them through a responsive picture component, and add a CI check that rejects oversized output. The goal is not to chase the smallest possible file. The goal is to keep readable images, predictable naming, browser fallbacks, and a reviewable workflow.
Masa’s first attempt on a small technical blog was too simple: “generate AVIF and ship it.” It reduced bytes, but older crawlers still needed JPEG, screenshots with code became blurry at aggressive quality settings, and the hero image accidentally stayed lazy-loaded. The useful pipeline came only after separating conversion, rendering, and verification.
If you are new to the tool, start with the Claude Code getting started guide. For broader speed work beyond images, pair this article with Claude Code performance optimization.
Pipeline Overview
Give Claude Code a concrete pipeline, not a vague request like “make images faster.” The work is easier to review when each file has one responsibility.
flowchart LR
A["original images"] --> B["sharp conversion"]
B --> C["AVIF / WebP / JPEG variants"]
C --> D["OptimizedImage component"]
D --> E["browser chooses best source"]
C --> F["manifest.json"]
F --> G["CI size budget check"]
The conversion script creates deterministic variants. The component tells the browser which candidates exist. The budget check stops heavy images before they are deployed. This division also makes Claude Code safer to use because you can ask for one piece at a time and inspect a focused diff.
Quality Rules Before Coding
Do not start by choosing one magic quality number. Photos, UI screenshots, diagrams, and OGP images have different failure modes. I usually give Claude Code this table before asking for code.
| Use case | Target | Review note |
|---|---|---|
| Hero image | 1280px or wider, AVIF/WebP first, JPEG fallback | Treat as the LCP candidate and load with priority |
| Article screenshot | 640px/960px variants | Keep small UI text readable |
| Gallery or listing | 320px/640px variants | Lazy-load below-the-fold cards |
| Social preview | JPEG or PNG fallback | Some crawlers still prefer conventional formats |
As of this June 2026 update, the sharp documentation is still the best primary reference for supported input and output formats. On the HTML side, follow MDN’s responsive images guide: srcset is only useful when sizes describes the real rendered width.
Implementation 1: Generate Image Variants
Create a script that reads jpg, jpeg, and png files from public/images/original, writes optimized output to public/images/optimized, and records a manifest for later checks.
npm install -D sharp glob tsx
// scripts/optimize-images.ts
import path from "node:path";
import { mkdir, writeFile } from "node:fs/promises";
import { glob } from "glob";
import sharp from "sharp";
const inputDir = process.argv[2] ?? "public/images/original";
const outputDir = process.argv[3] ?? "public/images/optimized";
const widths = [320, 640, 960, 1280, 1920] as const;
const formats = ["avif", "webp", "jpeg"] as const;
const quality = { avif: 52, webp: 76, jpeg: 82 } as const;
type ImageFormat = (typeof formats)[number];
type ManifestEntry = {
src: string;
width: number;
format: string;
bytes: number;
};
const manifest: Record<string, ManifestEntry[]> = {};
function slugFromPath(filePath: string) {
const relative = path.relative(inputDir, filePath);
return relative
.replace(path.extname(relative), "")
.split(path.sep)
.join("-")
.replace(/[^a-zA-Z0-9_-]/g, "-")
.toLowerCase();
}
function extension(format: ImageFormat) {
return format === "jpeg" ? "jpg" : format;
}
async function buildVariant(filePath: string, slug: string, width: number, format: ImageFormat) {
let image = sharp(filePath).rotate().resize({ width, withoutEnlargement: true });
if (format === "avif") image = image.avif({ quality: quality.avif, effort: 4 });
if (format === "webp") image = image.webp({ quality: quality.webp, effort: 4 });
if (format === "jpeg") image = image.jpeg({ quality: quality.jpeg, mozjpeg: true });
const fileName = `${slug}-${width}w.${extension(format)}`;
const target = path.join(outputDir, fileName);
const info = await image.toFile(target);
return {
src: `/images/optimized/${fileName}`,
width: info.width,
format: extension(format),
bytes: info.size,
};
}
async function optimizeOne(filePath: string) {
const metadata = await sharp(filePath).metadata();
const sourceWidth = metadata.width ?? widths[widths.length - 1];
const targetWidths: number[] = widths.filter((width) => width <= sourceWidth);
if (!targetWidths.includes(sourceWidth)) targetWidths.push(sourceWidth);
targetWidths.sort((a, b) => a - b);
const slug = slugFromPath(filePath);
manifest[slug] = [];
for (const width of targetWidths) {
for (const format of formats) {
manifest[slug].push(await buildVariant(filePath, slug, width, format));
}
}
console.log(`optimized ${slug}: ${manifest[slug].length} files`);
}
async function main() {
await mkdir(outputDir, { recursive: true });
const pattern = `${inputDir.replace(/\\/g, "/")}/**/*.{jpg,jpeg,png}`;
const files = await glob(pattern, { nodir: true });
for (const filePath of files) {
await optimizeOne(filePath);
}
await writeFile(
path.join(outputDir, "manifest.json"),
JSON.stringify(manifest, null, 2),
);
console.log(`done: ${files.length} source images`);
}
void main().catch((error) => {
console.error(error);
process.exit(1);
});
The important detail is that the script does not upscale small originals. If a 900px screenshot is silently written as 1280w, future debugging becomes confusing because the file name lies about the real asset. The manifest keeps the actual output width and byte size visible.
Implementation 2: Serve Responsive Images
Now add a component that turns the generated files into browser choices. This example works well in a React-based app; in Astro, the same picture, source, and img structure can be placed directly in an Astro component.
// src/components/OptimizedImage.tsx
import type { ImgHTMLAttributes } from "react";
type OptimizedImageProps = Omit<
ImgHTMLAttributes<HTMLImageElement>,
"src" | "srcSet" | "sizes" | "width" | "height" | "loading"
> & {
slug: string;
alt: string;
width: number;
height: number;
widths?: number[];
sizes?: string;
priority?: boolean;
};
function srcSet(slug: string, widths: number[], extension: "avif" | "webp" | "jpg") {
return widths
.map((width) => `/images/optimized/${slug}-${width}w.${extension} ${width}w`)
.join(", ");
}
export function OptimizedImage({
slug,
alt,
width,
height,
widths = [320, 640, 960, 1280],
sizes = "(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 960px",
priority = false,
className,
...imgProps
}: OptimizedImageProps) {
const fallbackWidth = widths.includes(960) ? 960 : widths[Math.floor(widths.length / 2)];
const priorityProps = priority
? ({ fetchPriority: "high" } as ImgHTMLAttributes<HTMLImageElement>)
: {};
return (
<picture className={className}>
<source type="image/avif" srcSet={srcSet(slug, widths, "avif")} sizes={sizes} />
<source type="image/webp" srcSet={srcSet(slug, widths, "webp")} sizes={sizes} />
<img
src={`/images/optimized/${slug}-${fallbackWidth}w.jpg`}
srcSet={srcSet(slug, widths, "jpg")}
sizes={sizes}
width={width}
height={height}
alt={alt}
loading={priority ? "eager" : "lazy"}
decoding={priority ? "sync" : "async"}
{...priorityProps}
{...imgProps}
/>
</picture>
);
}
Set priority only for the main hero or another above-the-fold image. Making every image eager competes with CSS, JavaScript, fonts, and the real LCP candidate. web.dev’s LCP guide is a useful reference when deciding which image deserves priority.
Implementation 3: Enforce a CI Budget
The pipeline is not complete until it can fail automatically. Reviewers often notice broken layouts, but they rarely inspect every generated file size. A small Node script can protect the budget.
// scripts/check-image-budget.mjs
import { readFile } from "node:fs/promises";
const manifestUrl = new URL("../public/images/optimized/manifest.json", import.meta.url);
const manifest = JSON.parse(await readFile(manifestUrl, "utf8"));
const maxBytes = Number(process.env.IMAGE_BUDGET_BYTES ?? 240_000);
const failures = [];
for (const [slug, entries] of Object.entries(manifest)) {
for (const entry of entries) {
const isLargeCandidate = entry.width >= 1280 && ["avif", "webp", "jpg"].includes(entry.format);
if (isLargeCandidate && entry.bytes > maxBytes) {
failures.push(`${slug} ${entry.width}w.${entry.format}: ${entry.bytes} bytes`);
}
}
}
if (failures.length > 0) {
console.error(`Image budget exceeded. Limit: ${maxBytes} bytes`);
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("Image budget check passed.");
{
"scripts": {
"images:build": "tsx scripts/optimize-images.ts",
"images:check": "node scripts/check-image-budget.mjs"
}
}
For a production site, split the limit by image purpose. A hero might be allowed 220KB, a screenshot 300KB, and a thumbnail 80KB. Start with one simple rule, then tighten it when the team has real output data.
Three Practical Use Cases
The first use case is a technical blog. Screenshots often start as large PNG files because text looks crisp during editing. The pipeline can keep readability while generating smaller delivery formats. Tell Claude Code that screenshots must preserve UI text and that article content usually renders at about 960px wide.
The second use case is a SaaS landing page. A hero image or product screenshot is often the LCP element, so it needs dimensions, priority loading, and a good fallback. Ask Claude Code to keep only that image eager and to lazy-load the rest.
The third use case is an ecommerce or portfolio gallery. The same source image may appear in a card, a detail view, a related-items carousel, and an OGP image. The manifest becomes valuable because it tells tests and reviewers which variants exist for each original.
Pitfalls to Avoid
Do not push AVIF quality too low just because the byte count looks good. Photographs may still look acceptable, while UI screenshots become unreadable. Review at least one photo, one screenshot, and one diagram before applying a global setting.
Do not omit sizes. Without it, the browser may assume the image occupies the full viewport width and download a larger candidate than the layout needs. This mistake is common in card grids.
Do not lazy-load the main hero image. Lazy loading below-the-fold content is useful, but delaying the visible image that defines the page can make LCP worse.
Do not ask Claude Code to implement CDN upload, admin UI, framework migration, and image conversion in one prompt. Start with the conversion script, then the component, then the CI check. Smaller diffs produce better reviews.
Prompt for Claude Code
Use a prompt with constraints, not just an outcome.
Create an image optimization script for jpg/png files in public/images/original.
Output files to public/images/optimized.
Generate 320, 640, 960, 1280, and 1920px widths in avif, webp, and jpg.
Do not generate a width larger than the original image.
Write manifest.json with src, width, format, and bytes.
Add package scripts named images:build and images:check.
Keep the diff minimal and do not touch unrelated files.
This gives Claude Code the boundaries it needs: where files live, which variants matter, what metadata to preserve, and how the result will be tested.
Verification Result
In Masa’s test, replacing raw 1920px PNG screenshots with this pipeline cut article image transfer by more than half. The failed experiment was AVIF quality 45 for code screenshots; it saved bytes but made text look soft. The stable setting was AVIF in the low 50s for photos, slightly higher WebP/JPEG checks for UI screenshots, and explicit priority only for the hero image.
The next useful step is to run npm run images:build and npm run images:check for one image category before expanding the rule to the whole site. Once the flow is stable, combine it with Claude Code workflow automation so image regressions are caught in pull requests.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Receipt Pattern: Record Scope, Proof, and Rollback
A permission receipt pattern for Claude Code: allowed actions, approval boundaries, proof commands, rollback, and revenue CTA checks.
Safe Agent Harness Design for Claude Code and Codex: Permissions, Checks, and Rollback
Build a practical agent harness for Claude Code and Codex with policy, planning, verification, and recovery layers.
Claude Code Subagents: A Practical Guide to Safe Agent Delegation
Claude Code subagent guide for safe parallel article and code work: delegation rules, prompts, pitfalls, and checks.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.