diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b89574..0a27e20 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,72 @@
+# [1.0.0-beta.21](https://github.com/cloudinary-devs/create-cloudinary-react/compare/v1.0.0-beta.20...v1.0.0-beta.21) (2026-02-25)
+
+
+### Bug Fixes
+
+* update title in readme and ui ([54f633e](https://github.com/cloudinary-devs/create-cloudinary-react/commit/54f633e6fc2a6e800979c9962383c8f83523f79b))
+
+# [1.0.0-beta.20](https://github.com/cloudinary-devs/create-cloudinary-react/compare/v1.0.0-beta.19...v1.0.0-beta.20) (2026-02-23)
+
+
+### Bug Fixes
+
+* Merge pull request [#9](https://github.com/cloudinary-devs/create-cloudinary-react/issues/9) from jlooper-cloudinary/main ([9696686](https://github.com/cloudinary-devs/create-cloudinary-react/commit/96966864d65168b0210a8200f26c9d0d144b6fa9))
+
+# [1.0.0-beta.19](https://github.com/cloudinary-devs/create-cloudinary-react/compare/v1.0.0-beta.18...v1.0.0-beta.19) (2026-02-18)
+
+
+### Bug Fixes
+
+* update readme ([b7a77ca](https://github.com/cloudinary-devs/create-cloudinary-react/commit/b7a77caaf4bf052c68bd912a0ae9c51d424d07cd))
+
+# [1.0.0-beta.18](https://github.com/cloudinary-devs/create-cloudinary-react/compare/v1.0.0-beta.17...v1.0.0-beta.18) (2026-02-18)
+
+
+### Bug Fixes
+
+* update registration link ([2a69d7a](https://github.com/cloudinary-devs/create-cloudinary-react/commit/2a69d7ada9de1cb2751bbd4c1d344422b4c2dd5e))
+* update video player example ([9db39ee](https://github.com/cloudinary-devs/create-cloudinary-react/commit/9db39ee8fc149c748ea422544a79b45a60365f47))
+
+# [1.0.0-beta.17](https://github.com/cloudinary-devs/create-cloudinary-react/compare/v1.0.0-beta.16...v1.0.0-beta.17) (2026-02-12)
+
+
+### Bug Fixes
+
+* simplified questions into arrays ([bade600](https://github.com/cloudinary-devs/create-cloudinary-react/commit/bade600ee63de7a7c3d8eec40569f8896f96a7bd))
+
+
+### Features
+
+* prompt copied animation ([9ad35b2](https://github.com/cloudinary-devs/create-cloudinary-react/commit/9ad35b2351a69bca6d4dcd716b10a1508d5228a8))
+
+# [1.0.0-beta.16](https://github.com/cloudinary-devs/create-cloudinary-react/compare/v1.0.0-beta.15...v1.0.0-beta.16) (2026-02-11)
+
+
+### Bug Fixes
+
+* add copy on click ([a4ecf5d](https://github.com/cloudinary-devs/create-cloudinary-react/commit/a4ecf5d1d1478d854683a115735fa71f7fb50cea))
+* complete the sentance ([d6c68dd](https://github.com/cloudinary-devs/create-cloudinary-react/commit/d6c68ddc213fbc18771f26840f33edb805950799))
+* remove upload question ([fbf01ae](https://github.com/cloudinary-devs/create-cloudinary-react/commit/fbf01ae696e158cab48feb053a3515bb21453fdd))
+
+# [1.0.0-beta.15](https://github.com/cloudinary-devs/create-cloudinary-react/compare/v1.0.0-beta.14...v1.0.0-beta.15) (2026-02-10)
+
+
+### Bug Fixes
+
+* use correct CLAUDE.md convention for Claude Code ([07a8a08](https://github.com/cloudinary-devs/create-cloudinary-react/commit/07a8a085686b160387388464a722d90c09cb6805))
+
+
+### Features
+
+* **analytics:** add CLI feature detection for React SDK ([4dfe495](https://github.com/cloudinary-devs/create-cloudinary-react/commit/4dfe4957bf615e9df08afe729e4f024c02d1cc7f))
+
+# [1.0.0-beta.14](https://github.com/cloudinary-devs/create-cloudinary-react/compare/v1.0.0-beta.13...v1.0.0-beta.14) (2026-02-04)
+
+
+### Bug Fixes
+
+* improve upload widget reliability and add video player poster options ([ac51e42](https://github.com/cloudinary-devs/create-cloudinary-react/commit/ac51e420f1b81f2a055bb3fb7e0331841e86b37a))
+
# [1.0.0-beta.13](https://github.com/cloudinary-devs/create-cloudinary-react/compare/v1.0.0-beta.12...v1.0.0-beta.13) (2026-02-03)
diff --git a/README.md b/README.md
index 4625907..dcea541 100644
--- a/README.md
+++ b/README.md
@@ -1,57 +1,99 @@
# create-cloudinary-react
-> **Beta Release** - This is a beta version. We welcome feedback and bug reports!
-
-Part of the [Cloudinary Developers](https://github.com/cloudinary-devs) organization.
+[](https://www.npmjs.com/package/create-cloudinary-react)
+[](https://opensource.org/licenses/MIT)
+[](http://makeapullrequest.com)
-Scaffold a Cloudinary React + Vite + TypeScript project with interactive setup.
+**The fastest way to start building with Cloudinary and React.**
-## Prerequisites
+Scaffold a modern, production-ready Cloudinary application with React 19, Vite 6, and TypeScript 5. Features interactive setup, automatic environment configuration, and built-in AI coding assistance.
- Node.js 18+ installed
- A Cloudinary account (free tier available)
- - [Sign up for free](https://cloudinary.com/users/register/free)
+ - [Sign up for free](https://cld.media/reactregister)
- Your cloud name is in your [dashboard](https://console.cloudinary.com/app/home/dashboard)
-## Usage
+> **Beta Release** - This is a beta version. We welcome feedback and bug reports!
+
+Part of the [Cloudinary Developers](https://github.com/cloudinary-devs) organization.
+
+
+
+## 📽️ Demo
+
+[](https://res.cloudinary.com/drir0kpia/video/upload/v1771449633/reactstarterdemo.mp4)
+
+
+## 🎬 Features
+
+- **🚀 Modern Stack**: React 19 + Vite 6 + TypeScript 5.7
+- **📦 Cloudinary SDKs**: Pre-configured `@cloudinary/react`
+- **🤖 AI-First**: Auto-generates configuration for Cursor, GitHub Copilot, and Claude
+- **🛠️ Best Practices**: ESLint 9 + TypeScript-ESLint, strict type checking
+- **⚡ Interactive Setup**: Validates your cloud name and configures `.env` automatically
+- **🎨 Typed Components**: Includes a fully typed Upload Widget component
+- **🔌 MCP Support**: Built-in Model Context Protocol configuration for advanced AI integrations
+
+## 🚀 Quick Start
+
+Ensure you have Node.js 18+ installed.
```bash
npx create-cloudinary-react
```
+*(No installation required)*
+
+The CLI will guide you through:
+1. **Project Name**: naming your new folder
+2. **Cloud Name**: entering your [Cloudinary cloud name](https://console.cloudinary.com/app/home/dashboard)
+3. **Upload Preset** (Optional): handling unsigned uploads
+4. **AI Assistant**: generating custom rules for your tool of choice (Cursor, VS Code, etc.)
+
+## 🛠️ What's Included
-The CLI will prompt you for:
-- Project name
-- **Cloudinary cloud name** (found in your [dashboard](https://console.cloudinary.com/app/home/dashboard))
-- Unsigned upload preset (optional - required for uploads, but transformations work without it)
-- AI coding assistant(s) you're using (Cursor, GitHub Copilot, Claude, etc.)
-- Whether to install dependencies
-- Whether to start dev server
+Your new project comes with:
-## Features
+- **`src/`**: specialized for Cloudinary workflows
+- **`src/components/UploadWidget.tsx`**: A ready-to-use, typed upload component
+- **`.env`**: Pre-filled with your Cloud Name (and Upload Preset if provided)
+- **`README.md`**: Custom instructions for your specific project
+- **AI Configuration**:
+ - `.cursorrules` / `.cursor/mcp.json` (for Cursor)
+ - `.github/copilot-instructions.md` (for Copilot)
+ - `.claude` / `claude.md` (for Claude)
-- ✅ Interactive setup with validation
-- ✅ Pre-configured Cloudinary React SDK
-- ✅ TypeScript + Vite + React 19
-- ✅ Typed Upload Widget component
-- ✅ Environment variables with VITE_ prefix
-- ✅ Multi-tool AI assistant support (Cursor, GitHub Copilot, Claude, and more)
-- ✅ MCP configuration for Cloudinary integration
-- ✅ ESLint + TypeScript configured
+## 🤖 AI Assistant Support
-## AI Assistant Support
+We believe AI is the future of development. This starter kit doesn't just give you code; it gives your AI context.
-During setup, you'll be asked which AI coding assistant(s) you're using. The CLI will generate the appropriate configuration files:
+During setup, select your AI tool to generate **Context Rules**. These rules teach your AI:
+- How to construct Cloudinary transformation URLs correctly
+- How to use the `@cloudinary/react` SDK components
+- Common pitfalls to avoid (like mixing up import paths)
+- How to handle upload widget events
-- ✅ **Cursor** → `.cursorrules` + `.cursor/mcp.json` (if selected)
-- ✅ **GitHub Copilot** → `.github/copilot-instructions.md`
-- ✅ **Claude Code / Claude Desktop** → `.claude`, `claude.md` + `.cursor/mcp.json` (if selected)
-- ✅ **Generic AI tools** → `AI_INSTRUCTIONS.md`, `PROMPT.md`
+**Supported Tools:**
+- ✅ **Cursor** (Rules + MCP)
+- ✅ **GitHub Copilot** (Instructions)
+- ✅ **Claude** (Project context + MCP)
+- ✅ **Generic LLMs** (System prompts provided)
-**MCP Configuration**: The `.cursor/mcp.json` file is automatically generated if you select Cursor or Claude, as it works with both tools.
+## 📋 Prerequisites
-These rules help AI assistants understand Cloudinary React SDK patterns, common errors, and best practices. The generated app also includes an "AI Prompts" section with ready-to-use suggestions for your AI assistant.
+- **Node.js 18+**
+- **Cloudinary Account**: [Sign up for free](https://cloudinary.com/users/register/free) if you haven't already.
-## Development
+## 🤝 Contributing
+
+Contributions are welcome! Please feel free to submit a Pull Request.
+
+1. Fork the repository
+2. Create your feature branch (`git checkout -b feature/amazing-feature`)
+3. Commit your changes (`git commit -m 'feat: add some amazing feature'`)
+4. Push to the branch (`git push origin feature/amazing-feature`)
+5. Open a Pull Request
+
+## ⚙️ Development
This project uses [Conventional Commits](https://www.conventionalcommits.org/) for version management and [semantic-release](https://github.com/semantic-release/semantic-release) for automated releases.
@@ -79,3 +121,6 @@ Releases are triggered manually via GitHub Actions workflow. The workflow uses n
- `perf`: Performance improvements
- `chore`: Other changes
+## 📄 License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
diff --git a/cli.js b/cli.js
index 21d6144..01a6c15 100755
--- a/cli.js
+++ b/cli.js
@@ -69,10 +69,10 @@ async function main() {
} else {
- console.log(chalk.cyan.bold('\n🚀 Cloudinary React + Vite\n'));
- console.log(chalk.gray('💡 Need a Cloudinary account? Sign up for free: https://cloudinary.com/users/register/free\n'));
+ console.log(chalk.cyan.bold('\n🚀 Cloudinary React Starter Kit\n'));
+ console.log(chalk.gray('💡 Need a Cloudinary account? Sign up for free: https://cld.media/reactregister\n'));
- answers = await inquirer.prompt([
+ const questions = [
{
type: 'input',
name: 'projectName',
@@ -101,7 +101,7 @@ async function main() {
if (!input.trim()) {
return chalk.yellow(
'Cloud name is required.\n' +
- ' → Sign up: https://cloudinary.com/users/register/free\n' +
+ ' → Sign up: https://cld.media/reactregister\n' +
' → Find your cloud name: https://console.cloudinary.com/app/home/dashboard'
);
}
@@ -135,11 +135,13 @@ async function main() {
{
type: 'checkbox',
name: 'aiTools',
- message: 'Which AI coding assistant(s) are you using? (Select all that apply)',
+ message:
+ 'Which AI coding assistant(s) are you using? (Select all that apply)\n' +
+ chalk.gray(' We’ll add local instruction files so your assistant knows Cloudinary patterns.\n'),
choices: [
{ name: 'Cursor', value: 'cursor' },
{ name: 'GitHub Copilot', value: 'copilot' },
- { name: 'Claude Code / Claude Desktop', value: 'claude' },
+ { name: 'Claude Code', value: 'claude' },
{ name: 'Other / Generic AI tools', value: 'generic' },
],
default: ['cursor'],
@@ -157,7 +159,9 @@ async function main() {
default: false,
when: (answers) => answers.installDeps,
},
- ]);
+ ];
+
+ answers = await inquirer.prompt(questions);
}
const { projectName, cloudName, uploadPreset, aiTools, installDeps, startDev } = answers;
@@ -183,6 +187,9 @@ async function main() {
PROJECT_NAME: projectName,
CLOUD_NAME: cloudName,
UPLOAD_PRESET: uploadPreset || '',
+ UPLOAD_PRESET_ENV_LINE: uploadPreset
+ ? `- \`VITE_CLOUDINARY_UPLOAD_PRESET\`: ${uploadPreset}`
+ : '- `VITE_CLOUDINARY_UPLOAD_PRESET`: (not set - add one for uploads)',
};
// Function to copy template file
@@ -247,8 +254,7 @@ async function main() {
}
if (aiTools.includes('claude')) {
- writeFileSync(join(projectPath, '.claude'), aiRulesContent);
- writeFileSync(join(projectPath, 'claude.md'), aiRulesContent);
+ writeFileSync(join(projectPath, 'CLAUDE.md'), aiRulesContent);
}
if (aiTools.includes('generic')) {
@@ -256,18 +262,21 @@ async function main() {
writeFileSync(join(projectPath, 'PROMPT.md'), aiRulesContent);
}
- // Generate MCP configuration if using Cursor or Claude (MCP works with both)
- if (aiTools.includes('cursor') || aiTools.includes('claude')) {
- const mcpTemplatePath = join(TEMPLATES_DIR, '.cursor/mcp.json.template');
- if (existsSync(mcpTemplatePath)) {
+ // Generate MCP configuration: Cursor uses .cursor/mcp.json, Claude Code uses .mcp.json in project root
+ const mcpTemplatePath = join(TEMPLATES_DIR, '.cursor/mcp.json.template');
+ if (existsSync(mcpTemplatePath)) {
+ const mcpContent = replaceTemplate(
+ readFileSync(mcpTemplatePath, 'utf-8'),
+ templateVars
+ );
+ if (aiTools.includes('cursor')) {
const cursorDir = join(projectPath, '.cursor');
mkdirSync(cursorDir, { recursive: true });
- const mcpContent = replaceTemplate(
- readFileSync(mcpTemplatePath, 'utf-8'),
- templateVars
- );
writeFileSync(join(cursorDir, 'mcp.json'), mcpContent);
}
+ if (aiTools.includes('claude')) {
+ writeFileSync(join(projectPath, '.mcp.json'), mcpContent);
+ }
}
}
@@ -279,6 +288,17 @@ async function main() {
console.log(chalk.green('✅ Project created successfully!\n'));
+ if (aiTools && aiTools.length > 0) {
+ console.log(chalk.cyan('📋 AI assistant files created:'));
+ if (aiTools.includes('cursor')) console.log(chalk.gray(' • Cursor: .cursorrules'));
+ if (aiTools.includes('copilot')) console.log(chalk.gray(' • GitHub Copilot: .github/copilot-instructions.md'));
+ if (aiTools.includes('claude')) console.log(chalk.gray(' • Claude: CLAUDE.md'));
+ if (aiTools.includes('generic')) console.log(chalk.gray(' • Generic: AI_INSTRUCTIONS.md, PROMPT.md'));
+ if (aiTools.includes('cursor')) console.log(chalk.gray(' • MCP (Cursor): .cursor/mcp.json'));
+ if (aiTools.includes('claude')) console.log(chalk.gray(' • MCP (Claude Code): .mcp.json'));
+ console.log('');
+ }
+
if (!answers.hasUploadPreset) {
console.log(chalk.yellow('\n📝 Note: Upload preset not configured'));
console.log(chalk.gray(' • Transformations will work with sample images'));
@@ -287,7 +307,8 @@ async function main() {
console.log(chalk.cyan(' 1. Go to https://console.cloudinary.com/app/settings/upload/presets'));
console.log(chalk.cyan(' 2. Click "Add upload preset"'));
console.log(chalk.cyan(' 3. Set it to "Unsigned" mode'));
- console.log(chalk.cyan(' 4. Add the preset name to your .env file\n'));
+ console.log(chalk.cyan(' 4. Add the preset name to your .env file'));
+ console.log(chalk.cyan(' 5. Save the file and restart the dev server so it loads correctly\n'));
}
if (installDeps) {
diff --git a/package-lock.json b/package-lock.json
index 1d72ba9..c8d64fc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "create-cloudinary-react",
- "version": "1.0.0-beta.12",
+ "version": "1.0.0-beta.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-cloudinary-react",
- "version": "1.0.0-beta.12",
+ "version": "1.0.0-beta.15",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
diff --git a/package.json b/package.json
index 59bff43..6718323 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "create-cloudinary-react",
- "version": "1.0.0-beta.13",
+ "version": "1.0.0-beta.21",
"description": "Scaffold a Cloudinary React + Vite + TypeScript project with interactive setup",
"type": "module",
"bin": {
diff --git a/templates/.cursorrules.template b/templates/.cursorrules.template
index b1dc8e4..c95f311 100644
--- a/templates/.cursorrules.template
+++ b/templates/.cursorrules.template
@@ -30,7 +30,7 @@ If the user is **not** using the create-cloudinary-react CLI and only has these
Create a `.env` file in the project root with **Vite prefix** (required for client access):
- `VITE_CLOUDINARY_CLOUD_NAME=your_cloud_name` (required)
- `VITE_CLOUDINARY_UPLOAD_PRESET=your_unsigned_preset_name` (optional; required for unsigned upload widget)
-- Restart the dev server after adding or changing `.env`. Use `import.meta.env.VITE_*` in code, not `process.env`.
+- Save the `.env` file after editing it, then restart the dev server so changes load correctly. Use `import.meta.env.VITE_*` in code, not `process.env`.
**2. Reusable Cloudinary instance (config)**
Create a config file (e.g. `src/cloudinary/config.ts`) so the rest of the app can use a single `cld` instance:
@@ -48,10 +48,15 @@ export const uploadPreset = import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET || '';
- Use **this** pattern for the reusable instance. Everywhere else: `import { cld } from './cloudinary/config'` (or the path the user chose) and call `cld.image(publicId)` / `cld.video(publicId)`.
**3. Upload Widget (unsigned, from scratch)**
-- **Script**: Add to `index.html`: ``. Because the script loads **async**, React's useEffect can run before it's ready — **do not** call `createUploadWidget` until the script is loaded.
-- **Wait for script**: Before calling `window.cloudinary.createUploadWidget(...)`, ensure `typeof window.cloudinary?.createUploadWidget === 'function'`. If not ready, poll (e.g. setInterval) or wait for the script's `onload` (if you inject the script in code). Otherwise you get "createUploadWidget is not a function".
-- **Create widget in useEffect**, not in render. Store the widget in a **ref**. Pass options: `{ cloudName: import.meta.env.VITE_CLOUDINARY_CLOUD_NAME, uploadPreset: uploadPreset || undefined, sources: ['local', 'camera', 'url'], multiple: false }`. Use `uploadPreset` from config or env.
-- **Open on click**: Attach a click listener to a button that calls `widgetRef.current?.open()`. Remove the listener in useEffect cleanup. Handle script load failures (e.g. show error state if script never loads).
+
+**Strict pattern (always follow this exactly):**
+1. **Script in `index.html`** (required): Add `` to `index.html`. Do **not** rely only on dynamic script injection from React — it's fragile.
+2. **Poll in useEffect** (required): In `useEffect`, poll with `setInterval` (e.g. every 100ms) until `typeof window.cloudinary?.createUploadWidget === 'function'`. Only then create the widget. A single check (even in `onload`) is **not** reliable because `window.cloudinary` can exist before `createUploadWidget` is attached.
+3. **Add a timeout**: Set a timeout (e.g. 10 seconds) to stop polling and show an error if the script never loads. Clear both interval and timeout in cleanup.
+4. **Create widget once**: When `createUploadWidget` is available, create the widget and store it in a **ref**. Clear the interval and timeout. Pass options: `{ cloudName, uploadPreset, sources: ['local', 'camera', 'url'], multiple: false }`.
+5. **Open on click**: Attach a click listener to a button that calls `widgetRef.current?.open()`. Remove the listener in useEffect cleanup.
+
+❌ **Do NOT**: Check only `window.cloudinary` (not enough); do a single check in `onload` (unreliable); skip the script in `index.html`; poll forever without a timeout.
- **Signed uploads**: Do not use only `uploadPreset`; use the pattern under "Secure (Signed) Uploads" (uploadSignature as function, fetch api_key, server includes upload_preset in signature).
**4. Video player**
@@ -60,7 +65,7 @@ export const uploadPreset = import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET || '';
**5. Summary for rules-only users**
- **Env**: Use your bundler's client env prefix and access (Vite: `VITE_` + `import.meta.env.VITE_*`; see "Other bundlers" if not Vite).
- **Reusable instance**: One config file that creates and exports `cld` (and optionally `uploadPreset`) from `@cloudinary/url-gen`; use it everywhere.
-- **Upload widget**: Script in index.html (or equivalent); create widget once in useEffect with ref; unsigned = cloudName + uploadPreset; signed = use uploadSignature function and backend.
+- **Upload widget**: Script in index.html (required); in useEffect, **poll** until `createUploadWidget` is a function, then create widget once and store in ref; unsigned = cloudName + uploadPreset; signed = use uploadSignature function and backend.
- **Video player**: Imperative video element (createElement, append to container ref, pass to videoPlayer); dispose + removeChild in cleanup; fall back to AdvancedVideo if init fails.
**If the user is not using Vite:** Use their bundler's client env prefix and access in the config file and everywhere you read env. Examples: Create React App → `REACT_APP_CLOUDINARY_CLOUD_NAME`, `process.env.REACT_APP_CLOUDINARY_CLOUD_NAME`; Next.js (client) → `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`. The rest (cld instance, widget options, video player) is the same.
@@ -201,16 +206,16 @@ cld.image('id').overlay(
- ✅ Import plugins from `@cloudinary/react`
- ✅ Pass plugins as array: `plugins={[responsive(), lazyload(), placeholder()]}`
- ✅ Recommended plugin order:
- 1. `responsive()` - First
- 2. `lazyload()` - Second
- 3. `accessibility()` - Third
- 4. `placeholder()` - Last
+ 1. `responsive()` - First (handles breakpoints)
+ 2. `placeholder()` - Second (shows placeholder while loading)
+ 3. `lazyload()` - Third (delays loading until in viewport)
+ 4. `accessibility()` - Last (if needed)
- ✅ Always add `width` and `height` attributes to prevent layout shift
- ✅ Example:
```tsx
@@ -250,11 +255,18 @@ cld.image('id').overlay(
## Upload Widget Pattern
- ✅ Use component: `import { UploadWidget } from './cloudinary/UploadWidget'`
-- ✅ Load script in `index.html`:
+
+**Strict initialization pattern (always follow this exactly):**
+1. ✅ **Script in `index.html`** (required):
```html
```
-- ✅ **Race condition**: The script loads **async**, so React's useEffect may run before `createUploadWidget` exists. **Wait until** `typeof window.cloudinary?.createUploadWidget === 'function'` before calling it (e.g. poll with setInterval or wait for script onload). Checking only `window.cloudinary` is not enough — `createUploadWidget` might not be attached yet. Otherwise: "createUploadWidget is not a function".
+2. ✅ **Poll in useEffect until `createUploadWidget` is available** (required): Use `setInterval` (e.g. every 100ms) to check `typeof window.cloudinary?.createUploadWidget === 'function'`. Only create the widget when this returns `true`. Clear the interval once ready.
+3. ✅ **Add a timeout** (e.g. 10 seconds) to stop polling and show an error state if the script never loads. Clear both interval and timeout in cleanup and when ready.
+4. ✅ **Create widget once**, store in a ref. Cleanup: clear interval, clear timeout, remove click listener.
+
+❌ **Do NOT**: Check only `window.cloudinary` (the function may not be attached yet); do a single check in `onload` (unreliable timing); skip `index.html` and rely only on dynamic injection; poll forever without a timeout.
+
- ✅ Create unsigned upload preset in dashboard at `settings/upload/presets`
- ✅ Add to `.env`: `VITE_CLOUDINARY_UPLOAD_PRESET=your_preset_name`
- ✅ Handle callbacks:
@@ -408,11 +420,17 @@ Use when the user asks for a **video player** (styled UI, controls, playlists).
**Rule: imperative element only.** Do **not** pass a React-managed `` to the player — the library mutates the DOM and React will throw removeChild errors. Create the video element with `document.createElement('video')`, append it to a container ref, and pass that element to `videoPlayer(el, ...)`.
- **Package**: `cloudinary-video-player`. Install with `npm install cloudinary-video-player` (no version).
-- **Import**: `import { videoPlayer } from 'cloudinary-video-player'` (named) and `import 'cloudinary-video-player/cld-video-player.min.css'` (no `dist/` in path).
+- **Import**: `import { videoPlayer } from 'cloudinary-video-player'` (named) and `import 'cloudinary-video-player/cld-video-player.min.css'` (no `dist/` in path). The package only exposes paths under `lib/` via `exports`; use `cld-video-player.min.css` (no `dist/`), which resolves to `lib/cld-video-player.min.css`.
+- ❌ **WRONG**: `import 'cloudinary-video-player/dist/cld-video-player.min.css'` — package `exports` do not expose `dist/`; the valid path is `cloudinary-video-player/cld-video-player.min.css`.
- **player.source()** takes an **object**: `player.source({ publicId: 'samples/elephants' })`. Not a string.
- **Cleanup**: Call `player.dispose()`, then **only if** `el.parentNode` exists call `el.parentNode.removeChild(el)` (avoids NotFoundError).
- **If init fails** (CSP, extensions, timing): render **AdvancedVideo** with the same publicId. Do not relax CSP in index.html or ask the user to disable extensions.
+**Poster options**: Always include `posterOptions` for a predictable poster image with a fallback color:
+- `transformation: { startOffset: '0' }` — use the first frame of the video as the poster (consistent and loads reliably)
+- `posterColor: '#0f0f0f'` — if the poster image fails to load, shows a dark background instead of blank/broken
+- These can be overridden via props (e.g. `posterOptions={{ transformation: { startOffset: '5' } }}` for a different frame)
+
**Example (copy this pattern):**
```tsx
const containerRef = useRef(null);
@@ -423,7 +441,16 @@ useLayoutEffect(() => {
el.className = 'cld-video-player cld-fluid';
containerRef.current.appendChild(el);
try {
- const player = videoPlayer(el, { cloudName, secure: true, controls: true, fluid: true });
+ const player = videoPlayer(el, {
+ cloudName,
+ secure: true,
+ controls: true,
+ fluid: true,
+ posterOptions: {
+ transformation: { startOffset: '0' },
+ posterColor: '#0f0f0f',
+ },
+ });
player.source({ publicId: 'samples/elephants' });
playerRef.current = player;
} catch (err) { console.error(err); }
@@ -494,18 +521,28 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player.md
- ✅ Access with type safety: `import.meta.env.VITE_CLOUDINARY_CLOUD_NAME`
### Type Guards and Safety
-- ✅ Type guard for window.cloudinary:
+- ✅ Type guard for window.cloudinary (check `createUploadWidget`, not just `cloudinary`):
```tsx
- function isCloudinaryLoaded(): boolean {
+ function isUploadWidgetReady(): boolean {
return typeof window !== 'undefined' &&
- typeof window.cloudinary !== 'undefined';
+ typeof window.cloudinary?.createUploadWidget === 'function';
}
```
-- ✅ Use type guards before accessing:
+- ✅ Use type guards before accessing (but **always poll with timeout** in useEffect — don't rely on a single check):
```tsx
- if (isCloudinaryLoaded()) {
- window.cloudinary.createUploadWidget(...);
- }
+ // In useEffect, poll until ready with timeout:
+ const interval = setInterval(() => {
+ if (isUploadWidgetReady()) {
+ clearInterval(interval);
+ clearTimeout(timeout);
+ window.cloudinary.createUploadWidget(...);
+ }
+ }, 100);
+ const timeout = setTimeout(() => {
+ clearInterval(interval);
+ console.error('Upload widget script failed to load');
+ }, 10000);
+ // Cleanup: clearInterval(interval); clearTimeout(timeout);
```
### Ref Typing Patterns
@@ -553,7 +590,7 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player.md
- ✅ Use `placeholder()` and `lazyload()` plugins together
- ✅ Always add `width` and `height` attributes to `AdvancedImage`
- ✅ Store `public_id` from upload success, not full URL
-- ✅ Video player: use imperative element only; dispose in useLayoutEffect cleanup and remove element with `if (el.parentNode) el.parentNode.removeChild(el)`
+- ✅ Video player: use imperative element only; dispose in useLayoutEffect cleanup and remove element with `if (el.parentNode) el.parentNode.removeChild(el)`; always include `posterOptions` with `transformation: { startOffset: '0' }` and `posterColor: '#0f0f0f'` for reliable poster display
- ✅ Use TypeScript for better autocomplete and error catching
- ✅ Prefer `unknown` over `any` when types aren't available
- ✅ Use type guards for runtime type checking
@@ -656,16 +693,17 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player.md
4. Consider using server-side upload for very large files
### Widget not opening
-- ❌ Problem: Script not loaded or initialization issue
+- ❌ Problem: Script not loaded, or widget created before `createUploadWidget` was available
- ✅ Solution:
1. Ensure script is in `index.html`: ``
- 2. Check widget initializes in `useEffect` after `window.cloudinary` is available
+ 2. In `useEffect`, **poll** with `setInterval` until `typeof window.cloudinary?.createUploadWidget === 'function'` — only then create the widget. Do **not** check only `window.cloudinary`.
3. Verify upload preset is set correctly
### "createUploadWidget is not a function"
-- ❌ Problem: **Race condition** — the script in index.html loads **async**, so React's useEffect can run before the script has finished loading. `window.cloudinary` might exist but `createUploadWidget` isn't attached yet.
-- ✅ **Wait for script**: Before calling `window.cloudinary.createUploadWidget(...)`, ensure `typeof window.cloudinary?.createUploadWidget === 'function'`. If not ready, poll (e.g. setInterval until it exists) or inject the script in code and call createUploadWidget in the script's `onload`. Don't assume `window.cloudinary` means the API is ready.
-- ✅ See PATTERNS → Upload Widget Pattern ("Race condition") and Project setup → Upload Widget ("Wait for script").
+- ❌ Problem: **Race condition** — the script loads **async**, so `window.cloudinary` can exist before `createUploadWidget` is attached. A single check (even in `onload`) is **not** reliable.
+- ✅ **Always poll**: In `useEffect`, use `setInterval` to check `typeof window.cloudinary?.createUploadWidget === 'function'`. Only create the widget when this returns `true`. Clear the interval once ready.
+- ❌ **Do NOT**: Check only `window.cloudinary`; do a single check in `onload`; skip the script in `index.html`.
+- ✅ See PATTERNS → Upload Widget Pattern and Project setup → Upload Widget for the strict pattern.
### Video player: "Invalid target for null#on" or React removeChild or NotFoundError
- ❌ Problem: Passing a React-managed `` to the player causes removeChild errors (the player mutates the DOM). Or container/ref not in DOM yet when init runs.
@@ -718,7 +756,7 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player.md
### Cloudinary package install fails or "version doesn't exist"
- ❌ Problem: Agent pinned a Cloudinary package to a specific version (e.g. `cloudinary-video-player@1.2.3`) that doesn't exist on npm, or used a wrong package name.
-- ✅ **Install latest**: Use `npm install ` with **no version** so npm gets the latest compatible. In package.json use a **caret** (e.g. `"cloudinary-video-player": "^1.0.0"`). Use only correct package names: `@cloudinary/react`, `@cloudinary/tps://console.cloudinary.com/app/settings/upload/presets\n\n'-gen`, `cloudinary-video-player`, `cloudinary`. See PATTERNS → "Installing Cloudinary packages".
+- ✅ **Install latest**: Use `npm install ` with **no version** so npm gets the latest compatible. In package.json use a **caret** (e.g. `"cloudinary-video-player": "^1.0.0"`). Use only correct package names: `@cloudinary/react`, `@cloudinary/url-gen`, `cloudinary-video-player`, `cloudinary`. See PATTERNS → "Installing Cloudinary packages".
### Confusion between AdvancedVideo and Video Player
- **AdvancedVideo** = for **displaying** a video (not a full player). **Cloudinary Video Player** = the **player** (styled UI, controls, playlists, etc.).
@@ -742,6 +780,11 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player.md
### Video player: "source is not a function" or video not playing
- **player.source()** takes an **object**: `player.source({ publicId: 'samples/elephants' })`, not a string. Use named import: `import { videoPlayer } from 'cloudinary-video-player'`. See PATTERNS → Cloudinary Video Player (The Player).
+### Video player: poster image missing, wrong frame, or broken
+- ❌ Problem: Video player shows no poster, wrong poster frame, or blank area before video loads.
+- ✅ **Always include `posterOptions`** in the player config: `posterOptions: { transformation: { startOffset: '0' }, posterColor: '#0f0f0f' }`. This uses the first frame as the poster (reliable) and provides a dark fallback color if the poster fails to load.
+- ✅ **Override if needed**: Pass different values via props, e.g. `startOffset: '5'` for a frame 5 seconds in, or a different `posterColor` for your design.
+
### Overlay: "Cannot read properties of undefined" or overlay not showing
- ❌ Problem: Wrong overlay API usage (Overlay.source, compass constants, .transformation().resize, fontWeight on wrong object).
- ✅ Import `source` directly from `@cloudinary/url-gen/actions/overlay` (not `Overlay.source`). Use **string** values for compass: `compass('south_east')` (underscores, not camelCase). Use `new Transformation()` inside `.transformation()`. Put `fontWeight` on **TextStyle**; put `textColor` on the **text source**. See PATTERNS → Image Overlays (text or logos).
@@ -815,8 +858,8 @@ When something isn't working, check:
- [ ] Format/quality use separate `.delivery()` calls
- [ ] Plugins are in array format
- [ ] Upload widget script is loaded in `index.html`
-- [ ] **"createUploadWidget is not a function"?** → Wait until `typeof window.cloudinary?.createUploadWidget === 'function'` before calling it (script loads async; poll or use script onload)
-- [ ] **Video player?** → **Imperative element only**: createElement('video'), append to container ref, pass to videoPlayer(el, ...); player.source({ publicId }); cleanup: dispose then if (el.parentNode) el.parentNode.removeChild(el). CSS: cloudinary-video-player/cld-video-player.min.css. If init fails, fall back to AdvancedVideo (do not relax CSP).
+- [ ] **"createUploadWidget is not a function"?** → In useEffect, **poll** with setInterval until `typeof window.cloudinary?.createUploadWidget === 'function'`. Do NOT check only `window.cloudinary`; do NOT rely on a single onload check
+- [ ] **Video player?** → **Imperative element only**: createElement('video'), append to container ref, pass to videoPlayer(el, ...); include `posterOptions: { transformation: { startOffset: '0' }, posterColor: '#0f0f0f' }` for reliable poster; player.source({ publicId }); cleanup: dispose then if (el.parentNode) el.parentNode.removeChild(el). CSS: cloudinary-video-player/cld-video-player.min.css. If init fails, fall back to AdvancedVideo (do not relax CSP).
- [ ] **Upload fails (unsigned)?** → Is `VITE_CLOUDINARY_UPLOAD_PRESET` set? Preset exists and is Unsigned in dashboard?
- [ ] **Upload default?** → Default to **unsigned** uploads (cloudName + uploadPreset); use signed only when the user explicitly asks for secure/signed uploads (signed requires a running backend)
- [ ] **Secure uploads?** → Use `uploadSignature` as function (not `signatureEndpoint`); fetch `api_key` from server first; include `uploadPreset` in widget config; server includes `upload_preset` in signed params; use Cloudinary Node SDK v2 on server; never expose or commit API secret
diff --git a/templates/README.md.template b/templates/README.md.template
index 4faa69d..fa24eac 100644
--- a/templates/README.md.template
+++ b/templates/README.md.template
@@ -11,18 +11,14 @@ npm run dev
## Cloudinary Setup
This project uses Cloudinary for image management. If you don't have a Cloudinary account yet:
-- [Sign up for free](https://cloudinary.com/users/register/free)
+- [Sign up for free](https://cld.media/reactregister)
- Find your cloud name in your [dashboard](https://console.cloudinary.com/app/home/dashboard)
## Environment Variables
Your `.env` file has been pre-configured with:
- `VITE_CLOUDINARY_CLOUD_NAME`: {{CLOUD_NAME}}
-{{#UPLOAD_PRESET}}
-- `VITE_CLOUDINARY_UPLOAD_PRESET`: {{UPLOAD_PRESET}}
-{{/UPLOAD_PRESET}}
-{{^UPLOAD_PRESET}}
-- `VITE_CLOUDINARY_UPLOAD_PRESET`: (not set - required for uploads)
+{{UPLOAD_PRESET_ENV_LINE}}
**Note**: Transformations work without an upload preset (using sample images). Uploads require an unsigned upload preset.
@@ -31,7 +27,8 @@ To create an unsigned upload preset:
2. Click "Add upload preset"
3. Set it to "Unsigned" mode
4. Add the preset name to your `.env` file
-{{/UPLOAD_PRESET}}
+5. **Save** the `.env` file and restart the dev server so the new values load correctly.
+
## AI Assistant Support
diff --git a/templates/index.html.template b/templates/index.html.template
index 1252adb..bad99bc 100644
--- a/templates/index.html.template
+++ b/templates/index.html.template
@@ -8,6 +8,8 @@
+
+