Skip to main content

How to Add Image Optimization with Cloudinary

·APIScout Team
cloudinaryimage optimizationmediatutorialperformance

How to Add Image Optimization with Cloudinary

Cloudinary handles image uploads, transformations, optimization, and CDN delivery. Upload an image, get back optimized versions in any size, format, and quality — served from a global CDN. This guide covers uploads, transformations, responsive images, and Next.js integration.

What You'll Build

  • Image upload (server and client-side)
  • On-the-fly transformations (resize, crop, effects)
  • Automatic format and quality optimization
  • Responsive images with art direction
  • Next.js Image component integration

Prerequisites: Cloudinary account (free: 25K transformations/month, 25GB storage).

1. Setup

npm install cloudinary
// lib/cloudinary.ts
import { v2 as cloudinary } from 'cloudinary';

cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME!,
  api_key: process.env.CLOUDINARY_API_KEY!,
  api_secret: process.env.CLOUDINARY_API_SECRET!,
  secure: true,
});

export default cloudinary;
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret

2. Upload Images

Server-Side Upload

// lib/upload.ts
import cloudinary from './cloudinary';

export async function uploadImage(filePath: string, options?: {
  folder?: string;
  publicId?: string;
  tags?: string[];
  transformation?: Record<string, any>;
}) {
  const result = await cloudinary.uploader.upload(filePath, {
    folder: options?.folder || 'uploads',
    public_id: options?.publicId,
    tags: options?.tags,
    transformation: options?.transformation,
    resource_type: 'image',
    // Automatic quality and format
    quality: 'auto',
    fetch_format: 'auto',
  });

  return {
    publicId: result.public_id,
    url: result.secure_url,
    width: result.width,
    height: result.height,
    format: result.format,
    bytes: result.bytes,
  };
}

// Upload from URL
export async function uploadFromUrl(imageUrl: string, folder: string = 'imports') {
  return cloudinary.uploader.upload(imageUrl, {
    folder,
    quality: 'auto',
    fetch_format: 'auto',
  });
}

// Upload from buffer
export async function uploadFromBuffer(buffer: Buffer, options?: {
  folder?: string;
  publicId?: string;
}) {
  return new Promise((resolve, reject) => {
    const stream = cloudinary.uploader.upload_stream(
      {
        folder: options?.folder || 'uploads',
        public_id: options?.publicId,
      },
      (error, result) => {
        if (error) reject(error);
        else resolve(result);
      }
    );
    stream.end(buffer);
  });
}

Client-Side Direct Upload

// 1. Server: Generate upload signature
// app/api/cloudinary/sign/route.ts
import { NextResponse } from 'next/server';
import cloudinary from '@/lib/cloudinary';

export async function POST() {
  const timestamp = Math.round(new Date().getTime() / 1000);
  const params = {
    timestamp,
    folder: 'user-uploads',
    transformation: 'c_limit,w_2000,h_2000',
  };

  const signature = cloudinary.utils.api_sign_request(
    params,
    process.env.CLOUDINARY_API_SECRET!
  );

  return NextResponse.json({
    signature,
    timestamp,
    cloudName: process.env.CLOUDINARY_CLOUD_NAME,
    apiKey: process.env.CLOUDINARY_API_KEY,
  });
}
// 2. Client: Upload directly to Cloudinary
// components/ImageUpload.tsx
'use client';
import { useState, useCallback } from 'react';

export function ImageUpload({ onUpload }: { onUpload: (url: string) => void }) {
  const [uploading, setUploading] = useState(false);
  const [preview, setPreview] = useState<string | null>(null);

  const handleUpload = useCallback(async (file: File) => {
    setUploading(true);

    // Get signature from server
    const signRes = await fetch('/api/cloudinary/sign', { method: 'POST' });
    const { signature, timestamp, cloudName, apiKey } = await signRes.json();

    // Upload directly to Cloudinary
    const formData = new FormData();
    formData.append('file', file);
    formData.append('signature', signature);
    formData.append('timestamp', String(timestamp));
    formData.append('api_key', apiKey);
    formData.append('folder', 'user-uploads');

    const uploadRes = await fetch(
      `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`,
      { method: 'POST', body: formData }
    );

    const result = await uploadRes.json();
    setPreview(result.secure_url);
    onUpload(result.secure_url);
    setUploading(false);
  }, [onUpload]);

  return (
    <div>
      <input
        type="file"
        accept="image/*"
        onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
        disabled={uploading}
      />
      {uploading && <p>Uploading...</p>}
      {preview && <img src={preview} alt="Preview" style={{ maxWidth: 300 }} />}
    </div>
  );
}

3. Image Transformations

URL-Based Transformations

// lib/transform.ts
import cloudinary from './cloudinary';

export function getImageUrl(publicId: string, options?: {
  width?: number;
  height?: number;
  crop?: 'fill' | 'fit' | 'limit' | 'thumb' | 'scale';
  gravity?: 'auto' | 'face' | 'center' | 'north' | 'south';
  quality?: 'auto' | 'auto:best' | 'auto:good' | 'auto:eco' | number;
  format?: 'auto' | 'webp' | 'avif' | 'jpg' | 'png';
  effect?: string;
  radius?: number | 'max';
}): string {
  return cloudinary.url(publicId, {
    width: options?.width,
    height: options?.height,
    crop: options?.crop || 'fill',
    gravity: options?.gravity || 'auto',
    quality: options?.quality || 'auto',
    fetch_format: options?.format || 'auto',
    effect: options?.effect,
    radius: options?.radius,
    secure: true,
  });
}

Common Transformations

// Thumbnail (200x200, face-aware crop)
const thumb = getImageUrl('products/photo1', {
  width: 200,
  height: 200,
  crop: 'thumb',
  gravity: 'face',
});

// Hero image (1200px wide, auto height)
const hero = getImageUrl('banners/hero', {
  width: 1200,
  crop: 'limit',
  quality: 'auto:best',
});

// Avatar (circular, 80x80)
const avatar = getImageUrl('users/avatar1', {
  width: 80,
  height: 80,
  crop: 'thumb',
  gravity: 'face',
  radius: 'max',
});

// Blurred placeholder
const placeholder = getImageUrl('products/photo1', {
  width: 20,
  effect: 'blur:1000',
  quality: 1,
});

Chained Transformations

// Multiple transformations in sequence
const url = cloudinary.url('products/photo1', {
  transformation: [
    { width: 800, height: 600, crop: 'fill', gravity: 'auto' },
    { effect: 'improve' }, // Auto-enhance
    { overlay: 'watermarks:logo', gravity: 'south_east', opacity: 50, width: 100 },
    { quality: 'auto', fetch_format: 'auto' },
  ],
  secure: true,
});

4. Responsive Images

Generate srcset

export function getResponsiveUrls(publicId: string, widths: number[] = [320, 640, 960, 1280, 1920]) {
  return widths.map(w => ({
    url: getImageUrl(publicId, { width: w, crop: 'limit' }),
    width: w,
  }));
}

// Generate HTML srcset string
export function getSrcSet(publicId: string): string {
  return getResponsiveUrls(publicId)
    .map(({ url, width }) => `${url} ${width}w`)
    .join(', ');
}

Responsive Image Component

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

interface CloudinaryImageProps {
  publicId: string;
  alt: string;
  width?: number;
  height?: number;
  sizes?: string;
  className?: string;
  priority?: boolean;
}

export function CloudinaryImage({
  publicId,
  alt,
  width,
  height,
  sizes = '100vw',
  className,
  priority = false,
}: CloudinaryImageProps) {
  const cloudName = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME;
  const baseUrl = `https://res.cloudinary.com/${cloudName}/image/upload`;

  const widths = [320, 640, 960, 1280, 1920];

  const srcSet = widths
    .map(w => `${baseUrl}/w_${w},c_limit,q_auto,f_auto/${publicId} ${w}w`)
    .join(', ');

  const src = `${baseUrl}/w_${width || 960},c_limit,q_auto,f_auto/${publicId}`;

  // Low-quality placeholder
  const blurPlaceholder = `${baseUrl}/w_20,e_blur:1000,q_1/${publicId}`;

  return (
    <img
      src={src}
      srcSet={srcSet}
      sizes={sizes}
      alt={alt}
      width={width}
      height={height}
      className={className}
      loading={priority ? 'eager' : 'lazy'}
      decoding="async"
      style={{ backgroundImage: `url(${blurPlaceholder})`, backgroundSize: 'cover' }}
    />
  );
}

5. Next.js Image Integration

Custom Loader

// lib/cloudinary-loader.ts
export function cloudinaryLoader({
  src,
  width,
  quality,
}: {
  src: string;
  width: number;
  quality?: number;
}): string {
  const cloudName = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME;
  const params = [
    `w_${width}`,
    `c_limit`,
    `q_${quality || 'auto'}`,
    `f_auto`,
  ].join(',');

  return `https://res.cloudinary.com/${cloudName}/image/upload/${params}/${src}`;
}
// Usage with Next.js Image
import Image from 'next/image';
import { cloudinaryLoader } from '@/lib/cloudinary-loader';

export function ProductImage({ publicId, alt }: { publicId: string; alt: string }) {
  return (
    <Image
      loader={cloudinaryLoader}
      src={publicId}
      alt={alt}
      width={800}
      height={600}
      sizes="(max-width: 768px) 100vw, 800px"
    />
  );
}

next.config.js Setup

// next.config.js
module.exports = {
  images: {
    domains: ['res.cloudinary.com'],
    // Or use the custom loader approach above
  },
};

6. Image Management

// Delete image
export async function deleteImage(publicId: string) {
  return cloudinary.uploader.destroy(publicId);
}

// Rename/move image
export async function renameImage(fromPublicId: string, toPublicId: string) {
  return cloudinary.uploader.rename(fromPublicId, toPublicId);
}

// List images in folder
export async function listImages(folder: string, maxResults: number = 30) {
  return cloudinary.api.resources({
    type: 'upload',
    prefix: folder,
    max_results: maxResults,
  });
}

// Get image metadata
export async function getImageInfo(publicId: string) {
  return cloudinary.api.resource(publicId, {
    colors: true,
    image_metadata: true,
  });
}

7. Performance Optimizations

Automatic Format Selection

Cloudinary picks the best format per browser:

  • Chrome/Edge → WebP or AVIF
  • Safari → WebP (AVIF on newer versions)
  • Older browsers → JPEG/PNG

Just use f_auto (or fetch_format: 'auto').

Quality Optimization

Quality SettingUse CaseTypical Savings
q_auto:bestHero images, portfolio20-30% smaller
q_auto:goodProduct images40-50% smaller
q_auto:ecoThumbnails, backgrounds60-70% smaller
q_autoGeneral purpose40-60% smaller

Lazy Loading Pattern

// Intersection Observer for lazy-loaded Cloudinary images
function useLazyCloudinary(publicId: string) {
  const [loaded, setLoaded] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setLoaded(true);
        observer.disconnect();
      }
    }, { rootMargin: '200px' });

    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);

  const placeholderUrl = getImageUrl(publicId, { width: 20, effect: 'blur:1000', quality: 1 });
  const fullUrl = loaded ? getImageUrl(publicId, { width: 800, quality: 'auto' }) : placeholderUrl;

  return { ref, src: fullUrl, loaded };
}

Pricing

PlanPriceTransformationsStorageBandwidth
Free$025K/month25 GB25 GB
Plus$89/month25K + $4/1K extra75 GB150 GB
Advanced$224/month100K225 GB450 GB
EnterpriseCustomCustomCustomCustom

Common Mistakes

MistakeImpactFix
Not using f_autoServing PNG to Chrome (3x larger)Always include f_auto
Not using q_autoOver-quality images waste bandwidthAlways include q_auto
Uploading without size limitsOriginal 20MB files storedSet c_limit,w_2000 on upload
Generating URLs client-side with API secretSecret exposed in browserUse signed uploads or unsigned presets
Not setting secure: trueHTTP URLs in mixed contentAlways use HTTPS

Optimizing images? Compare Cloudinary vs Cloudflare Images vs imgix on APIScout — transformation features, pricing, and CDN performance.

Comments