.cursor/skills/cloudinary-expert/SKILL.md
Expert in Cloudinary image management, upload patterns, transformations, optimization, and integration with this Next.js project. Use this skill for Cloudinary upload issues, image optimization, signature generation, or folder management.
npx skillsauth add ripgraphics/authorsinfo cloudinary-expertInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
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:
CLOUDINARY_CLOUD_NAME (without NEXT_PUBLIC_)NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME^2.8.0 (SDK for advanced operations)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.
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:
f_webp)images tableTransformation 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}`
}
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:
f_webp,q_95)authorsinfo/{entityType}_photos folderimages tablealbum_images tableLocation: 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 determinationFeatures:
Folder Mapping:
let folderType = imageType // Default
if (originalType === 'entityHeaderCover') {
folderType = 'entity_header_cover'
} else if (originalType === 'bookCover') {
folderType = 'book_cover'
}
const folderPath = `authorsinfo/${folderType}`
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:
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:
key1=value1&key2=value2&...apiSecretWebP 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',
}
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
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.
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
}
}
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:
https://res.cloudinary.com/{cloud_name}/image/upload/...https://api.cloudinary.com/{cloud_name}/image/upload/...blob: URLsdata: URLsfile://, C:\, /Users/)Pattern:
const cloudinaryPattern =
/^https?:\/\/(res|api)\.cloudinary\.com\/[^\/]+\/(image|video)\/upload\//
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:
authorsinfo/link_previews folderDelete Function:
import { deleteOptimizedImage } from '@/lib/link-preview/image-optimizer'
await deleteOptimizedImage(publicId: string)
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}`)
}
}
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...
}
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
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 stringImages 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_images table for photo galleriesauthorsinfo/{type})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')
}
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
}, {})
Cause: Database operations failed after Cloudinary upload.
Solution: Implement rollback pattern (see Error Handling section).
Cause: Too many requests to Cloudinary API.
Solution: Implement exponential backoff and retry logic.
Cause: URL doesn't match Cloudinary pattern or is a blob/data URL.
Solution: Use isValidCloudinaryUrl() before saving.
// 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 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)
import { isValidCloudinaryUrl } from '@/lib/utils/image-url-validation'
const isValid = isValidCloudinaryUrl(url)
if (!isValid) {
console.error('Invalid Cloudinary URL:', url)
}
When reviewing Cloudinary code:
app/actions/upload.ts - Base64 uploadapp/actions/upload-photo.ts - File uploadapp/api/upload/entity-image/route.ts - Entity image APIapp/api/cloudinary/signature/route.ts - Signature generationapp/api/cloudinary/delete/route.ts - Delete operationapp/api/cloudinary/list/route.ts - List resourceslib/utils/image-url-validation.ts - URL validationlib/link-preview/image-optimizer.ts - Link preview optimizationapp/actions/bulk-import-books.ts - Bulk book import with imagesapp/actions/bulk-add-books-from-search.ts - Search-based importtools
Webpack build optimization expert with deep knowledge of configuration patterns, bundle analysis, code splitting, module federation, performance optimization, and plugin/loader ecosystem. Use PROACTIVELY for any Webpack bundling issues including complex optimizations, build performance, custom plugins/loaders, and modern architecture patterns. If a specialized expert is a better fit, I will recommend switching and stop.
development
Web application security expert. OWASP Top 10, XSS, SQLi, CSRF, SSRF, authentication bypass, IDOR. Use for web app security testing.
testing
Vitest testing framework expert for Vite integration, Jest migration, browser mode testing, and performance optimization
tools
Vite build optimization expert with deep knowledge of ESM-first development, HMR optimization, plugin ecosystem, production builds, library mode, and SSR configuration. Use PROACTIVELY for any Vite bundling issues including dev server performance, build optimization, plugin development, and modern ESM patterns. If a specialized expert is a better fit, I will recommend switching and stop.