A powerful Cloudinary storage adapter for Payload CMS v3 that replaces local file storage with Cloudinary's cloud-based solution, providing automatic image optimization, transformations, and global CDN delivery.
- 🚀 Seamless Cloudinary Integration - Direct upload to Cloudinary with automatic URL generation
- 📁 Dynamic Folder Management - Type folder paths dynamically per upload
- 📂 Smart Folder Organization - Auto-create folders and move assets on folder change
- 🎨 Transformation Presets - Define reusable image transformation sets with multi-select support
- 📤 Upload Queue System - Handle large files with progress tracking and chunked uploads
- 🔒 Private Files with Signed URLs - Secure, time-limited access with per-file privacy control
- 🌊 Public Previews - Watermarked or blurred public previews for private files
- 🚫 Smart Re-upload Prevention - Prevents duplicate uploads when updating document fields
- 🌍 Global CDN - Fast content delivery worldwide
- 📱 Responsive Images - Automatic format and quality optimization
npm install payload-storage-cloudinary
# or
yarn add payload-storage-cloudinary
# or
pnpm add payload-storage-cloudinaryCLOUDINARY_CLOUD_NAME=your-cloud-name
CLOUDINARY_API_KEY=your-api-key
CLOUDINARY_API_SECRET=your-api-secretimport { buildConfig } from 'payload'
import { cloudinaryStorage } from 'payload-storage-cloudinary'
export default buildConfig({
// ... your config
plugins: [
cloudinaryStorage({
cloudConfig: {
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
},
collections: {
media: true, // Simple config - just works!
},
}),
],
})const Media: CollectionConfig = {
slug: 'media',
upload: {
disableLocalStorage: true, // Required - handled by Cloudinary
},
fields: [
{
name: 'alt',
type: 'text',
},
],
}When you upload a file, the plugin creates several URL fields:
url- The main URL field (always contains the original URL whenpreserveOriginal: true)originalUrl- Direct URL to the original file without any transformationstransformedUrl- URL with selected transformation presets applied (only when presets are selected)thumbnailURL- Always a 150x150 thumbnail for admin UI displaypublicTransformationUrl- Public URL with watermark/blur for private files (when enabled)previewUrl- Combines transformation presets with watermark/blur for private files
Important: When preserveOriginal: true (recommended), transformations are ONLY applied via URL parameters, never during upload. This ensures your original files remain untouched in Cloudinary.
collections: {
media: true, // Just works!
}collections: {
media: {
folder: 'website/uploads',
},
}collections: {
media: {
transformations: {
default: {
quality: 'auto',
fetch_format: 'auto',
},
preserveOriginal: true, // Recommended: keeps original file untransformed
},
},
}Allow users to organize uploads into custom folders:
collections: {
media: {
folder: {
path: 'uploads', // Default folder
enableDynamic: true, // Adds a text field for custom folder paths
fieldName: 'cloudinaryFolder', // Custom field name (optional)
},
},
}Features:
- Users can type paths like
products/2024orblog/images - Folders are automatically created in Cloudinary
- Smart Asset Moving: When you change the folder, the plugin moves the asset instead of re-uploading
- Uses Cloudinary's rename API to preserve the same asset
Users can select multiple transformation presets that will be combined:
import { cloudinaryStorage, commonPresets } from 'payload-storage-cloudinary'
collections: {
media: {
transformations: {
default: {
quality: 'auto',
fetch_format: 'auto',
},
presets: commonPresets, // Built-in presets
enablePresetSelection: true, // Shows multi-select dropdown
preserveOriginal: true, // Recommended
},
},
}Built-in Common Presets:
thumbnail- 150x150 thumb cropcard- 400x400 fill cropbanner- 1200x600 fill cropog-image- 1200x630 Open Graph sizeavatar- 200x200 circular crop with face detectionblur- Blurred preview imagegrayscale- Black and white conversionpixelate- Pixelated effect (20px blocks)
How it works:
- Users can select multiple presets (e.g., "Grayscale" + "Card")
- Transformations are combined and applied via URL only
- The
transformedUrlfield contains the URL with all selected transformations - Original file remains untouched in Cloudinary
Enable watermarked or blurred public previews for private files:
collections: {
media: {
privateFiles: true, // Enable per-file privacy control
transformations: {
preserveOriginal: true,
publicTransformation: {
enabled: true,
watermark: {
defaultText: 'PREVIEW',
style: {
fontFamily: 'Arial',
fontSize: 50,
color: 'rgb:808080',
opacity: 50,
angle: -45,
},
},
blur: {
effect: 'blur:2000',
quality: 30,
},
},
},
},
}How it works:
- Users mark files as private using the "Private File" checkbox
- They can enable "Public Preview" to generate a watermarked/blurred version
- Choose between "Watermark" or "Blur" transformation type
- The
publicTransformationUrlprovides a public URL with protection applied - The
previewUrlcombines both transformation presets AND watermark/blur
Handle large files with progress tracking:
collections: {
media: {
uploadQueue: {
enabled: true,
maxConcurrentUploads: 3,
enableChunkedUploads: true,
largeFileThreshold: 100, // MB - files larger use chunked upload
chunkSize: 20, // MB chunks
},
},
}File Size Limits:
- Files over 100MB automatically use Cloudinary's
upload_largeAPI - Cloudinary limits depend on your plan:
- Free plans: typically 10MB for images, 100MB for videos
- Paid plans: up to 1GB or more
import { buildConfig } from 'payload'
import { cloudinaryStorage, commonPresets } from 'payload-storage-cloudinary'
export default buildConfig({
collections: [
{
slug: 'media',
access: {
read: () => true, // Required for private files
},
hooks: {
afterRead: [
({ doc, req }) => {
// Check if file requires authentication
if ((doc.requiresSignedURL || doc.isPrivate) && !req.user) {
return null // Return null for unauthorized access
}
return doc
},
],
},
upload: {
disableLocalStorage: true,
},
fields: [
{
name: 'alt',
type: 'text',
},
],
},
],
plugins: [
cloudinaryStorage({
cloudConfig: {
cloud_name: process.env.CLOUDINARY_CLOUD_NAME!,
api_key: process.env.CLOUDINARY_API_KEY!,
api_secret: process.env.CLOUDINARY_API_SECRET!,
},
collections: {
media: {
// Folder configuration
folder: {
path: 'uploads',
enableDynamic: true,
},
// Transformation configuration
transformations: {
default: {
quality: 'auto',
fetch_format: 'auto',
},
presets: commonPresets,
enablePresetSelection: true,
preserveOriginal: true, // Recommended
// Public preview for private files
publicTransformation: {
enabled: true,
watermark: {
defaultText: 'PREVIEW',
style: {
fontFamily: 'Arial',
fontSize: 50,
color: 'rgb:808080',
opacity: 50,
angle: -45,
},
},
blur: {
effect: 'blur:2000',
quality: 30,
},
},
},
// Upload queue for large files
uploadQueue: {
enabled: true,
maxConcurrentUploads: 3,
enableChunkedUploads: true,
largeFileThreshold: 100, // MB
},
// Security
privateFiles: true,
// Options
resourceType: 'auto',
deleteFromCloudinary: true,
},
},
}),
],
})function ProductImage({ doc }) {
// Use transformedUrl if transformations were selected, otherwise use url
const imageUrl = doc.transformedUrl || doc.url
return (
<img
src={imageUrl}
alt={doc.alt}
width={doc.width}
height={doc.height}
/>
)
}Since transformations are applied via URL when preserveOriginal: true, you can easily create variations:
import { getTransformationUrl } from 'payload-storage-cloudinary'
// Create a thumbnail
const thumbnailUrl = getTransformationUrl({
publicId: doc.cloudinaryPublicId,
version: doc.cloudinaryVersion,
customTransformations: {
width: 200,
height: 200,
crop: 'fill',
gravity: 'auto',
}
})
// Create a responsive image
const responsiveUrl = getTransformationUrl({
publicId: doc.cloudinaryPublicId,
version: doc.cloudinaryVersion,
customTransformations: {
width: 'auto',
dpr: 'auto',
quality: 'auto',
fetch_format: 'auto',
}
})import { useSignedURL, createPrivateImageComponent } from 'payload-storage-cloudinary/client'
import React from 'react'
// Option 1: Use the hook
function MyPrivateImage({ doc }) {
const { url, loading, error } = useSignedURL('media', doc?.id, {
react: React // Required in Next.js
})
if (loading) return <div>Loading...</div>
if (error) return <div>Error loading image</div>
return url ? <img src={url} alt={doc.alt} /> : null
}
// Option 2: Use the pre-built component
const PrivateImage = createPrivateImageComponent(React)
<PrivateImage
doc={doc}
collection="media"
alt="My private image"
className="w-full h-auto"
/>function ProtectedImage({ doc }) {
if (!doc.isPrivate) {
// Public file - use normal URL
return <img src={doc.url} alt={doc.alt} />
}
if (doc.publicTransformationUrl) {
// Show watermarked/blurred preview
return (
<div>
<img src={doc.publicTransformationUrl} alt={`${doc.alt} - Preview`} />
<p>This is a preview. Login to see full quality.</p>
</div>
)
}
// No public preview available
return <div>This image requires authentication</div>
}The plugin now intelligently prevents re-uploads when you:
- Change transformation presets
- Update alt text or other fields
- Modify folder paths (uses rename instead)
- Toggle privacy settings
urlalways contains the original URL (whenpreserveOriginal: true)transformedUrlcontains URL with transformation presetspublicTransformationUrlcontains watermarked/blurred public previewpreviewUrlcombines presets with watermark/blur
- Transformation presets now support multiple selections
- Selected transformations are combined in order
- All transformations happen via URL, not during upload
- Frontend Transformations Guide - Applying transformations in your app
- Private Images Guide - Complete guide for private files
- Dynamic Folders Guide - Folder organization strategies
- Transformation Presets Guide - Creating and using presets
- Upload Queue Guide - Handling large files
- Public Transformation Guide - Watermarks and blur effects
Full TypeScript support with type definitions included:
import type {
CloudinaryStorageOptions,
CloudinaryCollectionConfig,
TransformationPreset,
SignedURLConfig,
TransformationConfig,
FolderConfig,
} from 'payload-storage-cloudinary'- Payload CMS v3.0.0 or higher
- Node.js 18 or higher
- Cloudinary account with API credentials
MIT
Built with ❤️ for the Payload CMS community.