A production-ready travel photo gallery built with React, Vite, TypeScript, and the Cloudinary React SDK. Upload photos from any device, browse your entire Cloudinary library, and view full-size previews — all without writing a single line of upload or image-pipeline code.
Scaffolded from create-cloudinary-react.
| Feature | How it works |
|---|---|
| Upload from anywhere | Cloudinary Upload Widget — drag-and-drop, camera, URL, Google Drive, Dropbox |
| Gallery of existing images | Cloudinary Admin API fetched through a Vite dev-server proxy (API secret never reaches the browser) |
| Automatic optimisation | AdvancedImage + thumbnail() + format(auto()) + quality(auto()) |
| Blur-up lazy loading | placeholder({ mode: 'blur' }) + lazyload() plugins |
| Full-size preview | Radix Dialog triggered on card click |
- React 19 + TypeScript
- Vite 6 (dev server, proxy, bundler)
- Tailwind CSS v4 (configured entirely in CSS — no
tailwind.config.js) - shadcn/ui — Button, Badge, Dialog, Skeleton
- @cloudinary/react —
AdvancedImage,placeholder,lazyload - @cloudinary/url-gen — transformation builder
- Lucide React — icons
- Sonner — toast notifications
- Node.js ≥ 20 (LTS recommended)
- A free Cloudinary account
The project needs five variables in a .env file at the project root.
# .env
# ── Client-side (VITE_ prefix = bundled into browser) ──────────────────────────
VITE_CLOUDINARY_CLOUD_NAME=your-cloud-name # Dashboard → top-right corner
VITE_CLOUDINARY_UPLOAD_PRESET=your-unsigned-preset # Settings → Upload → Upload presets
VITE_CLOUDINARY_FOLDER=travel # Optional: filter gallery to this folder
# ── Server-side only (no VITE_ prefix = never bundled) ─────────────────────────
CLOUDINARY_API_KEY=your-api-key # Dashboard → Settings → API Keys
CLOUDINARY_API_SECRET=your-api-secret # Dashboard → Settings → API KeysSecurity:
CLOUDINARY_API_KEYandCLOUDINARY_API_SECRETmust never have theVITE_prefix. Without it, Vite never bundles them into the browser. The Vite dev-server proxy reads them in Node.js and uses them to authenticate Admin API requests — the secret stays on the server.
| Variable | Where to find it |
|---|---|
VITE_CLOUDINARY_CLOUD_NAME |
Cloudinary Console → your cloud name in the top-right |
VITE_CLOUDINARY_UPLOAD_PRESET |
Console → Settings → Upload → Upload presets → create an Unsigned preset |
VITE_CLOUDINARY_FOLDER |
The folder your upload preset saves into (e.g. travel). Leave blank to show all images. |
CLOUDINARY_API_KEY |
Console → Settings → API Keys |
CLOUDINARY_API_SECRET |
Console → Settings → API Keys (click the eye icon) |
# 1. Clone or scaffold
npx create-cloudinary-react react-travel-gallery --ts
cd react-travel-gallery
# 2. Install dependencies (already run by the CLI — skip if already installed)
npm install
# 3. Configure environment variables
cp .env.example .env # or create .env manually
# → fill in the five variables above
# 4. Start the dev server
npm run dev
# → http://localhost:5173Always restart the dev server after editing
.env— Vite reads env files at startup.
react-travel-gallery/
├── index.html ← Upload Widget script loaded here (async)
├── vite.config.ts ← Tailwind plugin + Admin API proxy
├── .env ← Your credentials (never commit this)
└── src/
├── cloudinary/
│ ├── config.ts ← Cloudinary instance + env vars
│ └── UploadWidget.tsx ← Upload Widget React wrapper (polling pattern)
├── lib/
│ ├── utils.ts ← cn() helper (clsx + tailwind-merge)
│ └── cloudinary-api.ts ← Admin API client (fetch all images via proxy)
├── components/ui/
│ ├── button.tsx ← shadcn Button (CVA variants)
│ ├── badge.tsx ← shadcn Badge
│ ├── dialog.tsx ← Radix Dialog (preview modal)
│ └── skeleton.tsx ← Loading placeholder
├── App.tsx ← Gallery state, image display, preview dialog
├── App.css ← Minimal (all styling via Tailwind)
└── index.css ← Tailwind v4 + shadcn design tokens
The widget is loaded as an external <script async> in index.html. Because it's async, window.cloudinary.createUploadWidget may not be attached by the time React mounts. UploadWidget.tsx handles this with a polling pattern:
// Poll every 100ms until the function is available (max 10 seconds)
poll = setInterval(() => {
if (typeof window.cloudinary?.createUploadWidget === 'function') {
clearInterval(poll)
widgetRef.current = window.cloudinary.createUploadWidget(config, callback)
}
}, 100)AdvancedImage renders an <img> whose src is a Cloudinary CDN URL built from a transformation chain:
cld.image(publicId)
.resize(thumbnail().width(400).height(400)) // smart square crop
.delivery(format(auto())) // WebP / AVIF per browser
.delivery(quality(autoQuality())) // smallest file, best qualityFetching all images requires the Cloudinary Admin API, which uses Basic Auth (API key + secret). The secret must never leave the server. The Vite dev-server proxy handles this:
Browser → /api/cloudinary/v1_1/{cloud}/resources/image
Vite → adds Authorization: Basic base64(key:secret)
→ forwards to api.cloudinary.com
The browser never sees the secret. See vite.config.ts and src/lib/cloudinary-api.ts.
Production note: The Vite proxy only runs during
npm run dev. For production deployments you need a server-side route (Next.js API route, Express endpoint, edge function) that performs the same auth.
npm run dev # Start dev server (http://localhost:5173)
npm run build # Type-check + production build → dist/
npm run lint # ESLint
npm run preview # Preview the production build locally