Cloudinary Expert
You are an expert in Cloudinary integration for this Next.js project, with deep knowledge of upload patterns, image transformations, optimization strategies, signature generation, and folder management.
Project Configuration
Environment Variables
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your_cloud_name # For client-side operations
Important:
- Server-side operations use
CLOUDINARY_CLOUD_NAME(without NEXT_PUBLIC_) - Client-side operations may need
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME - Always validate all three credentials are set before operations
Package Dependencies
- cloudinary:
^2.8.0(SDK for advanced operations) - crypto: Node.js built-in (for signature generation)
Folder Structure
The project uses a consistent folder structure in Cloudinary:
authorsinfo/
├── book_cover/ # Book cover images
├── book_entity_header_cover/ # Book entity header covers
├── authorimage/ # Author profile images
├── entity_header_cover/ # Generic entity header covers
├── user_photos/ # User uploaded photos
├── {entityType}_photos/ # Dynamic entity photo folders (e.g., author_photos, publisher_photos)
└── link_previews/ # Optimized link preview images
Pattern: All images are stored under authorsinfo/ prefix for organization.
Upload Patterns
1. Base64 Image Upload (Server Action)
Location: app/actions/upload.ts
import { uploadImage } from '@/app/actions/upload'
// Upload with base64 string
const result = await uploadImage(
base64Image: string, // Base64 string or data URL
folder = 'general', // Cloudinary folder path
alt_text = '', // Alt text for database
maxWidth?: number, // Optional max width
maxHeight?: number, // Optional max height
img_type_id?: string // Optional image type ID
)
Features:
- Automatically converts to WebP format (
f_webp) - Handles both raw base64 and data URLs
- Generates signed uploads with SHA1 signature
- Saves to Supabase
imagestable - Returns Cloudinary URL and database record
Transformation Pattern:
let transformationString = 'f_webp' // Always WebP
if (maxWidth && maxHeight) {
transformationString += `,c_fit,w_${maxWidth},h_${maxHeight}`
} else if (maxWidth) {
transformationString += `,c_fit,w_${maxWidth}`
} else if (maxHeight) {
transformationString += `,c_fit,h_${maxHeight}`
}
2. File Upload (Server Action)
Location: app/actions/upload-photo.ts
import { uploadPhoto } from '@/app/actions/upload-photo'
const result = await uploadPhoto(
file: File, // File object from form
entityType: string, // 'author', 'publisher', 'book', etc.
entityId: string, // Entity ID
albumId?: string // Optional album ID
)
Features:
- Validates file type (images only)
- Validates file size (10MB limit)
- Converts to WebP with 95% quality (
f_webp,q_95) - Stores in
authorsinfo/{entityType}_photosfolder - Creates database record in
imagestable - Optionally links to album via
album_imagestable
3. Entity Image Upload (API Route)
Location: app/api/upload/entity-image/route.ts
Endpoint: POST /api/upload/entity-image
FormData Parameters:
file: File objectentityType: Entity type (author, publisher, book, etc.)entityId: Entity IDimageType: Image type (avatar, bookCover, entityHeaderCover)originalType: Original type for folder determination
Features:
- Comprehensive error handling with rollback
- Validates Cloudinary URL before saving
- Deletes from Cloudinary if database operations fail
- Supports multiple image types with folder mapping
- Updates entity records with image relationships
Folder Mapping:
let folderType = imageType // Default
if (originalType === 'entityHeaderCover') {
folderType = 'entity_header_cover'
} else if (originalType === 'bookCover') {
folderType = 'book_cover'
}
const folderPath = `authorsinfo/${folderType}`
4. Cloudinary SDK Upload (Advanced)
Location: lib/link-preview/image-optimizer.ts, app/actions/bulk-import-books.ts
import { v2 as cloudinary } from 'cloudinary'
// Configure once
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
})
// Upload with transformations
const result = await cloudinary.uploader.upload(imageUrl, {
folder: 'authorsinfo/book_cover',
transformation: [
{
width: 1200,
height: 630,
crop: 'fill',
quality: 'auto',
fetch_format: 'auto',
},
],
resource_type: 'image',
})
Use Cases:
- Bulk imports from external URLs
- Link preview optimization
- Advanced transformations
- Stream-based uploads
Signature Generation
Server-Side Signature API
Location: app/api/cloudinary/signature/route.ts
Endpoint: POST /api/cloudinary/signature
Request Body:
{
"folder": "book_cover"
}
Response:
{
"timestamp": 1234567890,
"signature": "abc123...",
"folder": "authorsinfo/book_cover",
"apiKey": "your_api_key",
"cloudName": "your_cloud_name"
}
Signature Generation Pattern:
// 1. Create timestamp
const timestamp = Math.round(new Date().getTime() / 1000)
// 2. Prepare parameters
const params: Record<string, string> = {
timestamp: timestamp.toString(),
folder: folderPath,
// Add other params (transformation, etc.)
}
// 3. Sort alphabetically (REQUIRED)
const sortedParams = Object.keys(params)
.sort()
.reduce((acc: Record<string, string>, key) => {
acc[key] = params[key]
return acc
}, {})
// 4. Create signature string
const signatureString =
Object.entries(sortedParams)
.map(([key, value]) => `${key}=${value}`)
.join('&') + apiSecret
// 5. Generate SHA1 hash
const signature = crypto.createHash('sha1')
.update(signatureString)
.digest('hex')
Critical Rules:
- Parameters MUST be sorted alphabetically
- Signature string format:
key1=value1&key2=value2&...apiSecret - Use SHA1 hash (not SHA256)
- Include timestamp in seconds (not milliseconds)
Image Transformations
Standard Transformations
WebP Conversion (Always Applied):
transformation: 'f_webp' // Format: WebP
transformation: 'f_webp,q_95' // Format: WebP, Quality: 95%
Resizing:
// Fit to dimensions (maintains aspect ratio)
transformation: 'c_fit,w_1200,h_630'
// Fill (crops to fit)
transformation: 'c_fill,w_1200,h_630'
// Scale (maintains aspect ratio, may not match exact dimensions)
transformation: 'c_scale,w_1200'
Link Preview Optimization:
// Main image: 1200x630 (Open Graph standard)
{
width: 1200,
height: 630,
crop: 'fill',
quality: 'auto',
fetch_format: 'auto',
}
// Thumbnail: 400x210
{
width: 400,
height: 210,
crop: 'fill',
quality: 'auto',
fetch_format: 'auto',
}
Transformation String Format
Cloudinary uses comma-separated transformation parameters:
// Format: f_webp
// Crop: c_fit, c_fill, c_scale
// Width: w_1200
// Height: h_630
// Quality: q_95, q_auto
// Examples:
'f_webp' // WebP only
'f_webp,q_95' // WebP with quality
'f_webp,c_fit,w_1200,h_630' // WebP, fit, dimensions
'f_webp,q_auto,c_fill,w_1200,h_630' // Full optimization
Delete Operations
Delete API Route
Location: app/api/cloudinary/delete/route.ts
Endpoint: POST /api/cloudinary/delete
Request Body:
{
"publicId": "authorsinfo/book_cover/image_id"
}
Implementation:
// 1. Generate signature for delete
const timestamp = Math.round(new Date().getTime() / 1000)
const signature = crypto.createHash('sha1')
.update(`public_id=${publicId}×tamp=${timestamp}${apiSecret}`)
.digest('hex')
// 2. Create form data
const formData = new FormData()
formData.append('public_id', publicId)
formData.append('api_key', apiKey)
formData.append('timestamp', timestamp.toString())
formData.append('signature', signature)
// 3. Delete via API
const response = await fetch(
`https://api.cloudinary.com/v1_1/${cloudName}/image/destroy`,
{
method: 'POST',
body: formData,
}
)
SDK Delete:
await cloudinary.uploader.destroy(publicId)
Important: Always delete from Cloudinary when database operations fail to prevent orphaned assets.
List Operations
List API Route
Location: app/api/cloudinary/list/route.ts
Endpoint: GET /api/cloudinary/list?folder=authorsinfo/book_cover&max_results=50
Query Parameters:
folder: Folder prefix to filter (optional)max_results: Maximum number of results (default: 50)
Response:
{
"success": true,
"data": {
"resources": [...],
"next_cursor": "cursor_string",
"total_count": 50
}
}
URL Validation
Validation Utilities
Location: lib/utils/image-url-validation.ts
Functions:
// Validate Cloudinary URL
isValidCloudinaryUrl(url: string | null | undefined): boolean
// Validate and sanitize
validateAndSanitizeImageUrl(url: string | null | undefined): string | null
// Check if URL should be rejected
shouldRejectUrl(url: string | null | undefined): boolean
// Add cache-busting
addCacheBusting(url: string | null | undefined): string | null
Validation Rules:
- ✅ Accepts:
https://res.cloudinary.com/{cloud_name}/image/upload/... - ✅ Accepts:
https://api.cloudinary.com/{cloud_name}/image/upload/... - ❌ Rejects:
blob:URLs - ❌ Rejects:
data:URLs - ❌ Rejects: Local file paths (
file://,C:\,/Users/)
Pattern:
const cloudinaryPattern =
/^https?:\/\/(res|api)\.cloudinary\.com\/[^\/]+\/(image|video)\/upload\//
Link Preview Optimization
Image Optimizer
Location: lib/link-preview/image-optimizer.ts
Function:
import { optimizeLinkPreviewImage } from '@/lib/link-preview/image-optimizer'
const result = await optimizeLinkPreviewImage(
imageUrl: string,
linkId?: string
)
Returns:
{
original_url: string
optimized_url: string // 1200x630 main image
thumbnail_url: string // 400x210 thumbnail
width: number
height: number
format: 'webp' | 'avif' | 'jpeg' | 'png'
size_bytes: number
cloudinary_public_id: string
}
Process:
- Validates image URL
- Downloads image with timeout (10 seconds)
- Uploads optimized main image (1200x630)
- Uploads thumbnail (400x210)
- Stores in
authorsinfo/link_previewsfolder - Returns both URLs and metadata
Delete Function:
import { deleteOptimizedImage } from '@/lib/link-preview/image-optimizer'
await deleteOptimizedImage(publicId: string)
Error Handling & Rollback
Rollback Pattern
When database operations fail after Cloudinary upload:
try {
// 1. Upload to Cloudinary
const cloudinaryResult = await uploadToCloudinary(...)
// 2. Save to database
const dbResult = await saveToDatabase(...)
if (!dbResult.success) {
// 3. ROLLBACK: Delete from Cloudinary
await deleteFromCloudinary(cloudinaryResult.public_id)
throw new Error('Database save failed, image removed from Cloudinary')
}
} catch (error) {
// Cleanup on any error
if (cloudinaryResult?.public_id) {
await deleteFromCloudinary(cloudinaryResult.public_id)
}
throw error
}
Helper Function (from app/api/upload/entity-image/route.ts):
async function deleteFromCloudinary(publicId: string): Promise<void> {
const cloudName = process.env.CLOUDINARY_CLOUD_NAME
const apiKey = process.env.CLOUDINARY_API_KEY
const apiSecret = process.env.CLOUDINARY_API_SECRET
if (!cloudName || !apiKey || !apiSecret) {
console.error('⚠️ Cannot delete from Cloudinary: credentials not configured')
return
}
const timestamp = Math.round(new Date().getTime() / 1000)
const signature = crypto.createHash('sha1')
.update(`public_id=${publicId}×tamp=${timestamp}${apiSecret}`)
.digest('hex')
const formData = new FormData()
formData.append('public_id', publicId)
formData.append('api_key', apiKey)
formData.append('timestamp', timestamp.toString())
formData.append('signature', signature)
const response = await fetch(
`https://api.cloudinary.com/v1_1/${cloudName}/image/destroy`,
{ method: 'POST', body: formData }
)
if (response.ok) {
console.log(`✅ Rollback: Deleted orphaned image from Cloudinary: ${publicId}`)
} else {
console.error(`⚠️ Rollback failed: Could not delete image from Cloudinary: ${publicId}`)
}
}
Rate Limiting
Handling Rate Limits
Cloudinary may return 429 Too Many Requests. Handle gracefully:
if (!response.ok) {
if (response.status === 429) {
console.error('Cloudinary rate limit exceeded. Waiting before retrying...')
await new Promise((resolve) => setTimeout(resolve, 1000))
throw new Error('Cloudinary rate limit exceeded. Please try again in a moment.')
}
// Handle other errors...
}
Public ID Extraction
Extract Public ID from URL
function extractPublicIdFromUrl(url: string): string | null {
if (!url || !url.includes('cloudinary.com')) return null
// Pattern: https://res.cloudinary.com/{cloud_name}/image/upload/{folder}/public_id.ext
const match = url.match(/\/upload\/(.+?)(?:\.[^.]+)?$/)
return match ? match[1] : null
}
// Example:
// URL: https://res.cloudinary.com/demo/image/upload/authorsinfo/book_cover/my_image
// Returns: authorsinfo/book_cover/my_image
Database Integration
Images Table Schema
Images are stored in Supabase images table with:
url: Cloudinary secure URLstorage_provider: Always'cloudinary'storage_path: Folder path (e.g.,authorsinfo/book_cover)cloudinary_public_id: Public ID for deletionoriginal_filename: Original file namefile_size: File size in bytesmime_type: MIME typeis_processed: Booleanprocessing_status: Status string
Entity Relationships
Images are linked to entities via:
book_cover_image_id→images.idauthor_image_id→images.idpublisher_image_id→images.identity_header_cover_image_id→images.idalbum_imagestable for photo galleries
Best Practices
✅ DO
- Always validate Cloudinary URLs before saving to database
- Use rollback pattern when database operations fail
- Generate signatures correctly (sorted params, SHA1)
- Convert to WebP for better performance
- Use appropriate folder structure (
authorsinfo/{type}) - Store public_id in database for easy deletion
- Handle rate limits gracefully
- Validate file types and sizes before upload
- Use signed uploads for security
- Clean up orphaned assets when operations fail
❌ DON'T
- Don't store blob/data URLs in database
- Don't skip signature validation in folder paths
- Don't forget to sort parameters for signatures
- Don't use SHA256 for signatures (use SHA1)
- Don't hardcode credentials (use environment variables)
- Don't skip error handling and rollback
- Don't upload without transformations (always optimize)
- Don't ignore rate limits (implement retry logic)
- Don't store local file paths in database
- Don't create orphaned assets (always clean up on failure)
Common Issues & Solutions
Issue: "Invalid folder path" Error
Cause: Folder path contains invalid characters or doesn't match pattern.
Solution:
// Validate folder path
if (!folder || typeof folder !== 'string' || !folder.match(/^[a-zA-Z0-9_/-]+$/)) {
throw new Error('Invalid folder path')
}
Issue: Signature Validation Failed
Cause: Parameters not sorted or signature string format incorrect.
Solution:
// Always sort parameters alphabetically
const sortedParams = Object.keys(params)
.sort()
.reduce((acc, key) => {
acc[key] = params[key]
return acc
}, {})
Issue: Orphaned Images in Cloudinary
Cause: Database operations failed after Cloudinary upload.
Solution: Implement rollback pattern (see Error Handling section).
Issue: Rate Limit Errors
Cause: Too many requests to Cloudinary API.
Solution: Implement exponential backoff and retry logic.
Issue: Invalid Cloudinary URL
Cause: URL doesn't match Cloudinary pattern or is a blob/data URL.
Solution: Use isValidCloudinaryUrl() before saving.
Testing & Validation
Validate Configuration
// Check environment variables
const cloudName = process.env.CLOUDINARY_CLOUD_NAME
const apiKey = process.env.CLOUDINARY_API_KEY
const apiSecret = process.env.CLOUDINARY_API_SECRET
if (!cloudName || !apiKey || !apiSecret) {
throw new Error('Cloudinary credentials are not properly configured')
}
Test Upload
// Test with minimal image
const testImage = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
const result = await uploadImage(testImage, 'test', 'Test image')
console.log('Upload successful:', result.url)
Verify URL Format
import { isValidCloudinaryUrl } from '@/lib/utils/image-url-validation'
const isValid = isValidCloudinaryUrl(url)
if (!isValid) {
console.error('Invalid Cloudinary URL:', url)
}
Code Review Checklist
When reviewing Cloudinary code:
- Environment variables are validated before use
- Signatures are generated with sorted parameters
- SHA1 hash is used (not SHA256)
- Folder paths are validated for security
- Rollback pattern is implemented for failed database operations
- Cloudinary URLs are validated before saving
- File types and sizes are validated
- Transformations are applied (WebP conversion)
- Public IDs are stored for deletion
- Error handling includes rate limit handling
- No blob/data URLs are stored in database
- Appropriate folder structure is used
- Cache-busting is considered for updated images
Related Files
Core Upload Functions
app/actions/upload.ts- Base64 uploadapp/actions/upload-photo.ts- File uploadapp/api/upload/entity-image/route.ts- Entity image API
API Routes
app/api/cloudinary/signature/route.ts- Signature generationapp/api/cloudinary/delete/route.ts- Delete operationapp/api/cloudinary/list/route.ts- List resources
Utilities
lib/utils/image-url-validation.ts- URL validationlib/link-preview/image-optimizer.ts- Link preview optimization
Bulk Operations
app/actions/bulk-import-books.ts- Bulk book import with imagesapp/actions/bulk-add-books-from-search.ts- Search-based import
Resources
- Cloudinary Documentation
- Cloudinary Transformation Reference
- Cloudinary Upload API
- Cloudinary Node.js SDK
Converted and distributed by TomeVault — claim your Tome and manage your conversions.