How to Integrate Cloudinary Media Uploads into a Firebase Web App
Media uploads are one of the trickiest parts of building a web app. Firebase Storage is the obvious choice when you're already in the Firebase ecosystem — but it gets expensive fast, and it doesn't give you image transformations out of the box.
Cloudinary solves both problems. It's built specifically for media: it handles uploads, stores files on a CDN, and lets you resize, crop, and optimize images on the fly via URL parameters. Combined with Firebase for your database and auth, it's a powerful and cost-effective stack.
In this tutorial, I'll show you how to integrate Cloudinary media uploads into a Firebase web app using vanilla JavaScript — no React, no Node.js backend required. This is the exact setup I use in MySocial, a social media platform I built from scratch.
By the end, you'll be able to:
- Upload images and videos to Cloudinary directly from the browser
- Show upload progress to the user
- Store the returned Cloudinary URL in Firestore
- Display uploaded media in your app
Prerequisites
- A Firebase project with Firestore enabled
- A free Cloudinary account
- Basic knowledge of JavaScript and Firebase
Setting Up Cloudinary
First, sign up at cloudinary.com if you haven't already. The free tier is generous — 25GB storage and 25GB bandwidth per month.
Once logged in, head to your Dashboard. You'll need:
- Cloud Name — a unique identifier for your account
- An Upload Preset — this controls upload permissions
Creating an Unsigned Upload Preset
By default, Cloudinary requires a server-side signature for uploads. Since we're uploading directly from the browser (no backend), we'll use an unsigned upload preset.
- Go to Settings → Upload
- Scroll to Upload Presets and click Add upload preset
- Set Signing Mode to Unsigned
- Give it a name, e.g.
mysocial_uploads - Save
Note: Unsigned presets are fine for user-generated content like profile photos and post media. For sensitive uploads, use a signed preset with a server-side function.
Project Structure
We'll keep this simple. Here's what our file structure looks like:
/project
index.html
app.js
firebase-config.js
Firebase Setup
In firebase-config.js, initialize Firebase and export Firestore:
import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.0/firebase-app.js";
import { getFirestore } from "https://www.gstatic.com/firebasejs/10.7.0/firebase-firestore.js";
import { getAuth } from "https://www.gstatic.com/firebasejs/10.7.0/firebase-auth.js";
const firebaseConfig = {
apiKey: "YOUR_API_KEY",
authDomain: "YOUR_AUTH_DOMAIN",
projectId: "YOUR_PROJECT_ID",
storageBucket: "YOUR_STORAGE_BUCKET",
messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
appId: "YOUR_APP_ID"
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
export const auth = getAuth(app);
Building the Upload UI
In index.html, add a simple file input and progress bar:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Upload Demo</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
#preview { max-width: 100%; margin-top: 16px; border-radius: 8px; display: none; }
#progress-bar { width: 0%; height: 6px; background: #00c853; border-radius: 4px; transition: width 0.3s; }
#progress-wrap { background: #eee; border-radius: 4px; margin-top: 12px; display: none; }
button { padding: 10px 20px; background: #1a73e8; color: white; border: none; border-radius: 6px; cursor: pointer; margin-top: 12px; }
#status { margin-top: 12px; font-size: 14px; color: #555; }
</style>
</head>
<body>
<h2>Upload a Photo</h2>
<input type="file" id="fileInput" accept="image/*,video/*" />
<div id="progress-wrap">
<div id="progress-bar"></div>
</div>
<img id="preview" alt="Preview" />
<p id="status"></p>
<button id="uploadBtn">Upload</button>
<script type="module" src="app.js"></script>
</body>
</html>
The Upload Function
Now for the core logic in app.js. Cloudinary accepts uploads via a simple POST request to their API — no SDK required.
import { db, auth } from "./firebase-config.js";
import { collection, addDoc, serverTimestamp } from "https://www.gstatic.com/firebasejs/10.7.0/firebase-firestore.js";
const CLOUD_NAME = "your_cloud_name"; // from Cloudinary dashboard
const UPLOAD_PRESET = "mysocial_uploads"; // the preset you created
const fileInput = document.getElementById("fileInput");
const uploadBtn = document.getElementById("uploadBtn");
const progressBar = document.getElementById("progress-bar");
const progressWrap = document.getElementById("progress-wrap");
const preview = document.getElementById("preview");
const status = document.getElementById("status");
uploadBtn.addEventListener("click", async () => {
const file = fileInput.files[0];
if (!file) {
status.textContent = "Please select a file first.";
return;
}
const url = await uploadToCloudinary(file);
if (url) {
await saveToFirestore(url, file.type);
}
});
Uploading to Cloudinary with Progress Tracking
We use XMLHttpRequest instead of fetch here because fetch doesn't natively support upload progress events.
function uploadToCloudinary(file) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append("file", file);
formData.append("upload_preset", UPLOAD_PRESET);
// Optional: organize uploads into folders
formData.append("folder", "posts");
const xhr = new XMLHttpRequest();
const endpoint = `https://api.cloudinary.com/v1_1/${CLOUD_NAME}/auto/upload`;
xhr.open("POST", endpoint);
// Track upload progress
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressWrap.style.display = "block";
progressBar.style.width = percent + "%";
status.textContent = `Uploading... ${percent}%`;
}
});
xhr.addEventListener("load", () => {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
status.textContent = "Upload complete!";
progressBar.style.width = "100%";
// Show preview for images
if (file.type.startsWith("image/")) {
preview.src = data.secure_url;
preview.style.display = "block";
}
resolve(data.secure_url);
} else {
status.textContent = "Upload failed. Please try again.";
reject(new Error("Upload failed"));
}
});
xhr.addEventListener("error", () => {
status.textContent = "Network error during upload.";
reject(new Error("Network error"));
});
xhr.send(formData);
});
}
The /auto/upload endpoint in the URL automatically detects whether the file is an image or video — no need to hardcode the resource type.
The response from Cloudinary includes several useful fields. The most important one is secure_url, which is the HTTPS link to your uploaded file. You also get public_id (useful for deletions), width, height, format, and more.
Saving the URL to Firestore
Once we have the Cloudinary URL, we save it to a Firestore posts collection alongside the current user's ID and a timestamp:
async function saveToFirestore(mediaUrl, fileType) {
try {
const user = auth.currentUser;
if (!user) {
status.textContent = "You must be logged in to post.";
return;
}
const resourceType = fileType.startsWith("video/") ? "video" : "image";
await addDoc(collection(db, "posts"), {
mediaUrl,
resourceType,
userId: user.uid,
createdAt: serverTimestamp()
});
status.textContent = "Post saved successfully!";
} catch (error) {
console.error("Firestore error:", error);
status.textContent = "Failed to save post.";
}
}
Displaying Uploaded Media
To render posts from Firestore, query the collection and build DOM elements dynamically:
import { getDocs, orderBy, query } from "https://www.gstatic.com/firebasejs/10.7.0/firebase-firestore.js";
async function loadPosts() {
const feed = document.getElementById("feed");
const q = query(collection(db, "posts"), orderBy("createdAt", "desc"));
const snapshot = await getDocs(q);
snapshot.forEach((doc) => {
const post = doc.data();
const item = document.createElement("div");
item.className = "post";
if (post.resourceType === "video") {
item.innerHTML = `
<video src="${post.mediaUrl}" controls style="max-width:100%;border-radius:8px;"></video>
`;
} else {
item.innerHTML = `
<img src="${post.mediaUrl}" alt="Post" style="max-width:100%;border-radius:8px;" />
`;
}
feed.appendChild(item);
});
}
loadPosts();
Bonus: On-the-Fly Image Transformations
One of Cloudinary's best features is URL-based transformations. You can resize, crop, and optimize images just by modifying the URL — no extra code needed.
For example, if your uploaded image URL is:
https://res.cloudinary.com/your_cloud/image/upload/v123/posts/photo.jpg
You can serve a 400px-wide, auto-quality version like this:
https://res.cloudinary.com/your_cloud/image/upload/w_400,q_auto,f_auto/v123/posts/photo.jpg
Common transformation parameters:
| Parameter | Description |
|---|---|
w_400 |
Resize width to 400px |
h_300 |
Resize height to 300px |
c_fill |
Crop to fill dimensions |
q_auto |
Auto-optimize quality |
f_auto |
Auto-select best format (WebP, AVIF, etc.) |
r_max |
Circular crop |
A simple helper function to apply transformations:
function cloudinaryUrl(baseUrl, transformations) {
return baseUrl.replace("/upload/", `/upload/${transformations}/`);
}
// Usage
const thumbnail = cloudinaryUrl(post.mediaUrl, "w_400,h_400,c_fill,q_auto,f_auto");
This is especially useful for feed thumbnails — serve a small optimized version in the feed, and the full image only when the user opens the post.
Handling Errors Gracefully
A few edge cases to handle in production:
File size limits: Cloudinary's free tier allows up to 10MB per upload. Add a client-side check:
const MAX_SIZE_MB = 10;
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
status.textContent = `File too large. Maximum size is ${MAX_SIZE_MB}MB.`;
return;
}
File type validation: Even with accept on the input, users can bypass it programmatically. Validate on the JS side too:
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "video/mp4", "video/webm"];
if (!allowedTypes.includes(file.type)) {
status.textContent = "Unsupported file type.";
return;
}
Upload cancellation: You can cancel an in-progress upload by calling xhr.abort(). Useful for a "Cancel" button:
let activeXhr = null;
cancelBtn.addEventListener("click", () => {
if (activeXhr) {
activeXhr.abort();
status.textContent = "Upload cancelled.";
progressBar.style.width = "0%";
}
});
// Inside uploadToCloudinary, store the xhr reference
activeXhr = xhr;
Conclusion
With just a few dozen lines of vanilla JavaScript, you now have a fully functional media upload system that:
- Sends files directly to Cloudinary from the browser
- Tracks upload progress in real time
- Stores the media URL in Firestore
- Displays uploaded content dynamically
- Supports on-the-fly image transformations via URL
This approach avoids Firebase Storage costs and gives you powerful media handling that scales well. It's the same architecture behind MySocial's profile photos, feed posts, and chat attachments — and it's held up well in production.
If you want to go further, look into:
- Signed uploads for added security using Firebase Cloud Functions
- Cloudinary webhooks for post-processing notifications
-
Real-time Firestore listeners (
onSnapshot) for a live-updating feed.
Top comments (0)