DEV Community

Gabriel Sakala
Gabriel Sakala

Posted on

How to Integrate Cloudinary Media Uploads into a Firebase Web App

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.

  1. Go to Settings → Upload
  2. Scroll to Upload Presets and click Add upload preset
  3. Set Signing Mode to Unsigned
  4. Give it a name, e.g. mysocial_uploads
  5. 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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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.";
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)