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. Find the full code in the repo:
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);
}
};
This code initializes Firebase services for your app and provides helper functions to read and update event data in Firestore. It first creates a Firebase app using firebaseConfig, then exports instances of Storage, Firestore, and Authentication so the rest of the app can use them. The getEventData function retrieves a specific event document by ID and returns its data if it exists. The updateEventData function writes or overwrites an event document in the events collection using the given ID and data, logging an error if the write fails.
Replace
firebaseConfigwith the values from Project settings → Your apps → Web in the Firebase console. Make surefirebaseis installed (npm i firebase).
5) Auth Context (Google SSO)
Create src/AuthContext.jsx. You can find the full code of this file in the repo.
Let's explain the main parts of this file:
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);
}
};
This component provides authentication state and helper methods to the rest of the app. It tracks two pieces of state: the authenticated user and that user’s associated Firestore document (docSnap). Inside a useEffect, it listens for Firebase Auth state changes when a user logs in or out, Firebase triggers a callback and updates user. Once a user is logged in, the effect fetches that user’s event data from Firestore using their UID and stores it in docSnap. The provider also exposes two actions: signInWithGoogle, which signs the user in via a Google popup and redirects them to /profile, and handleLogout, which signs them out, clears the user state, and redirects them to the home page.
6) Cloudinary Upload Widget Component
Let's build the Cloudinary Upload Widget component by creating a new file src/CloudinaryUploadWidget.jsx. The full code for the component can be found here.
Now, let's take a look at the main part of this file:
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]);
This useEffect ensures the Cloudinary Upload Widget script loads only once. If it’s not already loaded, the code checks for a script with ID "uw"—if missing, it creates the script, sets its source URL, waits for it to finish loading, and then marks loaded as true. If the script already exists, it simply updates loaded. The effect runs whenever loaded changes.
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]);
This useEffect automatically saves new uploaded images to Firestore whenever the event data (docSnap), full-size images, or thumbnails change. When all required data is present, it builds an updated event object by merging the existing Firestore document with the newly uploaded images and thumbnails. It then writes this updated object back to Firestore using updateEventData, using the event ID extracted from the page URL. If anything fails during the update, the error is logged. The effect re-runs anytime docSnap, images, or thumbnails change.
const initializeCloudinaryWidget = async () => {
setUploadProgress(null);
if (loaded) {
try {
await window.cloudinary.openUploadWidget(uwConfig, processUploads);
} catch (error) {
setUploadProgress("failed");
}
}
};
This function resets the upload progress and, if the Cloudinary widget script is loaded, tries to open the upload widget with the given config and callback. If the widget fails to open, it marks the upload as "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");
}
});
}
}, []);
This callback runs when the Cloudinary widget finishes all uploads. It checks each file: if there's an error, a failed status, or the moderation isn't approved, it marks the upload as "failed". Otherwise, it stores the image and thumbnail URLs and marks the upload as "successful". Wrapped in useCallback, it stays stable across renders.
7) Event Page (Upload)
Create src/Event.jsx. You can find the full code to this file in our repo.
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" }
This object defines the configuration for the Cloudinary Upload Widget. It sets the cloudName using an environment variable (or falls back to "eventography"), and specifies the uploadPreset that controls upload rules on Cloudinary. The widget accepts uploads only from the local device (sources: ["local"]) and allows multiple files at once. Uploaded images are stored in a Cloudinary folder matching the current URL path. Finally, thumbnailTransformation tells Cloudinary to generate a 500×500 cropped thumbnail for each upload.
const [docSnap, setDocSnap] = useState();
const urlPath = window.location.pathname;
useEffect(() => {
const fetchData = async () => {
const eventData = await getEventData(getEventIdFromUrl(urlPath));
setDocSnap(eventData);
};
fetchData();
}, [urlPath]);
This code loads event data from Firestore based on the current URL. It stores the event document in docSnap state and reads the page’s path (window.location.pathname) to determine which event to fetch. Inside a useEffect, it calls getEventData with the event ID extracted from the URL, then saves the returned data into state. The effect runs whenever the URL path changes, ensuring the app always displays the correct event data.
The Event page passes your Upload Widget config to
CloudinaryUploadWidgetand renders event metadata from Firestore.
8) Gallery Page (Thumbnails + Cloudinary React SDK)
Time to display the images from our event in the gallery page. Create src/Gallery.jsx and copy paste the full code found here.
const cld = new Cloudinary({
cloud: { cloudName: import.meta.env.VITE_CLOUD_NAME }
});
In this code, we are creating a new instance and initializing Cloudinary using the cloudName from our enviroment variables.
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]);
This useEffect retrieves event data whenever the eventId changes. It calls getEventData, saves the returned document to docSnap, and then initializes a loadingStates array sized to the number of thumbnails in the document—each entry starts as true to indicate that every thumbnail is still loading. If fetching fails, it logs the error.
const handleThumbnailClick = (imgUrl) => {
const imageName = imgUrl.substring(imgUrl.lastIndexOf("/") + 1);
const urlBuilder = `events/${eventId}/${imageName}`;
setShowFullImage(true);
setSelectedImage(urlBuilder);
};
This function runs when a thumbnail is clicked. It extracts the image’s file name from the URL, then builds the Cloudinary folder path for that image using the current eventId. After constructing the full image path, it updates state to display the full-size image setShowFullImage(true) and stores the selected image’s path setSelectedImage(urlBuilder) so it can be rendered.
const handleImageLoad = (index) => {
setLoadingStates((prev) => {
const next = [...prev];
next[index] = false;
return next;
});
};
This function updates the loading state for a specific thumbnail once it finishes loading. It takes the thumbnail’s index, creates a copy of the loadingStates array, sets that index to false to mark it as loaded, and returns the updated array. This allows the UI to show or hide loading indicators on a per-image basis.
9) Profile Page, QR Code, and Routing
-
Profile.jsx: reads/writes
eventTitleandeventHashtagto Firestore; includes copy‑link and QR share (via aQRCodeGeneratorcomponent). - 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_autoon 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!