DEV Community

Cover image for Building a Fashion App Using Cloudinary’s GenAI in React and Node.js
Pato for Cloudinary

Posted on • Originally published at cloudinary.com

Building a Fashion App Using Cloudinary’s GenAI in React and Node.js

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

Fashonista App


Prerequisites

GenAI features may need to be enabled depending on your plan.

  • Basic React/TypeScript familiarity (optional but helpful)

1) Set up Cloudinary

  1. Create/LoginSettings → Product Environments.
  2. Confirm your Cloud name (keep it consistent across tools).
  3. Settings → Product Environments → API KeysGenerate 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
Enter fullscreen mode Exit fullscreen mode

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

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

Never expose CLOUDINARY_API_SECRET on 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}`)
})
Enter fullscreen mode Exit fullscreen mode

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

Now you can run both servers with:

npm run start:both
Enter fullscreen mode Exit fullscreen mode

(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">
              &times;
            </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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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

7) How it works (quick tour)

  • Upload: The file is sent to POST /api/generate. The server uses cloudinary.uploader.upload_stream to store it and returns the public_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
Enter fullscreen mode Exit fullscreen mode

Production notes (optional but recommended)

  • Secrets: Keep CLOUDINARY_API_SECRET server‑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 fileFilter and limits in place; consider scanning/validating uploads.
  • Caching/CDN: Cloudinary URLs are CDN‑backed; reusing the same public_id improves cache hits.
  • Accessibility: Provide helpful alt text 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)