A Convex Component for Cloudinary integration that provides image upload, transformation, and management capabilities using direct Cloudinary REST APIs with full TypeScript support.
- Direct API Integration: Uses Cloudinary REST APIs directly instead of SDKs for better control and reduced dependencies.
- Two Upload Methods:
- Base64 Upload: Simple flow for small files (<10MB).
- Direct Upload: Bypasses Convex 16MB limit for large files (100MB+) with real-time progress.
- Asset Management: Automatic asset tracking in Convex with optimized indexes.
- Dynamic Transformations: Generate transformed URLs on-the-fly.
- Type Safety: Full TypeScript support with exported validators (
vAssetResponse) and inferred types. - Secure: Server-side signature generation and environment-based credentials.
graph TD
Client["Client Browser"] -->|"Action/Query"| Convex["Convex Component"]
Convex -->|"API Call"| Cloudinary["Cloudinary API"]
Client -->|"Direct Upload (Large Files)"| Cloudinary
Cloudinary -->|"Webhook/Response"| Convex
Simple and robust data flow ensuring your assets are always in sync with your database.
- Convex Project: An existing Convex project (Setup Guide)
- Cloudinary Account: Free account at cloudinary.com
npm install @imaxis/cloudinary-convexCreate or update your convex.config.ts:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import cloudinary from "@imaxis/cloudinary-convex/convex.config";
const app = defineApp();
app.use(cloudinary);
export default app;Get credentials from Cloudinary Console:
npx convex env set CLOUDINARY_CLOUD_NAME <your_cloud_name>
npx convex env set CLOUDINARY_API_KEY <your_api_key>
npx convex env set CLOUDINARY_API_SECRET <your_api_secret>This approach creates public API functions that can be called from React clients.
Backend (convex/cloudinary.ts):
import { makeCloudinaryAPI } from "@imaxis/cloudinary-convex";
import { components } from "./_generated/api";
// Export all API functions - uses environment variables automatically
export const {
upload,
transform,
deleteAsset,
listAssets,
getAsset,
updateAsset,
generateUploadCredentials,
finalizeUpload,
} = makeCloudinaryAPI(components.cloudinary);React Client:
import { api } from "../convex/_generated/api";
import { useQuery, useAction } from "convex/react";
import { useCloudinaryUpload } from "@imaxis/cloudinary-convex/react";
function ImageGallery() {
// List images
const images = useQuery(api.cloudinary.listAssets, { limit: 20 });
// Upload with progress tracking
const { upload, isUploading, progress } = useCloudinaryUpload(
api.cloudinary.upload
);
const handleUpload = async (file: File) => {
const base64 = await fileToBase64(file);
const result = await upload(base64, { folder: "uploads" });
console.log("Uploaded:", result.secureUrl);
};
return (
<div>
{isUploading && <p>Uploading... {progress}%</p>}
{images?.map((img) => (
<img key={img.publicId} src={img.secureUrl} alt="" />
))}
</div>
);
}This approach is ideal when you need more control or want to build custom logic.
// convex/images.ts
import { action, query } from "./_generated/server";
import { components } from "./_generated/api";
import { CloudinaryClient, vAssetResponse } from "@imaxis/cloudinary-convex";
import { v } from "convex/values";
// Initialize client - uses environment variables automatically
const cloudinary = new CloudinaryClient(components.cloudinary);
// Upload Action (Base64)
export const uploadImage = action({
args: { base64Data: v.string() },
handler: async (ctx, args) => {
return await cloudinary.upload(ctx, args.base64Data, {
folder: "uploads",
tags: ["user-content"],
});
},
});
// List Images Query
export const getImages = query({
args: {},
returns: v.array(vAssetResponse),
handler: async (ctx) => {
return await cloudinary.list(ctx, { limit: 20 });
},
});The component provides React hooks for common operations. Important: These hooks require function references from your app's API (created via makeCloudinaryAPI), not the component directly.
import {
useCloudinaryUpload,
useCloudinaryImage,
useCloudinaryAssets,
useCloudinaryAsset,
useCloudinaryOperations,
useUploadStatus,
usePendingUploads,
} from "@imaxis/cloudinary-convex/react";import { api } from "../convex/_generated/api";
function UploadButton() {
const { upload, isUploading, progress, error, reset } = useCloudinaryUpload(
api.cloudinary.upload
);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
try {
const result = await upload(file, { folder: "uploads" });
console.log("Upload complete:", result);
} catch (err) {
console.error("Upload failed:", err);
}
}
};
return (
<div>
<input type="file" onChange={handleFileChange} disabled={isUploading} />
{isUploading && <p>Uploading... {progress}%</p>}
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
}function TransformedImage({ publicId }: { publicId: string }) {
const { transformedUrl, isLoading } = useCloudinaryImage(
api.cloudinary.transform,
publicId,
{ width: 300, height: 300, crop: "fill" }
);
if (isLoading) return <div>Loading...</div>;
return <img src={transformedUrl} alt="" />;
}function AssetManager({ publicId }: { publicId: string }) {
const { deleteAsset, updateAsset } = useCloudinaryOperations(
api.cloudinary.deleteAsset,
api.cloudinary.updateAsset
);
const handleDelete = async () => {
await deleteAsset(publicId);
};
const handleUpdateTags = async () => {
await updateAsset(publicId, { tags: ["featured", "hero"] });
};
return (
<div>
<button onClick={handleUpdateTags}>Add Tags</button>
<button onClick={handleDelete}>Delete</button>
</div>
);
}For advanced image rendering features like responsive images, lazy loading, and automatic format optimization, we recommend using Cloudinary's official React SDK alongside this component.
Install Cloudinary React SDK:
npm install @cloudinary/react @cloudinary/url-genUse your component for data, Cloudinary SDK for rendering:
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import { Cloudinary } from "@cloudinary/url-gen";
import { AdvancedImage, lazyload, responsive } from "@cloudinary/react";
// Initialize Cloudinary (client-side only needs cloud name)
const cld = new Cloudinary({ cloud: { cloudName: "your-cloud-name" } });
function ImageGallery() {
// Your component: reactive asset list from Convex
const assets = useQuery(api.cloudinary.listAssets, { limit: 20 });
return (
<div className="gallery">
{assets?.map((asset) => (
<AdvancedImage
key={asset.publicId}
cldImg={cld.image(asset.publicId)}
plugins={[lazyload(), responsive()]}
// Cloudinary SDK handles:
// - Responsive sizing
// - Lazy loading
// - Automatic format (WebP/AVIF)
// - Retina displays
/>
))}
</div>
);
}Benefits of this approach:
| This Component | Cloudinary React SDK |
|---|---|
| Stores asset metadata in Convex | Optimized image rendering |
Real-time queries with useQuery |
Responsive images |
| Secure server-side uploads | Lazy loading |
| Two upload methods (base64 + direct) | Automatic format selection (WebP/AVIF) |
| Asset management (delete, update, list) | Blur placeholders |
The component provides fully type-safe transformation options with IntelliSense support. All transformation parameters are documented with JSDoc comments for easy discovery.
// In React with the transform hook
const { transformedUrl } = useCloudinaryImage(
api.cloudinary.transform,
"my-image-id",
{
width: 300,
height: 200,
crop: "fill",
gravity: "auto",
quality: "auto",
format: "auto",
}
);
// Or with CloudinaryClient in Convex
const result = await cloudinary.transform(ctx, "my-image-id", {
width: 300,
height: 200,
crop: "fill",
});| Option | Type | Description |
|---|---|---|
width |
number |
Output width in pixels |
height |
number |
Output height in pixels |
| Mode | Description |
|---|---|
"fill" |
Scale and crop to fill exact dimensions (may crop edges) |
"fit" |
Scale to fit within dimensions (maintains aspect ratio) |
"crop" |
Extract region (requires gravity or x/y coordinates) |
"thumb" |
Generate thumbnail using face detection |
"scale" |
Resize without maintaining aspect ratio (may distort) |
"pad" |
Resize and pad with background color to exact dimensions |
"limit" |
Like fit, but only scales down (never enlarges) |
"fill_pad" |
Fill with smart content-aware padding |
"auto" |
Automatic cropping based on content |
| Gravity | Description |
|---|---|
"auto" |
AI-powered detection of important content |
"face" |
Focus on detected face |
"faces" |
Focus on multiple faces |
"auto:subject" |
Focus on main subject |
"center" |
Center of the image |
"north" |
Top of the image |
"south" |
Bottom of the image |
"east" |
Right side |
"west" |
Left side |
"north_east" |
Top-right corner |
"south_west" |
Bottom-left corner |
| Option | Values | Description |
|---|---|---|
quality |
"auto", "auto:best", "auto:eco", 1-100 |
Compression quality |
format |
"auto", "webp", "avif", "jpg", "png" |
Output format |
dpr |
1, 2, 3, "auto" |
Device pixel ratio for retina displays |
| Option | Examples | Description |
|---|---|---|
radius |
20, "max", "20:30:40:50" |
Border radius (max = circle) |
angle |
90, 180, -45 |
Rotation in degrees |
effect |
"grayscale", "sepia:80", "blur:300" |
Visual effects |
opacity |
0-100 |
Transparency level |
| Option | Examples | Description |
|---|---|---|
background |
"white", "rgb:FF0000" |
Background for padding/transparency |
color |
"blue", "rgb:00FF00" |
Color for effects |
border |
"3px_solid_black" |
Add border around image |
| Option | Description |
|---|---|
overlay |
Overlay another image or text |
aspectRatio |
Maintain aspect ratio ("16:9" or 1.5) |
zoom |
Zoom level for face/object crops |
defaultImage |
Fallback image if not found |
namedTransformation |
Apply a preset transformation |
flags |
Additional flags ("progressive", "lossy") |
rawTransformation |
Raw Cloudinary URL parameters |
// Circular avatar with face detection
{
width: 150,
height: 150,
crop: "thumb",
gravity: "face",
radius: "max"
}
// Optimized responsive image
{
width: 800,
quality: "auto",
format: "auto",
dpr: "auto"
}
// Thumbnail with blur effect
{
width: 200,
height: 200,
crop: "fill",
effect: "blur:100"
}
// Vintage sepia photo
{
width: 600,
effect: "sepia:80",
quality: 85
}
// Grayscale with rounded corners
{
width: 400,
height: 300,
crop: "fill",
effect: "grayscale",
radius: 20
}
// Image with text overlay
{
width: 800,
overlay: "text:Arial_60_bold:Hello%20World",
gravity: "south",
y: 20
}
// Pixelated faces (privacy)
{
effect: "pixelate_faces:15"
}
// Auto-enhanced quality
{
width: 1200,
effect: "improve",
quality: "auto:best",
format: "auto"
}For files larger than ~10MB, use the direct upload flow to bypass Convex's 16MB argument size limit. This uploads files directly from the browser to Cloudinary, with only metadata stored in Convex.
If you're using makeCloudinaryAPI, the required functions are already exported:
// convex/cloudinary.ts
export const {
// ... other functions
generateUploadCredentials, // Step 1: Get signed credentials
finalizeUpload, // Step 3: Store metadata
} = makeCloudinaryAPI(components.cloudinary);import { api } from "../convex/_generated/api";
import { useAction, useMutation } from "convex/react";
import { useState } from "react";
function LargeFileUpload() {
const [progress, setProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const getCredentials = useAction(api.cloudinary.generateUploadCredentials);
const finalizeUpload = useMutation(api.cloudinary.finalizeUpload);
const handleLargeUpload = async (file: File) => {
if (!file) return;
setIsUploading(true);
setProgress(0);
try {
// Step 1: Get signed upload credentials from Convex
const credentials = await getCredentials({
folder: "large-uploads",
tags: ["user-upload"],
});
// Step 2: Upload directly to Cloudinary with progress tracking
const formData = new FormData();
formData.append("file", file);
Object.entries(credentials.uploadParams).forEach(([key, value]) => {
if (value) formData.append(key, value);
});
const response = await new Promise<Response>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", credentials.uploadUrl);
// Track upload progress
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => resolve(new Response(xhr.responseText));
xhr.onerror = () => reject(new Error("Upload failed"));
xhr.send(formData);
});
const result = await response.json();
// Step 3: Store metadata in Convex database
await finalizeUpload({
publicId: result.public_id,
uploadResult: result,
});
console.log("Large file uploaded:", result.secure_url);
} catch (error) {
console.error("Upload failed:", error);
} finally {
setIsUploading(false);
}
};
return (
<div>
<input
type="file"
onChange={(e) => handleLargeUpload(e.target.files?.[0]!)}
disabled={isUploading}
/>
{isUploading && (
<div>
<p>Uploading... {progress}%</p>
<progress value={progress} max="100" />
</div>
)}
</div>
);
}The component supports reactive upload status tracking, allowing you to show upload progress across browser tabs and devices.
| Status | Description |
|---|---|
"pending" |
Upload record created, not yet started |
"uploading" |
Upload is in progress |
"completed" |
Upload finished successfully |
"failed" |
Upload failed (check errorMessage) |
// convex/cloudinary.ts
export const {
// ... other functions
createPendingUpload, // Create a pending upload record
updateUploadStatus, // Update status (pending -> uploading -> completed/failed)
getUploadsByStatus, // Query uploads by status (reactive!)
deletePendingUpload, // Delete pending/failed uploads
} = makeCloudinaryAPI(components.cloudinary);import { api } from "../convex/_generated/api";
import { useQuery, useMutation } from "convex/react";
import {
useUploadStatus,
usePendingUploads,
} from "@imaxis/cloudinary-convex/react";
// Show uploads in progress (reactive - updates across tabs!)
function UploadIndicator({ userId }: { userId: string }) {
const { uploads, isLoading, hasUploading } = useUploadStatus(
api.cloudinary.getUploadsByStatus,
"uploading",
{ userId }
);
if (isLoading) return null;
if (!hasUploading) return null;
return (
<div className="upload-indicator">
{uploads.length} upload(s) in progress...
</div>
);
}
// Full upload workflow with status tracking
function TrackedUpload() {
const { createPending, updateStatus, deletePending } = usePendingUploads(
api.cloudinary.createPendingUpload,
api.cloudinary.updateUploadStatus,
api.cloudinary.deletePendingUpload
);
const handleUpload = async (file: File) => {
// Step 1: Create pending record (visible to other tabs immediately!)
const { uploadId } = await createPending({
filename: file.name,
folder: "uploads",
});
// Step 2: Update to uploading
await updateStatus(uploadId, "uploading");
try {
// Step 3: Perform actual upload...
const result = await performUpload(file);
// Step 4: Update to completed with final data
await updateStatus(uploadId, "completed", {
publicId: result.public_id,
secureUrl: result.secure_url,
width: result.width,
height: result.height,
});
} catch (error) {
// Handle failure
await updateStatus(uploadId, "failed", {
errorMessage: error.message,
});
}
};
return (
<input type="file" onChange={(e) => handleUpload(e.target.files![0])} />
);
}- Real-time updates: Status changes are reactive - all tabs/devices see updates instantly
- Multi-tab awareness: Show "upload in progress" indicators across browser tabs
- Failed upload recovery: Query failed uploads and retry them
- Progress tracking: Track uploads even if the user navigates away
The component manages an assets table:
{
_id: Id<"assets">,
publicId: string,
secureUrl: string,
status: "pending" | "uploading" | "completed" | "failed",
errorMessage?: string,
// ... metadata (width, height, format, tags, etc.)
userId?: string,
}For type-safe function definitions:
import {
vAssetResponse,
vUploadResult,
vTransformResult,
vDeleteResult,
vTransformation,
vUploadStatus,
} from "@imaxis/cloudinary-convex";- Live Demo
- Example Repo (Studio App)
- Convex Documentation
- Cloudinary API Reference
- Cloudinary React SDK
- GitHub Repository
Apache-2.0 - see LICENSE for details.