Upload a picture → get four styled looks (elegant, streetwear, sporty, business casual). In this walkthrough we’ll build FashionistaAI with Cloudinary GenAI, React (Vite) on the frontend, and a tiny Node.js/Express backend for secure uploads.
Repo: Cloudinary-FashionistaAI
What you’ll build
-
A React app that:
- uploads an image to your Node backend
- asks Cloudinary GenAI to swap tops/bottoms
- replaces the background
- lets you recolor top or bottom on click
A Node.js server that securely uploads files to Cloudinary using the official SDK.
Demo (what it looks like)
The background adapts to the look; each tile is a different style:
- Elegant
- Streetwear
- Sporty
- Business casual
Prerequisites
- Node 18+ and npm
- A free Cloudinary account
GenAI features may need to be enabled depending on your plan.
- Basic React/TypeScript familiarity (optional but helpful)
1) Set up Cloudinary
- Create/Login → Settings → Product Environments.
- Confirm your Cloud name (keep it consistent across tools).
- Settings → Product Environments → API Keys → Generate New API Key. Save: Cloud name, API key, API secret (secret stays on the server).
2) Bootstrap the React app (Vite)
# Create a Vite + React + TS app
npm create vite@latest fashionistaai -- --template react-ts
cd fashionistaai
# Frontend deps
npm i axios @cloudinary/react @cloudinary/url-gen
# Dev tooling
npm i -D @vitejs/plugin-react
# Backend deps (we'll use one package.json for both)
npm i express cors cloudinary multer streamifier dotenv
# Nice-to-have dev deps
npm i -D nodemon concurrently
3) Configure Vite dev proxy (frontend → backend)
Create/replace vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false,
},
},
},
})
This forwards any /api/* calls to the Express server on port 8000.
4) Environment variables
Create .env in the project root:
# Server (Node) reads these:
CLOUDINARY_CLOUD_NAME=YOUR_CLOUD_NAME
CLOUDINARY_API_KEY=YOUR_API_KEY
CLOUDINARY_API_SECRET=YOUR_API_SECRET
# Frontend (Vite) reads those prefixed with VITE_
VITE_CLOUDINARY_CLOUD_NAME=YOUR_CLOUD_NAME
Never expose
CLOUDINARY_API_SECRETon the frontend. That’s why we’re using a server.
5) Node/Express backend (server.js)
Create server.js in the project root:
/* eslint-disable no-undef */
import 'dotenv/config.js'
import express from 'express'
import cors from 'cors'
import { v2 as cloudinary } from 'cloudinary'
import multer from 'multer'
import streamifier from 'streamifier'
const app = express()
app.use(express.json())
app.use(
cors({
origin: 'http://localhost:3000',
credentials: true,
})
)
// Cloudinary config
cloudinary.config({
secure: true,
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
})
// Multer: in-memory with basic safety checks
const storage = multer.memoryStorage()
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (_req, file, cb) => {
const ok = /image\/(png|jpe?g|webp)/i.test(file.mimetype)
cb(ok ? null : new Error('Only PNG/JPG/WEBP images are allowed'), ok)
},
})
// Upload endpoint (used by the React app)
app.post('/api/generate', upload.single('image'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Image file is required' })
const uploadStream = cloudinary.uploader.upload_stream(
{ resource_type: 'image' },
(error, result) => {
if (error) {
console.error('Cloudinary error:', error)
return res.status(500).json({ error: error.message })
}
// Send the full Cloudinary response; we'll use result.public_id on the frontend
res.json(result)
}
)
streamifier.createReadStream(req.file.buffer).pipe(uploadStream)
})
const PORT = 8000
app.listen(PORT, () => {
console.log(`Server listening on http://localhost:${PORT}`)
})
package.json scripts
Open package.json and add these scripts:
{
"type": "module",
"scripts": {
"dev": "vite",
"server": "nodemon server.js",
"start:both": "concurrently -k \"npm:server\" \"npm:dev\""
}
}
Now you can run both servers with:
npm run start:both
(Or use two terminals: npm run server and npm run dev.)
6) React UI (src/App.tsx)
Below is a drop‑in, TypeScript‑friendly version that keeps your original logic but tightens types, separates file vs. Cloudinary images, and reads the cloud name from env:
import React, { useEffect, useState } from 'react'
import axios from 'axios'
import './App.css'
import { AdvancedImage } from '@cloudinary/react'
import { fill } from '@cloudinary/url-gen/actions/resize'
import { Cloudinary, CloudinaryImage } from '@cloudinary/url-gen'
import {
generativeReplace,
generativeRecolor,
generativeRestore,
generativeBackgroundReplace,
} from '@cloudinary/url-gen/actions/effect'
type StyleKey = 'top' | 'bottom'
type StyleConfig = {
top: string
bottom: string
background: string
type: string
}
const STYLES: StyleConfig[] = [
{
top: 'suit jacket for upper body',
bottom: 'suit pants for lower body',
background: 'office',
type: 'business casual',
},
{
top: 'sport tshirt for upper body',
bottom: 'sport shorts for lower body',
background: 'gym',
type: 'sporty',
},
{
top: 'streetwear shirt for upper body',
bottom: 'streetwear pants for lower body',
background: 'street',
type: 'streetwear',
},
{
top: 'elegant tuxedo for upper body',
bottom: 'elegant tuxedo pants for lower body',
background: 'gala',
type: 'elegant',
},
]
const cld = new Cloudinary({
cloud: { cloudName: import.meta.env.VITE_CLOUDINARY_CLOUD_NAME },
})
export default function App() {
const [file, setFile] = useState<File | null>(null)
const [baseImg, setBaseImg] = useState<CloudinaryImage | null>(null)
const [looks, setLooks] = useState<CloudinaryImage[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loadingStatus, setLoadingStatus] = useState<boolean[]>([])
const [openModal, setOpenModal] = useState(false)
const [selectedItem, setSelectedItem] = useState<StyleKey>('top')
const [selectedLookIndex, setSelectedLookIndex] = useState(0)
const [color, setColor] = useState('#ff0000')
// Auto-submit when user picks a file
useEffect(() => {
if (file) void handleSubmit()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [file])
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]
if (f) setFile(f)
}
async function handleSubmit() {
setError(null)
setLooks([])
setLoadingStatus([])
if (!file) return
try {
setLoading(true)
const data = new FormData()
data.append('image', file)
const resp = await axios.post('/api/generate', data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const publicId = resp.data.public_id as string
const base = cld.image(publicId).resize(fill().width(508).height(508))
setBaseImg(base)
createLooks(publicId)
} catch (err: any) {
console.error(err)
setError(err?.message ?? 'Upload failed')
} finally {
setLoading(false)
}
}
function preload(img: CloudinaryImage, index: number, attempts = 0) {
const url = img.toURL()
const tag = new Image()
tag.onload = () =>
setLoadingStatus(prev => {
const copy = [...prev]
copy[index] = false
return copy
})
tag.onerror = async () => {
// 423 means "still deriving" on Cloudinary
try {
const r = await fetch(url, { method: 'HEAD' })
if (r.status === 423 && attempts < 6) {
setTimeout(() => preload(img, index, attempts + 1), 2000 * (attempts + 1))
return
}
} catch {}
setError('Error loading image. Please try again.')
setLoadingStatus(prev => {
const copy = [...prev]
copy[index] = false
return copy
})
}
tag.src = url
}
function createLooks(publicId: string) {
const imgs = STYLES.map(style => {
const i = cld.image(publicId)
i.effect(generativeReplace().from('shirt').to(style.top))
i.effect(generativeReplace().from('pants').to(style.bottom))
i.effect(generativeBackgroundReplace()) // optional: prompt with your background
i.effect(generativeRestore())
i.resize(fill().width(500).height(500))
return i
})
setLooks(imgs)
setLoadingStatus(imgs.map(() => true))
imgs.forEach((img, idx) => preload(img, idx))
}
function openRecolorModal(index: number) {
setSelectedLookIndex(index)
setOpenModal(true)
}
function applyRecolor() {
const clone = [...looks]
const img = clone[selectedLookIndex]
if (!img) return
setLoadingStatus(prev => {
const copy = [...prev]
copy[selectedLookIndex] = true
return copy
})
setOpenModal(false)
// Recolor only the chosen item for the chosen look
img.effect(generativeRecolor(STYLES[selectedLookIndex][selectedItem], color))
setLooks(clone)
preload(img, selectedLookIndex)
}
return (
<div className="app">
<h1>FashionistaAI</h1>
<form onSubmit={e => e.preventDefault()}>
<label className="custom-file-upload" aria-label="Choose an image to upload">
<input type="file" accept="image/*" onChange={handleImageChange} />
Choose File
</label>
</form>
{loading && <div className="spinner" aria-label="Loading" />}
{error && (
<p style={{ color: 'red' }} role="alert">
{error}
</p>
)}
<div className="container">
{baseImg && !loading && (
<AdvancedImage cldImg={baseImg} alt="Uploaded base image" />
)}
<div className="grid-container" role="list">
{looks.map((img, idx) => (
<div key={idx} role="listitem">
{loadingStatus[idx] ? (
<div className="spinner" />
) : (
<AdvancedImage
cldImg={img}
alt={`Generated ${STYLES[idx].type} look`}
onClick={() => openRecolorModal(idx)}
style={{ cursor: 'pointer' }}
/>
)}
<p className="caption">{STYLES[idx].type}</p>
</div>
))}
</div>
</div>
{openModal && (
<div className="modal-overlay" role="dialog" aria-modal="true">
<div className="modal">
<button className="close-icon" onClick={() => setOpenModal(false)} aria-label="Close">
×
</button>
<h2>Recolor item</h2>
<div className="radio-group">
<label>
<input
type="radio"
value="top"
checked={selectedItem === 'top'}
onChange={() => setSelectedItem('top')}
/>
Top
</label>
<label>
<input
type="radio"
value="bottom"
checked={selectedItem === 'bottom'}
onChange={() => setSelectedItem('bottom')}
/>
Bottom
</label>
</div>
<input
type="color"
value={color}
onChange={e => setColor(e.target.value)}
aria-label="Pick color"
/>
<button onClick={applyRecolor} disabled={!color}>
Change color
</button>
</div>
</div>
)}
</div>
)
}
Minimal CSS (src/App.css)
:root { color-scheme: light dark; }
.app { max-width: 1100px; margin: 2rem auto; padding: 0 1rem; }
.custom-file-upload input[type="file"] { display: none; }
.custom-file-upload { display: inline-block; padding: .6rem 1rem; background: #111; color: #fff; border-radius: 8px; cursor: pointer; }
.container { margin-top: 1.5rem; }
.grid-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; align-items: start; }
.caption { text-transform: capitalize; font-size: .9rem; opacity: .75; margin-top: .25rem; }
.spinner {
width: 36px; height: 36px; border: 4px solid #ddd; border-top-color: #111;
border-radius: 50%; animation: spin 1s linear infinite; margin: 1rem auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
.modal-overlay { position: fixed; inset: 0; background: rgb(0 0 0 / .5); display: grid; place-items: center; }
.modal { background: #fff; color: #111; padding: 1rem; border-radius: 12px; width: min(480px, 90vw); position: relative; }
.close-icon { position: absolute; right: .75rem; top: .5rem; background: transparent; border: none; font-size: 1.5rem; cursor: pointer; }
.radio-group { display: flex; gap: 1rem; margin: .75rem 0; }
7) How it works (quick tour)
-
Upload: The file is sent to
POST /api/generate. The server usescloudinary.uploader.upload_streamto store it and returns thepublic_id. -
Transform:
generativeReplace().from('shirt').to(style.top)generativeReplace().from('pants').to(style.bottom)-
generativeBackgroundReplace()(optionally prompt it to steer the scene) -
generativeRestore()for quality
Recolor: On a generated tile, open a modal and apply
generativeRecolor(<item>, <hex>).423 handling: When the first request for a derived image hits Cloudinary while it’s still being generated, you might see HTTP 423. The preload helper retries with backoff; for heavy use, consider preparing eager transformations on upload.
8) Testing locally
# Install (already done if you followed along)
npm i
# Run both servers
npm run start:both
# Frontend: http://localhost:3000
# Backend: http://localhost:8000
Production notes (optional but recommended)
-
Secrets: Keep
CLOUDINARY_API_SECRETserver‑side only; use environment vars on your host. - Upload presets: Lock down transformations and content rules with a Cloudinary upload preset.
- Limits: Add rate limiting to your API if you open it to the public.
-
Validation: Keep the Multer
fileFilterandlimitsin place; consider scanning/validating uploads. -
Caching/CDN: Cloudinary URLs are CDN‑backed; reusing the same
public_idimproves cache hits. -
Accessibility: Provide helpful
alttext for generated images (the example includes captions).
Wrap‑up
FashionistaAI shows how a small React app plus Cloudinary’s GenAI can turn one image into four on‑brand looks with background changes and easy recoloring. Fork it, tweak the prompts, and ship your own AI‑powered try‑on experience.
If you build something with this, drop a link—DEV readers will want to see it!

Top comments (0)