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 Setting | Use Case | Typical Savings |
|---|---|---|
q_auto:best | Hero images, portfolio | 20-30% smaller |
q_auto:good | Product images | 40-50% smaller |
q_auto:eco | Thumbnails, backgrounds | 60-70% smaller |
q_auto | General purpose | 40-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
| Plan | Price | Transformations | Storage | Bandwidth |
|---|---|---|---|---|
| Free | $0 | 25K/month | 25 GB | 25 GB |
| Plus | $89/month | 25K + $4/1K extra | 75 GB | 150 GB |
| Advanced | $224/month | 100K | 225 GB | 450 GB |
| Enterprise | Custom | Custom | Custom | Custom |
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
Not using f_auto | Serving PNG to Chrome (3x larger) | Always include f_auto |
Not using q_auto | Over-quality images waste bandwidth | Always include q_auto |
| Uploading without size limits | Original 20MB files stored | Set c_limit,w_2000 on upload |
| Generating URLs client-side with API secret | Secret exposed in browser | Use signed uploads or unsigned presets |
Not setting secure: true | HTTP URLs in mixed content | Always use HTTPS |
Optimizing images? Compare Cloudinary vs Cloudflare Images vs imgix on APIScout — transformation features, pricing, and CDN performance.