Live demo: https://eventographyapp.com/
How to Build an Event Gallery in React With Cloudinary, AWS Rekognition, and Firebase
Image optimization paired with Firebase’s realtime database + hosting creates a smooth UX: faster loads, less bandwidth, and happier users. In this tutorial we’ll build Eventography — a simple event gallery where guests upload their best shots. Cloudinary stores and optimizes images, AWS Rekognition (via Cloudinary’s auto‑moderation) keeps things safe, and Firebase handles auth, Firestore, and Hosting.
What you’ll build
- Google SSO login
- An Event page to upload photos via the Cloudinary Upload Widget
- Auto‑moderation with AWS Rekognition (rejects unsafe images)
- A Gallery page with optimized images (thumbnails + full‑size via Cloudinary React SDK)
- A Profile page to edit event title/hashtag and share a QR link
Prereqs
- React 18 (Vite) and Node.js ≥ 20
- Free accounts for Firebase and Cloudinary
1) Firebase Setup (Auth, Firestore, Hosting)
- Create a Firebase project (Analytics optional).
- Authentication → Get started → Sign-in method → Google → Enable.
- Firestore → Create database → Test mode (switch to production rules later).
-
Hosting → Get started and install CLI:
npm i -g firebase-tools
- Initialize in your project root:
firebase login
firebase init
# choose: Hosting, Firestore, (and optionally Emulators)
Deploy (later): firebase deploy
.
Firestore security rules (tighten for prod)
Start in test mode for development, but move to restrictive rules. Example:
# firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /events/{eventId} {
allow read: if true; // or auth != null
allow write: if request.auth != null && request.auth.uid == eventId;
}
}
}
Deploy rules:
firebase deploy --only firestore:rules
2) Cloudinary Setup (Upload Preset + Moderation)
- Create a Cloudinary account.
- Settings → Product Environments and confirm your Cloud name.
- Settings → Upload → Add upload preset:
-
Signing mode:
Unsigned
(easy to start; switch to Signed in production) - Enable Use filename or externally defined Public ID and Unique filename
-
Upload control → Auto moderation:
AWS Rekognition
(default confidence 0.5 is fine)
⚠️ Security note: Unsigned presets are convenient but can be abused if leaked. For production, prefer signed uploads from your own backend and verify Firebase auth on server. Keep preset name private.
3) Project Scaffolding & Packages
Create your Vite app if you haven’t:
npm create vite@latest eventography -- --template react
cd eventography
npm i firebase @cloudinary/react @cloudinary/url-gen
Add an .env
file (Vite uses import.meta.env
):
# .env
VITE_CLOUD_NAME=your_cloud_name
4) Firebase Client Config
Create src/helpers/firebase.js
:
// src/helpers/firebase.js
import { initializeApp } from "firebase/app";
import { getStorage } from "firebase/storage";
import { getAuth } from "firebase/auth";
import { collection, getFirestore, setDoc, doc, getDoc } from "firebase/firestore";
const firebaseConfig = {
apiKey: "XXXXXXXXXXXX",
authDomain: "XXXXXXXXXXXX",
projectId: "XXXXXXXXXXXX",
storageBucket: "XXXXXXXXXXXX",
messagingSenderId: "XXXXXXXXXXXX",
appId: "XXXXXXXXXXXX",
measurementId: "XXXXXXXXXXXX"
};
const app = initializeApp(firebaseConfig);
export const storage = getStorage(app);
export const db = getFirestore(app);
export const auth = getAuth(app);
export const getEventData = async (eventId) => {
const docRef = doc(db, "events", eventId);
const docSnap = await getDoc(docRef);
return docSnap.exists() ? docSnap.data() : null;
};
export const updateEventData = async (eventId, data) => {
try {
await setDoc(doc(collection(db, "events"), eventId), data);
} catch (e) {
console.error("Error adding document: ", e);
}
};
Replace
firebaseConfig
with the values from Project settings → Your apps → Web in the Firebase console. Make surefirebase
is installed (npm i firebase
).
5) Auth Context (Google SSO)
Create src/AuthContext.jsx
:
/* eslint-disable react-refresh/only-export-components */
import { createContext, useState, useContext, useEffect } from "react";
import { auth } from "./helpers/firebase";
import { getEventData } from "./helpers/firebase";
import { GoogleAuthProvider, signInWithPopup } from "firebase/auth";
import PropTypes from "prop-types";
const provider = new GoogleAuthProvider();
const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [docSnap, setDocSnap] = useState(null);
useEffect(() => {
const fetchData = async () => {
if (user) {
try {
const eventData = await getEventData(user.uid);
setDocSnap(eventData);
} catch (error) {
console.error("Error fetching data:", error);
}
}
};
if (!user) {
const unsubscribe = auth.onAuthStateChanged((u) => setUser(u));
return () => unsubscribe();
} else {
fetchData();
}
}, [user]);
const signInWithGoogle = async () => {
try {
const result = await signInWithPopup(auth, provider);
setUser(result.user);
window.location.href = "/profile";
} catch (error) {
console.error(error);
}
};
const handleLogout = async () => {
try {
await auth.signOut();
setUser(null);
window.location.href = "/";
} catch (error) {
console.error(error);
}
};
return (
<AuthContext.Provider value={{ user, docSnap, signInWithGoogle, handleLogout, setUser }}>
{children}
</AuthContext.Provider>
);
};
AuthProvider.propTypes = { children: PropTypes.node.isRequired };
export const useAuth = () => useContext(AuthContext);
6) Cloudinary Upload Widget Component
Create src/CloudinaryUploadWidget.jsx
:
import { useEffect, useState, useCallback } from "react";
import PropTypes from "prop-types";
import { updateEventData } from "./helpers/firebase";
import { getEventIdFromUrl } from "./helpers/urlHelpers";
function CloudinaryUploadWidget({ uwConfig, docSnap }) {
const [loaded, setLoaded] = useState(false);
const [images, setImages] = useState([]);
const [thumbnails, setThumbnails] = useState([]);
const [uploadProgress, setUploadProgress] = useState(null);
// Load Cloudinary Upload Widget script
useEffect(() => {
if (!loaded) {
const uwScript = document.getElementById("uw");
if (!uwScript) {
const script = document.createElement("script");
script.setAttribute("async", "");
script.setAttribute("id", "uw");
script.src = "https://upload-widget.cloudinary.com/global/all.js";
script.addEventListener("load", () => setLoaded(true));
document.body.appendChild(script);
} else {
setLoaded(true);
}
}
}, [loaded]);
// Persist images to Firestore when new uploads arrive
useEffect(() => {
const updateEventImages = async () => {
try {
const updatedData = {
...docSnap,
images: docSnap?.images ? [...docSnap.images, ...images] : [...images],
thumbnails: docSnap?.thumbnails ? [...docSnap.thumbnails, ...thumbnails] : [...thumbnails],
};
await updateEventData(getEventIdFromUrl(window.location.pathname), updatedData);
} catch (error) {
console.error("Error updating document: ", error);
}
};
if (docSnap && images.length > 0 && thumbnails.length > 0) {
updateEventImages();
}
}, [docSnap, images, thumbnails]);
const initializeCloudinaryWidget = async () => {
setUploadProgress(null);
if (loaded) {
try {
await window.cloudinary.openUploadWidget(uwConfig, processUploads);
} catch (error) {
setUploadProgress("failed");
}
}
};
const processUploads = useCallback((error, result) => {
if (result?.event === "queues-end") {
result.info.files.forEach((img) => {
if (
img.status !== "success" ||
img.uploadInfo.moderation?.[0]?.status !== "approved" ||
error !== undefined
) {
setUploadProgress("failed");
} else {
setImages((prev) => [...prev, img.uploadInfo.url]);
setThumbnails((prev) => [...prev, img.uploadInfo.thumbnail_url]);
setUploadProgress("successful");
}
});
}
}, []);
return (
<>
<button id="upload_widget" onClick={initializeCloudinaryWidget}>
Upload Images
</button>
{uploadProgress && (
<p>Image Upload Status: {uploadProgress === "successful" ? "successful" : "failed"}</p>
)}
</>
);
}
CloudinaryUploadWidget.propTypes = {
uwConfig: PropTypes.object.isRequired,
docSnap: PropTypes.object,
};
export default CloudinaryUploadWidget;
7) Event Page (Upload)
Create src/Event.jsx
:
import "./Event.css";
import { useEffect, useState } from "react";
import CloudinaryUploadWidget from "./CloudinaryUploadWidget";
import { getEventIdFromUrl } from "./helpers/urlHelpers";
import { getEventData } from "./helpers/firebase";
function Event() {
const uwConfig = {
cloudName: import.meta.env.VITE_CLOUD_NAME || "eventography",
uploadPreset: "eventography", // set to your preset name
sources: ["local"],
multiple: true,
folder: `${window.location.pathname}`,
thumbnailTransformation: { width: 500, height: 500, crop: "fill" },
};
const [docSnap, setDocSnap] = useState();
const urlPath = window.location.pathname;
useEffect(() => {
const fetchData = async () => {
const eventData = await getEventData(getEventIdFromUrl(urlPath));
setDocSnap(eventData);
};
fetchData();
}, [urlPath]);
return (
<>
{docSnap && (
<div className="event">
<h2>{docSnap?.eventTitle}</h2>
<h3>{docSnap?.eventHashtag}</h3>
<CloudinaryUploadWidget uwConfig={uwConfig} docSnap={docSnap} />
<button onClick={() => (window.location.href = `/galleries/${getEventIdFromUrl(urlPath)}`)}>
View Pictures
</button>
<p className="footer">
Created with 💜 by <a href="https://eventographyapp.com/">Eventography</a>
</p>
</div>
)}
</>
);
}
export default Event;
The Event page passes your Upload Widget config to
CloudinaryUploadWidget
and renders event metadata from Firestore.
8) Gallery Page (Thumbnails + Cloudinary React SDK)
Create src/Gallery.jsx
:
import { useEffect, useState } from "react";
import "./Gallery.css";
import { getEventIdFromUrl } from "./helpers/urlHelpers";
import { getEventData } from "./helpers/firebase";
import { AdvancedImage } from "@cloudinary/react";
import { Cloudinary } from "@cloudinary/url-gen";
const Gallery = () => {
const [showFullImage, setShowFullImage] = useState(false);
const [selectedImage, setSelectedImage] = useState(null);
const [docSnap, setDocSnap] = useState(null);
const [loadingStates, setLoadingStates] = useState([]);
const cld = new Cloudinary({
cloud: { cloudName: import.meta.env.VITE_CLOUD_NAME }
});
const eventId = getEventIdFromUrl(window.location.pathname);
useEffect(() => {
const fetchData = async () => {
try {
const snap = await getEventData(eventId);
setDocSnap(snap);
setLoadingStates(new Array(snap?.thumbnails?.length || 0).fill(true));
} catch (error) {
console.error("Error fetching data:", error);
}
};
fetchData();
}, [eventId]);
const handleThumbnailClick = (imgUrl) => {
const imageName = imgUrl.substring(imgUrl.lastIndexOf("/") + 1);
const urlBuilder = `events/${eventId}/${imageName}`;
setShowFullImage(true);
setSelectedImage(urlBuilder);
};
const handleCloseFullImage = () => {
setShowFullImage(false);
setSelectedImage(null);
};
const handleImageLoad = (index) => {
setLoadingStates((prev) => {
const next = [...prev];
next[index] = false;
return next;
});
};
return (
<div className="gallery">
<button className="gallery-upload-btn" onClick={() => (window.location.href = `/events/${eventId}`)}>
Upload Pics
</button>
<div className="gallery-container">
{showFullImage && selectedImage && (
<div className="full-image-container">
<button className="close-btn" onClick={handleCloseFullImage}>Close</button>
<AdvancedImage
cldImg={cld.image(selectedImage).delivery("q_auto").format("auto")}
className="full-image"
/>
</div>
)}
{!showFullImage && docSnap?.thumbnails?.length > 0 ? (
docSnap.thumbnails.map((imgUrl, index) => (
<div className="image-item" key={index} onClick={() => handleThumbnailClick(imgUrl)}>
<img src={imgUrl} alt={`Image ${index}`} onLoad={() => handleImageLoad(index)} />
{loadingStates[index] && <p>Loading...</p>}
</div>
))
) : (
<p>Loading...</p>
)}
</div>
</div>
);
};
export default Gallery;
9) Profile Page, QR Code, and Routing
-
Profile.jsx: reads/writes
eventTitle
andeventHashtag
to Firestore; includes copy‑link and QR share (via aQRCodeGenerator
component). - QRCodeGenerator.jsx: generate a QR from the event link.
- Navbar.jsx, App.jsx, Main.jsx, and CSS files: wire up routes and basic styling.
To keep this post focused, grab the remaining components/styles from the public repo.
10) URL Helpers
Create src/helpers/urlHelpers.js
:
/** Extract the eventId from app URLs like /events/<id> or /galleries/<id> */
export const getEventIdFromUrl = (urlPath) => {
return urlPath
.split("/")
.filter((segment) => segment !== "")[1];
};
11) Test Drive
- Log in (Google).
- In Profile, set your event title + hashtag and save.
- Go to Event, click Upload Images, select photos.
- Approved uploads appear in Gallery as thumbnails; click for full image.
- Verify assets in Cloudinary: DAM → Media Library → Assets or by Folders (
events/<eventId>/...
).
Production Hardening Checklist ✅
- [ ] Switch to signed uploads through your backend.
- [ ] Hide preset names and API keys; never expose secrets in frontend.
- [ ] Lock down Firestore rules (see earlier section).
- [ ] Add rate‑limiting / abuse prevention for uploads.
- [ ] Use source lists in Upload Widget (e.g., limit to local only) and a dedicated upload folder per event.
- [ ] Enable transformations like
q_auto,f_auto
on all delivery URLs. - [ ] Configure CSP headers on Firebase Hosting.
Wrap‑up
We covered the Cloudinary Upload Widget, image optimization, auto‑moderation via AWS Rekognition, Firebase Auth, Firestore, and Hosting. With a few components and presets, you can ship a safe, fast, and shareable event gallery.
- Follow Cloudinary on Twitter/X
- Looking for inspiration around Cloudinary products? Visit our app gallery
If you ship this, drop your demo in the comments — would love to see it! 🚀
Top comments (1)
Nice!