Use Cases (Updated: 6/2/2026)

Claude Code Image Processing Guide: Sharp, Canvas, WebP/AVIF, and Upload Validation

Build safe image processing with Claude Code: Sharp, Canvas, EXIF stripping, WebP/AVIF, validation, and tests.

Claude Code Image Processing Guide: Sharp, Canvas, WebP/AVIF, and Upload Validation

Image processing is one of those features that looks small in a product ticket and becomes a production boundary very quickly. A user uploads a photo, the app makes thumbnails, and the page feels faster. Under the surface, you need upload validation, safe filenames, EXIF stripping, resizing, compression, format fallbacks, background jobs, privacy rules, and tests.

Claude Code can help because the implementation touches many small files at once. The risk is that a vague prompt like “optimize uploaded images” can produce code that trusts file.type, publishes the original filename, keeps metadata, converts every image to AVIF synchronously, and never tests a rotated phone photo. This article gives Claude Code a tighter implementation contract.

Use the primary references while reviewing generated code: the Claude Code documentation, Sharp resize API, Sharp output API, MDN File API, MDN Canvas toBlob, and the OWASP File Upload Cheat Sheet. For heavy browser-side work, pair this with the internal Claude Code Web Worker guide.

Choose The Processing Boundary

Do not start by choosing a format. Start by deciding where each responsibility belongs. Browser, synchronous server route, and background worker are different tools.

BoundaryGood fitBad fit
BrowserPreview, light resize, reducing transfer sizeTrusted validation, bulk AVIF conversion, privacy decisions
Server requestMIME validation, dimensions, EXIF stripping, small thumbnailsDozens of variants, slow AVIF generation
Background jobProduct image sets, CMS regeneration, migration of old assetsUpload responses that must return immediately

The practical pattern is: shrink in the browser when it improves UX, validate again on the server, and move expensive variants to a job. Browser hints such as accept="image/*" and file.type are not security boundaries. They help the user pick a file, but the server still owns the decision.

flowchart LR
  Browser["Browser preview / optional resize"]
  Upload["Upload endpoint"]
  Validate["Magic bytes, size, dimensions"]
  Store["Private raw storage"]
  Job["Background variants"]
  Public["Public WebP/JPEG/AVIF"]
  Browser --> Upload
  Upload --> Validate
  Validate --> Store
  Store --> Job
  Job --> Public

When prompting Claude Code, write this boundary directly: validate MIME and magic bytes on the server, never use the original filename in a public URL, call rotate() before output, avoid .withMetadata(), and make AVIF optional. Those small requirements remove many plausible but unsafe defaults.

Product Use Cases

The first realistic use case is an ecommerce or marketplace listing. Sellers upload large phone photos. The product needs a square thumbnail, a card image, a detail image, and sometimes a social sharing image. Over-compression hurts trust because buyers need to inspect texture, condition, labels, and color. Start with WebP, then add AVIF only after measuring encode time and byte savings.

The second use case is an avatar or team profile image. The important details are a reliable square crop, safe URLs, and privacy. A filename like alice-client-contract-final.png should not become a public asset path. EXIF can include camera metadata and sometimes location metadata, so server output should strip metadata even if the browser already resized the file.

The third use case is blog, help-center, or course screenshots. Text readability matters more than maximum compression. A tutorial screenshot that saves 80 KB but blurs a menu label is a failed optimization. This is especially relevant for monetized educational content where screenshots support a product CTA or a paid template.

The fourth use case is private SaaS attachments: invoices, verification images, support screenshots, or admin documents. These should not use a public uploads directory at all. They need private storage, access checks, deletion rules, and audit logs. The same image library can be used, but the delivery model is different.

Setup

The examples assume Node.js 20 or newer. You can adapt the same modules to Next.js, Express, Hono, Astro API routes, or a queue worker.

npm i sharp file-type p-limit
npm i -D tsx typescript @types/node
mkdir -p src public/uploads

Add a focused test command so image behavior can run in CI.

{
  "scripts": {
    "test:images": "node --import tsx --test src/**/*.test.ts"
  }
}

Validate Uploads And Safe Filenames

This module is the trust boundary. It checks magic bytes instead of the extension, reads dimensions with Sharp, rejects oversized files, and refuses animated or multi-page images for this particular upload path.

// src/image-policy.ts
import { randomUUID } from "node:crypto";
import { fileTypeFromBuffer } from "file-type";
import sharp from "sharp";

const MAX_BYTES = 6 * 1024 * 1024;
const MAX_PIXELS = 24_000_000;

const EXTENSION_BY_MIME = {
  "image/jpeg": ".jpg",
  "image/png": ".png",
  "image/webp": ".webp",
  "image/avif": ".avif",
} as const;

export type MimeType = keyof typeof EXTENSION_BY_MIME;

export type ImageUploadInfo = {
  mime: MimeType;
  extension: string;
  width: number;
  height: number;
  bytes: number;
  originalName: string;
};

function isAllowedMime(mime: string): mime is MimeType {
  return mime in EXTENSION_BY_MIME;
}

export async function assertImageUpload(
  buffer: Buffer,
  originalName = "upload",
): Promise<ImageUploadInfo> {
  if (buffer.byteLength === 0) {
    throw new Error("Empty file");
  }

  if (buffer.byteLength > MAX_BYTES) {
    throw new Error("Image must be 6 MB or smaller");
  }

  const detected = await fileTypeFromBuffer(buffer);

  if (!detected || !isAllowedMime(detected.mime)) {
    throw new Error("Unsupported image type");
  }

  const metadata = await sharp(buffer, { failOn: "error" }).metadata();

  if (!metadata.width || !metadata.height) {
    throw new Error("Image dimensions could not be read");
  }

  if (metadata.pages && metadata.pages > 1) {
    throw new Error("Animated images are not allowed here");
  }

  const pixels = metadata.width * metadata.height;

  if (pixels > MAX_PIXELS) {
    throw new Error("Image dimensions are too large");
  }

  return {
    mime: detected.mime,
    extension: EXTENSION_BY_MIME[detected.mime],
    width: metadata.width,
    height: metadata.height,
    bytes: buffer.byteLength,
    originalName,
  };
}

export function safeImageName(mime: MimeType): string {
  return `${randomUUID()}${EXTENSION_BY_MIME[mime]}`;
}

The original name can still be stored as a private display value if the product needs it. It should not be part of the public URL. Random names also avoid Unicode normalization bugs and duplicate overwrite mistakes.

Resize, Compress, And Strip EXIF With Sharp

Sharp is a strong default for server-side image processing in Node.js. The key detail is metadata behavior: apply orientation with rotate(), then avoid .withMetadata() unless you have a deliberate reason to keep metadata. For public web images, stripping metadata is usually the safer default.

// src/optimize-image.ts
import { mkdir } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";

type Variant = {
  kind: "thumb" | "card" | "hero";
  width: number;
  height?: number;
};

const VARIANTS: Variant[] = [
  { kind: "thumb", width: 320, height: 320 },
  { kind: "card", width: 640 },
  { kind: "hero", width: 1280 },
];

export type OptimizedImage = {
  src: string;
  width: number;
  height: number;
  bytes: number;
  format: "webp" | "avif";
};

export async function optimizeImage(
  buffer: Buffer,
  outputDir: string,
  baseName: string,
  makeAvif = false,
): Promise<OptimizedImage[]> {
  await mkdir(outputDir, { recursive: true });

  const results: OptimizedImage[] = [];

  for (const variant of VARIANTS) {
    const resized = sharp(buffer)
      .rotate()
      .resize({
        width: variant.width,
        height: variant.height,
        fit: variant.height ? "cover" : "inside",
        withoutEnlargement: true,
      });

    const webpName = `${baseName}-${variant.kind}.webp`;
    const webpInfo = await resized
      .clone()
      .webp({ quality: 78, effort: 4 })
      .toFile(path.join(outputDir, webpName));

    results.push({
      src: `/uploads/${webpName}`,
      width: webpInfo.width,
      height: webpInfo.height,
      bytes: webpInfo.size,
      format: "webp",
    });

    if (makeAvif) {
      const avifName = `${baseName}-${variant.kind}.avif`;
      const avifInfo = await resized
        .clone()
        .avif({ quality: 45, effort: 4 })
        .toFile(path.join(outputDir, avifName));

      results.push({
        src: `/uploads/${avifName}`,
        width: avifInfo.width,
        height: avifInfo.height,
        bytes: avifInfo.size,
        format: "avif",
      });
    }
  }

  return results;
}

AVIF is a useful output, not a universal rule. It often compresses well, but encoding can be much slower than WebP. Some operational tools and older delivery paths may still prefer WebP or JPEG. Keep a fallback and measure real images before converting a whole catalog.

Next.js Upload Route

Here is a minimal App Router endpoint. In production, replace public/uploads with object storage if images are private, sensitive, or user-specific.

// app/api/images/route.ts
import path from "node:path";
import { NextResponse } from "next/server";
import { assertImageUpload, safeImageName } from "@/src/image-policy";
import { optimizeImage } from "@/src/optimize-image";

export async function POST(request: Request) {
  const form = await request.formData();
  const file = form.get("image");

  if (!(file instanceof File)) {
    return NextResponse.json(
      { error: "image field is required" },
      { status: 400 },
    );
  }

  const buffer = Buffer.from(await file.arrayBuffer());
  const upload = await assertImageUpload(buffer, file.name);
  const storedName = safeImageName(upload.mime);
  const baseName = storedName.replace(/\.[^.]+$/, "");

  const variants = await optimizeImage(
    buffer,
    path.join(process.cwd(), "public", "uploads"),
    baseName,
    false,
  );

  return NextResponse.json({
    original: {
      width: upload.width,
      height: upload.height,
      bytes: upload.bytes,
    },
    variants,
  });
}

This endpoint only returns public media variants. For private attachments, store the raw object outside the public path and serve it through an authenticated route or signed URL.

Client-Side Resize For UX

Client-side resizing reduces transfer cost and gives the user a faster preview. It is not a substitute for server validation. Treat it as a comfort feature.

// src/resize-in-browser.ts
export async function resizeInBrowser(
  file: File,
  maxSide = 1600,
): Promise<File> {
  const bitmap = await createImageBitmap(file);
  const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
  const width = Math.round(bitmap.width * scale);
  const height = Math.round(bitmap.height * scale);

  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;

  const context = canvas.getContext("2d");

  if (!context) {
    throw new Error("Canvas 2D context is not available");
  }

  context.drawImage(bitmap, 0, 0, width, height);
  bitmap.close();

  const blob = await new Promise<Blob>((resolve, reject) => {
    canvas.toBlob(
      (result) => {
        if (result) resolve(result);
        else reject(new Error("Canvas export failed"));
      },
      "image/webp",
      0.82,
    );
  });

  const outputName = file.name.replace(/\.[^.]+$/, ".webp");

  return new File([blob], outputName, {
    type: blob.type || "image/webp",
    lastModified: Date.now(),
  });
}

Canvas re-encoding usually drops metadata, but privacy should still be enforced by the server output. Do not rely on browser AVIF export unless you have tested the exact browsers you support.

Background Jobs And Budgets

Image processing consumes CPU and memory. Keep synchronous upload work small: validate, store, and create the one thumbnail needed for the response. Generate heavy variants, AVIF versions, OGP images, and old-asset migrations in a job.

A practical starting budget is 80 KB for avatars at 320x320, 120 KB for card images around width 640, and 250 KB for hero images around width 1280. The numbers are not universal, but having a budget keeps Claude Code from picking “high quality” settings that break performance.

// src/batch-optimize.ts
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import pLimit from "p-limit";
import { assertImageUpload, safeImageName } from "./image-policy";
import { optimizeImage } from "./optimize-image";

export async function batchOptimize(inputDir: string, outputDir: string) {
  const files = await readdir(inputDir);
  const limit = pLimit(3);

  const jobs = files.map((file) =>
    limit(async () => {
      const sourcePath = path.join(inputDir, file);
      const buffer = await readFile(sourcePath);
      const upload = await assertImageUpload(buffer, file);
      const baseName = safeImageName(upload.mime).replace(/\.[^.]+$/, "");
      const variants = await optimizeImage(buffer, outputDir, baseName, true);

      return {
        file,
        variants: variants.length,
      };
    }),
  );

  return Promise.allSettled(jobs);
}

With a real queue, store job ID, source image ID, variant type, failure reason, retry count, and generated asset paths. Otherwise you will eventually have files on disk that no database row can explain.

Failure Modes To Ask Claude Code To Review

The common failures are concrete. The code trusts file.type instead of inspecting bytes. It uses the user-provided filename in a public URL. It forgets phone orientation and ships sideways images. It keeps EXIF because .withMetadata() was copied from an unrelated snippet. It converts every upload to AVIF inside the request path. It accepts transparent PNGs and then places them on a white background without review.

Another product failure is optimizing the image while damaging the conversion path. If a course screenshot is unreadable, the paid template CTA loses trust. If a product image is late because AVIF is generated synchronously, the purchase button may be visible before the product is understandable. Pair this article with Claude Code analytics implementation if you need to measure image load, CTA click, and purchase paths together.

Test The Behavior

Test generated images, not only uploaded fixtures. A generated image keeps the test self-contained and makes CI easier.

// src/image-policy.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import sharp from "sharp";
import { assertImageUpload, safeImageName } from "./image-policy";
import { optimizeImage } from "./optimize-image";

test("validates and optimizes a generated image", async () => {
  const input = await sharp({
    create: {
      width: 1200,
      height: 800,
      channels: 3,
      background: "#38bdf8",
    },
  })
    .jpeg()
    .toBuffer();

  const info = await assertImageUpload(input, "masa-profile.jpg");
  assert.equal(info.mime, "image/jpeg");
  assert.equal(info.width, 1200);

  const safeName = safeImageName(info.mime);
  assert.match(safeName, /^[a-f0-9-]+\.jpg$/);

  const outDir = await mkdtemp(path.join(tmpdir(), "images-"));
  const baseName = safeName.replace(/\.[^.]+$/, "");
  const variants = await optimizeImage(input, outDir, baseName, false);

  assert.equal(variants.length, 3);
  assert.ok(variants.every((item) => item.bytes > 0));

  const thumb = await sharp(
    path.join(outDir, `${baseName}-thumb.webp`),
  ).metadata();

  assert.equal(thumb.width, 320);
  assert.equal(thumb.height, 320);
  assert.equal(thumb.exif, undefined);
});

Manual testing should include mobile width, slow network, broken files, huge files, rotated phone images, transparent PNG, and screenshots with small text. Ask Claude Code for a review pass that focuses only on validation, filenames, metadata, CPU cost, fallbacks, and missing tests.

Monetization CTA And Verification Note

Image processing protects revenue when it helps the reader or buyer trust what they see. Fast product images, readable screenshots, safe profile photos, and reliable social images all support the next click. Start with the free Claude Code cheatsheet, use ClaudeCodeLab products for reusable prompts and CLAUDE.md templates, and use training or consultation when your team needs upload rules, review gates, and verification scripts built into a real repository.

On June 2, 2026, Masa tested this flow in a small Next.js project. The best result came from telling Claude Code the boundary first: browser resize is optional, server validation is mandatory, AVIF is optional, and original filenames must not become public URLs. The vague prompt “build image upload” produced weaker code: file.type validation, public original names, missing orientation handling, and synchronous AVIF generation. The quality jump came from providing budgets and failure cases before implementation.

#Claude Code #image processing #Sharp #WebP #upload validation
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.