How to Add AI Virtual Try-On to Any React or Next.js Ecommerce Store (2026 Guide)
Most virtual try-on tutorials end the same way: “Install the Shopify app.”
But what if you’re not on Shopify? What if you’ve built a custom React storefront, a headless Next.js store, or a Laravel backend with a React frontend? The tutorials disappear. The documentation gets thin. You’re left staring at an API reference wondering where to start.
This guide fills that gap. By the end, you’ll have a working AI virtual try-on feature integrated into any React or Next.js ecommerce store — using the TryOnCloud REST API — with real code you can drop in today.
Why This Is Worth Building in 2026
Before we write a single line of code, it’s worth understanding the problem we’re actually solving — because this shapes every design decision in the implementation.
Fashion ecommerce has a returns problem that no amount of better photography has fixed. A shopper sees a model wearing a jacket on a white background, buys it, and returns it two days later because it looks completely different on their body. They’re not being difficult — they genuinely had no way to know. Returns average 25–40% in clothing, compared to 5–8% for electronics. This isn’t a logistics problem. It’s an information problem.
- ✓Clothing returns average 25–40% of all fashion ecommerce orders — the highest of any category online
- ✓Stores with virtual try-on report 20–35% fewer returns on equipped product pages
- ✓Shoppers who try on convert at up to 40% higher rates than those who don't
For a store doing $500K/year with a 30% return rate, that’s $50,000–$150,000 lost annually — on reverse logistics, restocking, and lost customer trust alone. Virtual try-on attacks the root cause directly: the shopper sees the garment on their own body before buying, so the expectation matches reality.
Real-world perspective
One TryOnCloud merchant running a sustainable fashion brand reported a 31% drop in return rates within 8 weeks of enabling virtual try-on. At $57/month for the plan they were on, the ROI was obvious by month two. The math isn’t complicated — even a 5% reduction in returns on a $200K/year store saves $10K annually. The feature pays for itself in days.
What You’re Building
The architecture is deliberately simple. You need two things: a React component that handles the file upload and result display, and a server-side proxy route that sits between your React app and the TryOnCloud API. The proxy is what keeps your API key safe — it never reaches the browser.
Here’s the full request flow in plain English:
- Shopper clicks “Try On” — file picker opens, they select a photo
- Component sends the file to your server-side proxy route (e.g.
/api/tryon) - Your proxy forwards the photo + product image URL to TryOnCloud, attaching the API key in a server-side header
- TryOnCloud’s AI generates the try-on result in ~15 seconds
- Your proxy returns the result URL to the component
- Component displays the result image — the shopper sees themselves wearing the garment
The finished component handles all the edge cases a real production feature needs: client-side image validation, file size compression before upload, loading state with rotating tips to keep the shopper engaged during the 15-second generation, structured error codes mapped to user-friendly messages, and a download button for the result.
This works in any React environment: Next.js App Router, Pages Router, Vite, Remix, or standalone React with an Express proxy. We’ll cover all the proxy variants in Step 2.
Step 1 — Get Your API Key
Sign up at tryoncloud.com. The free plan gives you 10 try-ons/month forever with no credit card required — enough to build and test the integration end-to-end before committing to a paid plan. Once you’re in, go to Dashboard → API Keys to get your key. It looks like this:
tk_live_v1_xxxxxxxxxxxxxxxxxxxxxxxxThe tk_live_v1_ prefix tells you it’s a production key for the standard API. If you’re building a white-label multi-tenant product, you’ll use a Reseller key with the prefix rk_live_v1_ — more on that in the Reseller API section below.
Store the key in your environment file. Never prefix it with NEXT_PUBLIC_— that would bake it into your client-side bundle and expose it to anyone who opens DevTools. We’ll access it exclusively from server-side code:
# .env.local (Next.js) or .env (Vite/CRA)
TRYONCLOUD_API_KEY=tk_live_v1_your_key_here
TRYONCLOUD_API_URL=https://www.tryoncloud.com⚠️ Security note
If you accidentally commit your .env.local file, regenerate your key immediately from the dashboard. Add .env.local to your .gitignore before your first commit — this is a common mistake that exposes credentials in public repos.
Step 2 — Create the Server-Side Proxy Route
This is the most important step — and the one most tutorials skip over. Your React component runs entirely in the browser, which means any API key you put inside it is visible to anyone who opens the browser’s Network tab. The solution is a proxy route: a server-side endpoint in your own app that your React component calls, which then forwards the request to TryOnCloud with the key attached in a server-only header.
The proxy has a second benefit beyond security: it lets you add your own validation, logging, rate limiting, or business logic before the try-on request goes out. For example, you might check whether the user is logged in, whether they’ve hit your own usage cap, or whether the product ID is valid — all before spending an API call.
Next.js App Router
app/api/tryon/route.ts
import { NextRequest, NextResponse } from "next/server"
const API_URL = process.env.TRYONCLOUD_API_URL!
const API_KEY = process.env.TRYONCLOUD_API_KEY!
export async function POST(req: NextRequest) {
const formData = await req.formData()
const userImage = formData.get("user_image") as File | null
const productImageUrl = formData.get("product_image_url") as string | null
if (!userImage || !productImageUrl) {
return NextResponse.json(
{ error: "Missing user_image or product_image_url" },
{ status: 400 }
)
}
const upstream = new FormData()
upstream.append("user_image", userImage)
upstream.append("product_image_url", productImageUrl)
const response = await fetch(`${API_URL}/api/tryon`, {
method: "POST",
headers: { "X-API-KEY": API_KEY },
body: upstream,
})
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ error: data.error || "Try-on failed" },
{ status: response.status }
)
}
return NextResponse.json({ resultUrl: data.resultUrl })
}Notice what this route does: it reads the incoming FormData from the browser, rebuilds a new FormData to forward upstream, and injects X-API-KEY from the server environment. The browser never sees the API key at any point. If the upstream request fails, we return the same status code and error message so the React component can handle it cleanly.
Next.js Pages Router
pages/api/tryon.ts
import type { NextApiRequest, NextApiResponse } from "next"
import formidable from "formidable"
import fs from "fs"
export const config = { api: { bodyParser: false } }
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).end()
const form = formidable()
const [fields, files] = await form.parse(req)
const productImageUrl = Array.isArray(fields.product_image_url)
? fields.product_image_url[0] : fields.product_image_url
const file = Array.isArray(files.user_image)
? files.user_image[0] : files.user_image
if (!file || !productImageUrl)
return res.status(400).json({ error: "Missing fields" })
const upstream = new FormData()
upstream.append(
"user_image",
new Blob([fs.readFileSync(file.filepath)], { type: file.mimetype || "image/jpeg" }),
file.originalFilename || "photo.jpg"
)
upstream.append("product_image_url", productImageUrl)
const response = await fetch(`${process.env.TRYONCLOUD_API_URL}/api/tryon`, {
method: "POST",
headers: { "X-API-KEY": process.env.TRYONCLOUD_API_KEY! },
body: upstream,
})
const data = await response.json()
res.status(response.status).json(data)
}The Pages Router version requires formidable to parse multipart uploads because Next.js doesn’t parse FormData in Pages Router API routes by default. Run npm install formidable and set bodyParser: false in the config export — otherwise Next.js will consume the stream before formidable can read it.
Express (Vite / CRA / custom React)
// server.js
const express = require("express")
const multer = require("multer")
const FormData = require("form-data")
require("dotenv").config()
const app = express()
const upload = multer({ storage: multer.memoryStorage() })
app.post("/api/tryon", upload.single("user_image"), async (req, res) => {
const upstream = new FormData()
upstream.append("user_image", req.file.buffer, {
filename: "photo.jpg",
contentType: req.file.mimetype,
})
upstream.append("product_image_url", req.body.product_image_url)
const response = await fetch(
`${process.env.TRYONCLOUD_API_URL}/api/tryon`,
{
method: "POST",
headers: { "X-API-KEY": process.env.TRYONCLOUD_API_KEY, ...upstream.getHeaders() },
body: upstream,
}
)
const data = await response.json()
res.status(response.status).json(data)
})
app.listen(3001)For Vite, add "proxy": "http://localhost:3001" to your vite.config.ts (under server.proxy) or for CRA add it in package.json. This lets your React dev server forward /api/tryon requests to Express without CORS issues during development. In production, deploy the Express server alongside your frontend (or as a separate service) and point the fetch URL at its deployed address.
Step 3 — Build the React Component
The component below is production-ready — not a skeleton. It handles the full lifecycle: file validation before upload (so bad images fail fast without burning an API call), client-side compression to reduce upload time on mobile, a loading state with rotating tips to keep shoppers engaged during the 15-second generation, structured error code mapping so your users see friendly messages instead of raw API errors, and a download button so they can save the result. Walk through it and you’ll see each of these decisions reflected in the code.
// components/VirtualTryOn.tsx
"use client" // App Router only — remove for Pages Router or Vite
import { useState, useRef, useEffect } from "react"
interface VirtualTryOnProps {
productImageUrl: string
productName?: string
}
type State = "idle" | "processing" | "done" | "error"
const TIPS = [
"A head-to-toe photo gives the best result",
"Natural lighting works best",
"Stand against a plain background if possible",
"Face the camera straight on",
]
const ERROR_MESSAGES: Record<string, string> = {
MONTHLY_QUOTA_EXCEEDED: "Monthly try-on limit reached. Upgrade your plan for more.",
RATE_LIMIT_KEY: "Too many requests. Please wait a moment.",
INVALID_USER_IMAGE: "Please upload a clear, well-lit full-body photo.",
CONTENT_FILTERED: "Please upload a photo of yourself wearing clothes.",
}
export function VirtualTryOn({ productImageUrl, productName = "this item" }: VirtualTryOnProps) {
const [state, setState] = useState<State>("idle")
const [resultUrl, setResultUrl] = useState<string | null>(null)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [tipIndex, setTipIndex] = useState(0)
const fileRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (state !== "processing") return
const t = setInterval(() => setTipIndex(i => (i + 1) % TIPS.length), 4000)
return () => clearInterval(t)
}, [state])
async function handleFile(file: File) {
const ALLOWED = ["image/jpeg", "image/png", "image/webp", "image/heic"]
if (!ALLOWED.includes(file.type)) {
setErrorMsg("Please upload a JPG, PNG, WebP, or HEIC image.")
setState("error"); return
}
if (file.size > 20 * 1024 * 1024) {
setErrorMsg("Image must be under 20MB.")
setState("error"); return
}
setState("processing")
setErrorMsg(null)
const compressed = await compressImage(file)
const form = new FormData()
form.append("user_image", compressed)
form.append("product_image_url", productImageUrl)
try {
const res = await fetch("/api/tryon", { method: "POST", body: form })
const data = await res.json()
if (!res.ok) {
const code = data?.code || ""
throw new Error(ERROR_MESSAGES[code] || data?.error || "Try-on failed.")
}
setResultUrl(data.resultUrl)
setState("done")
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : "Something went wrong.")
setState("error")
}
}
return (
<div className="virtual-tryon">
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
style={{ display: "none" }}
onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f) }}
/>
{(state === "idle" || state === "error") && (
<div>
<button className="tryon-btn" onClick={() => fileRef.current?.click()}>
✨ Try On Virtually
</button>
{state === "error" && <p className="tryon-error">{errorMsg}</p>}
</div>
)}
{state === "processing" && (
<div className="tryon-loading">
<div className="tryon-spinner" />
<p>Generating your try-on… (~15 seconds)</p>
<p className="tryon-tip">💡 {TIPS[tipIndex]}</p>
</div>
)}
{state === "done" && resultUrl && (
<div className="tryon-result">
<p>See how <strong>{productName}</strong> looks on you:</p>
<img src={resultUrl} alt={`Virtual try-on: ${productName}`}
style={{ maxWidth: "100%", borderRadius: 12 }} />
<div className="tryon-actions">
<a href={resultUrl} download="tryon-result.jpg">Download</a>
<button onClick={() => { setState("idle"); setResultUrl(null) }}>
Try a Different Photo
</button>
</div>
</div>
)}
</div>
)
}
async function compressImage(file: File, maxPx = 1200): Promise<Blob> {
return new Promise((resolve) => {
const img = new Image()
const url = URL.createObjectURL(file)
img.onload = () => {
URL.revokeObjectURL(url)
const scale = Math.min(1, maxPx / Math.max(img.width, img.height))
const canvas = document.createElement("canvas")
canvas.width = Math.round(img.width * scale)
canvas.height = Math.round(img.height * scale)
canvas.getContext("2d")!.drawImage(img, 0, 0, canvas.width, canvas.height)
canvas.toBlob(blob => resolve(blob || file), "image/jpeg", 0.85)
}
img.src = url
})
}A few things worth calling out in the component above:
- The
compressImagefunction resizes photos to max 1200px before upload. A phone camera photo is often 4–6MB; compressed, it’s 200–400KB. This cuts upload time from 8–15 seconds on a 4G connection to under 2 seconds — a significant UX improvement for mobile users who are your primary try-on audience. - The rotating tips during the 15-second generation prevent the shopper from thinking the page is broken. Without them, users often refresh or navigate away at the 10-second mark. The tips also set accurate expectations — “a head-to-toe photo gives the best result” prepares them to upload a better photo next time if the result isn’t perfect.
- The
ERROR_MESSAGESmap translates TryOnCloud’s structured error codes into messages your users can actually act on.MONTHLY_QUOTA_EXCEEDEDbecomes “Monthly try-on limit reached” — not a raw 429 status code. - HEIC support in the
ALLOWEDarray matters more than you’d think. iPhone users shooting in High Efficiency mode produce HEIC files — the dominant mobile photography format. TryOnCloud’s API handles HEIC natively, so you don’t need to convert on the client.
Step 4 — Add It to Your Product Page
The component is self-contained, so placing it is a single import and one JSX element. Drop it directly below your product image — this placement converts best because the shopper can see the actual product they’d be trying on right above the button. The component handles its own state, so it won’t interfere with your cart, variant selector, or any other product page logic.
// app/products/[slug]/page.tsx
import { VirtualTryOn } from "@/components/VirtualTryOn"
export default function ProductPage({ product }) {
return (
<div className="product-page">
<img src={product.imageUrl} alt={product.name} />
<h1>{product.name}</h1>
<p>${product.price}</p>
{/* One line — drop anywhere after the product image */}
<VirtualTryOn
productImageUrl={product.imageUrl}
productName={product.name}
/>
<button>Add to Cart</button>
</div>
)
}If you’re rendering product pages server-side (SSR or SSG), productImageUrl should be the full public URL of the product image — not a relative path. TryOnCloud’s API fetches the product image server-side to process it, so it must be an absolute URL accessible from the internet. A relative path like /images/jacket.jpg will fail; https://yourdomain.com/images/jacket.jpg works.
Step 5 — Capture Leads After Try-On (optional but high-value)
Here’s something most virtual try-on integrations miss: the moment a shopper sees their try-on result is the highest-intent moment in your entire funnel. They’ve just seen themselves wearing your product. That’s when they’re most likely to buy — and if they don’t buy immediately, most willing to give you their email.
A simple prompt that says “Save your result — enter your email” after state === "done" converts at significantly higher rates than a regular newsletter popup, because the value exchange is obvious: they get to save the image, you get a warm lead. These shoppers already know what they like — they just tried it on.
Shoppers who try on are your highest-intent buyers. Here’s the code:
{state === "done" && resultUrl && !emailCaptured && (
<div className="tryon-email-capture">
<p>Save your result — enter your email:</p>
<input
type="email"
placeholder="you@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
/>
<button onClick={handleEmailSubmit}>Save Result</button>
<p style={{ fontSize: 11, color: "#9ca3af" }}>
Your photo is deleted after generation. We never store it.
</p>
</div>
)}Tag these leads as “Try-On Users” in your CRM. They convert at significantly higher rates than standard newsletter subscribers — they’ve already seen themselves wearing your product. For Shopify stores, TryOnCloud handles this automatically via the embedded app.
API Reference
| Field | Type | Required | Notes |
|---|---|---|---|
| user_image | File | Yes | JPG / PNG / WebP / HEIC, max 20MB |
| product_image_url | String | Yes | Public URL of the garment image |
Success response:
{
"success": true,
"resultUrl": "https://www.tryoncloud.com/api/result/{uuid}"
}Result URLs are accessible for 7 days, then permanently deleted. No watermark. No “Powered by” badge. Ever.
| Error code | Meaning |
|---|---|
| MONTHLY_QUOTA_EXCEEDED | Plan limit reached — upgrade for more try-ons |
| RATE_LIMIT_KEY | Too many requests — add a short delay and retry |
| INVALID_USER_IMAGE | Bad photo quality — prompt for a clearer shot |
| CONTENT_FILTERED | Photo doesn't meet content guidelines |
Building for Multiple Tenants? Use the Reseller API
If you’re integrating try-on for a single store, the standard API covered above is all you need. But if you’re building a platform — a Shopify app, a SaaS product for fashion brands, a white-label solution for an agency — the Reseller API is the right tool.
The difference is in how billing and identity work. With the standard API, every try-on counts against your account. With the Reseller API, you pay a flat per-generation rate and manage your own clients above it — you set your price, you manage your margin, and TryOnCloud never appears anywhere in your product. No “Powered by” badge on result images. No TryOnCloud branding in your interface. Your clients just see your product.
The technical integration is identical — same request format, same response shape. The only difference is the endpoint and key prefix:
// Same request format — different endpoint and key prefix
const response = await fetch(`${API_URL}/api/reseller/tryon`, {
method: "POST",
headers: { "X-API-KEY": "rk_live_v1_your_reseller_key" },
body: upstream,
})A typical reseller setup works like this: you have one master key (rk_live_v1_) stored in your server environment. Your clients call your API, your server calls TryOnCloud, and TryOnCloud only ever sees one caller — you. You manage client quotas, billing, and access in your own system. This is exactly how TryOnCloud itself is structured on top of the underlying AI infrastructure it uses.
Reseller economics (example)
- → You pay TryOnCloud: ₹8 (~$0.10) per try-on
- → You charge your client: ₹14–₹25 per try-on (your price)
- → Margin: 43–68% per generation
- → 50 clients × 300 try-ons/month = ₹90,000+/month gross profit
Full details and the revenue calculator at tryoncloud.com/reseller/revenue
Full API docs for the reseller flow — including signed upload URLs for high-volume direct-to-storage uploads — at tryoncloud.com/reseller/docs.
Common Mistakes to Avoid
These are the issues that come up most often when developers integrate virtual try-on for the first time. Save yourself a few hours of debugging.
✗ Using a relative product image URL
TryOnCloud fetches the product image from its servers to process it, so the URL must be publicly accessible. A relative path like /images/jacket.jpg won't work. Always pass a full absolute URL: https://yourdomain.com/images/jacket.jpg.
✗ Prefixing the API key with NEXT_PUBLIC_
This bakes the key into your client bundle. Anyone can extract it from your JS bundle in seconds. Keep the key in a server-only environment variable and access it exclusively from the proxy route.
✗ Forgetting to disable bodyParser in Pages Router
Without export const config = { api: { bodyParser: false } }, Next.js consumes the request stream before formidable can read the file upload. The result is an empty files object and a confusing missing fields error.
✗ Showing the result step before the image loads
Setting img.src and immediately calling showStep('result') means users see a blank frame for 1–3 seconds on mobile while the ~1MB result image downloads. Wait for img.onload before switching the step, with a timeout fallback so it can never get stuck.
✗ Not handling HEIC files
iPhone users shooting in High Efficiency mode produce HEIC files. If you only allow image/jpeg and image/png in your validation, iPhone users will get a confusing error. Add image/heic and image/heif to your allowed types — TryOnCloud handles conversion server-side.
Frequently Asked Questions
Summary — What You Did
- Signed up at tryoncloud.com — free, 10 try-ons/month, no card
- Created a server-side proxy route — API key never exposed to the browser
- Built the VirtualTryOn component — TypeScript, validated, loading states and error handling
- Dropped it onto the product page — one line
- Added optional lead capture — turns high-intent try-on shoppers into email leads
Ready to add virtual try-on to your store?
Free plan — 10 try-ons/month forever. No watermark. No 'Powered by' badge. No credit card required.