How to Add Image Optimization with Cloudinary 2026
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 |
Video Upload and Optimization
Cloudinary handles video with the same transformation URL pattern as images. The resource_type: 'video' parameter on upload routes the file through Cloudinary's video transcoding pipeline, and you get format conversion, thumbnail extraction, and adaptive bitrate streaming without additional infrastructure.
Upload video from a file path or stream:
const result = await cloudinary.uploader.upload('product-demo.mp4', {
resource_type: 'video',
folder: 'videos',
public_id: 'product-demo',
// Auto-generate thumbnails at 5 timestamps:
eager: [
{ format: 'jpg', width: 640, crop: 'fill', start_offset: '0%' },
{ format: 'jpg', width: 640, crop: 'fill', start_offset: '50%' },
],
eager_async: true, // Generate thumbnails asynchronously
});
Video transformations follow the same URL pattern. Resize, crop, change format, and adjust quality entirely via the URL:
// Convert to WebM, limit to 720p:
const webmUrl = cloudinary.url('videos/product-demo', {
resource_type: 'video',
format: 'webm',
width: 1280,
height: 720,
crop: 'limit',
quality: 'auto',
});
// Generate a GIF preview from first 5 seconds:
const gifPreview = cloudinary.url('videos/product-demo', {
resource_type: 'video',
format: 'gif',
end_offset: 5,
width: 480,
});
// Extract a thumbnail at 30 seconds:
const thumbnail = cloudinary.url('videos/product-demo', {
resource_type: 'video',
format: 'jpg',
start_offset: 30,
});
Adaptive Bitrate Streaming (HLS): Cloudinary can generate HLS streams for video that adapts quality based on the viewer's bandwidth. Generate an HLS manifest URL:
const hlsUrl = cloudinary.url('videos/product-demo', {
resource_type: 'video',
format: 'm3u8',
streaming_profile: 'hd', // Generates multiple bitrate renditions
});
// Use with video.js or HLS.js in the browser:
// <video src={hlsUrl} /> with an HLS-capable player
For the free tier, video transformation units are charged at the same rate as image transformations, but video is CPU-intensive — a 10-second video conversion can consume 20-50 transformation units depending on output format and resolution.
AI-Powered Transformations
Cloudinary's AI features operate as transformation parameters in the URL, making them composable with standard transformations. No additional API calls or model setup is required.
Background Removal uses Cloudinary's AI to detect the subject and remove the background, returning a PNG with transparent background. This is particularly useful for product photography:
const noBgUrl = cloudinary.url('products/shoe', {
effect: 'background_removal',
format: 'png',
});
// Or replace the background with a color or image:
const onWhiteUrl = cloudinary.url('products/shoe', {
transformation: [
{ effect: 'background_removal' },
{ background: 'white', effect: 'make_transparent:10' },
],
format: 'jpg',
});
Background removal is available on paid plans (Plus and above). Each background removal counts as 5 transformation units. For product catalogs with hundreds of images, bulk background removal via a script is more cost-effective than on-demand URL-based removal.
Generative Fill uses AI to extend an image beyond its original borders, filling the new area with content that matches the existing image — useful for changing aspect ratios without cropping:
// Extend a 3:2 landscape image to 16:9 without cropping:
const extendedUrl = cloudinary.url('photography/landscape', {
width: 1920,
height: 1080,
crop: 'pad',
background: 'gen_fill', // AI fills the padded areas
});
Content-Aware Cropping uses object detection to crop images while keeping the most important subject in frame. The gravity: 'auto' setting activates this — Cloudinary's model detects faces, products, and other subjects:
const smartCropUrl = cloudinary.url('user/profile-photo', {
width: 400,
height: 400,
crop: 'thumb',
gravity: 'auto', // AI-detected subject stays centered
});
// For specific subject detection:
const faceUrl = cloudinary.url('team/headshot', {
width: 200,
height: 200,
crop: 'thumb',
gravity: 'face', // Face-detection crop
zoom: 0.75, // Zoom out slightly for padding
});
Cost Management and Monitoring
Cloudinary's free tier (25K transformations, 25GB storage, 25GB bandwidth) is generous for development but runs out quickly in production. Understanding what counts as a "transformation" prevents unexpected bills.
What counts as a transformation: each unique combination of transformation parameters applied to a base image is one transformation. Serving the same transformed version multiple times uses bandwidth, not transformations — the result is cached at Cloudinary's CDN. The practical implication: avoid generating unique transformations with user-specific data embedded in the URL (e.g., user IDs in overlay text), as each unique URL is a new transformation.
Named Transformations pre-define common transformation chains and reference them by name, which makes URLs shorter and easier to audit:
// Create a named transformation via API:
await cloudinary.api.create_transformation('product_thumb', {
transformation: [
{ width: 400, height: 400, crop: 'fill', gravity: 'auto' },
{ quality: 'auto', fetch_format: 'auto' },
],
});
// Reference it in URLs:
const url = cloudinary.url('products/photo1', {
transformation: [{ named: 'product_thumb' }],
});
// Output: .../t_product_thumb/products/photo1
Named transformations also allow you to change a transformation globally — update the named transformation definition, and all URLs using it automatically serve the new version (after the CDN cache expires). This is far better than having hundreds of hardcoded transformation strings scattered across your codebase.
Monitoring usage: Cloudinary's dashboard shows monthly transformation count, storage, and bandwidth consumption. Set up email alerts at 80% of your plan limits — cloudinary.api.usage() returns current month consumption via API, which you can poll in a daily cron job and alert on:
const usage = await cloudinary.api.usage();
console.log({
storage_used_gb: usage.storage.used / 1e9,
transformations_used: usage.transformations.usage,
transformations_limit: usage.transformations.limit,
bandwidth_used_gb: usage.bandwidth.used / 1e9,
});
Transformation caching is automatic but only applies to the same transformation parameters. Avoid dynamic parameters like timestamps or user-specific overlays in URLs — use separate Cloudinary accounts for user-generated content (where transformation counts are unpredictable) and application assets (where transformation sets are finite and stable).
Cloudinary vs. alternative image CDNs is worth understanding before committing. Cloudflare Images ($5/month for 100K images, unlimited transformations) and imgix (usage-based, starting at $25/month) both offer similar on-the-fly transformation capabilities. Cloudinary's advantage is its media management platform: the upload widget, asset management UI, AI features, and video support go beyond what pure image CDNs offer. For simple responsive image serving without AI features or video, Cloudflare Images or imgix can be meaningfully cheaper at scale. For applications that need background removal, generative fill, video optimization, and a media management interface — not just image resizing — Cloudinary is the most complete solution. If you're already on Vercel, the Vercel Image Optimization built into next/image handles resizing and format conversion automatically at no additional cost for most use cases, making a dedicated image CDN optional until you need features beyond basic optimization.
Setting up upload presets for unsigned client uploads is an alternative to signed uploads that simplifies the client-side code for lower-security contexts:
// Cloudinary dashboard: create an unsigned preset named 'user-avatars'
// with folder, size, and format restrictions configured
// Client-side upload using unsigned preset:
const formData = new FormData();
formData.append('file', file);
formData.append('upload_preset', 'user-avatars');
const res = await fetch(
`https://api.cloudinary.com/v1_1/${cloudName}/image/upload`,
{ method: 'POST', body: formData }
);
const result = await res.json();
Unsigned presets are appropriate for user-generated content where you've configured folder, transformation, and size restrictions in the preset itself. Use signed uploads (server-generated signature) when you need per-request control over destination folder, tags, or transformations.
Methodology
Cloudinary in Next.js App Router vs. Pages Router: the cloudinaryLoader function approach shown here works identically in both routing models. In the App Router, import next/image directly in server components for images — the cloudinaryLoader runs server-side and generates the srcset without any client-side JavaScript. For client components that need dynamic images (based on user input or API data), use 'use client' and import the loader normally. The next.config.js domains approach (adding res.cloudinary.com to the allowed domains list) is simpler to set up than a custom loader but provides less control over transformation parameters — you cannot inject quality or format parameters through the domains approach alone.
Folder structure conventions: organize Cloudinary assets into meaningful folder hierarchies from the start. The public ID includes the folder path (folder/subfolder/filename), so restructuring later requires renaming assets and updating all URL references. A typical convention for application assets: {env}/{content-type}/{identifier} — for example, prod/avatars/user_123, prod/products/sku_456, dev/test-images/sample. Keep development and production assets in separate folders or separate Cloudinary accounts to prevent mixing test data with production assets and to get clean usage metrics per environment.
Cloudinary SDK examples use cloudinary npm package v2.x (Node.js). Client-side upload security uses Cloudinary's signed upload model — API secret is never sent to the browser; all client uploads go through a signed payload generated server-side. Transformation URL structure follows Cloudinary's official transformation reference documentation as of March 2026. AI transformation features (background removal, generative fill) are available on Plus plan and above; feature availability and transformation unit cost verified from Cloudinary's pricing page as of March 2026. Video transformation billing (units per operation) varies by output format, duration, and resolution — consult Cloudinary's video transformation pricing page for exact costs. HLS streaming profile options (hd, full_hd, 4k) documented in Cloudinary's adaptive streaming guide. Next.js custom loader pattern based on Next.js 15.x Image component documentation. The cloudinaryLoader function runs at build time for statically imported images and at request time for dynamic image sources — there is no difference in how Next.js applies the loader between these two cases. AVIF format support in Cloudinary (f_avif) is available on all plans and typically reduces file size by an additional 30% compared to WebP, at the cost of slightly higher CPU on encode — Cloudinary handles encoding server-side, so the client always receives a pre-encoded file regardless of format.
Optimizing images? Compare Cloudinary vs Cloudflare Images vs imgix on APIScout — transformation features, pricing, and CDN performance.
Related: Best Image Recognition APIs for Developers, Cloudinary vs Cloudflare Images: Image CDN APIs, Cloudinary vs Imgix vs Cloudflare Images 2026