Skip to content

RESOURCES / BLOG

How I Built a Professional Course Preview Player in React With Cloudinary Video

Have you ever clicked on a video and a black rectangle is staring back at you? No thumbnail, no controls, just emptiness. That’s the default HTML5 <video> experience, and it makes your content feel unfinished before the user even presses play.

In this guide, we’ll fix that. You’ll build a full course catalog where every card shows a smart-cropped thumbnail derived directly from the video and opens a professional branded player with poster frames, scrub previews, and adaptive quality all powered by Cloudinary. By the end it’ll look like it belongs on a real learning platform.

Tool Why
React 19 + TypeScript Modern, type-safe component model
Vite Instant dev server, native ESM, great code-splitting
Tailwind CSS v4 Utility-first styling with no separate CSS files
shadcn/ui (base-nova) Accessible components built on @base-ui/react
Cloudinary Video Player SDK Branded player with thumbnails, quality selector, seek previews
Cloudinary URL-Gen SDK Build transformation URLs in TypeScript instead of hand-writing strings

Start from the official Vite React TypeScript template, then add the packages this project needs.

npm create vite@latest react-course-preview-player -- --template react-ts
cd react-course-preview-player
npm install
Code language: CSS (css)
npm install cloudinary-video-player @cloudinary/url-gen @cloudinary/react
npm install tailwindcss @tailwindcss/vite
npx shadcn@latest init
Code language: CSS (css)

cloudinary-video-player is the main SDK that powers the player UI. @cloudinary/url-gen gives us a typed API to build transformation URLs for thumbnails without writing raw strings by hand. @cloudinary/react provides the React wrapper for image delivery.

Every Cloudinary account has a Cloud Name it appears in your dashboard and in every asset URL:

https://res.cloudinary.com/YOUR-CLOUD-NAME/video/upload/...

Rather than hardcoding it, you’ll read it from an environment variable. This means the same codebase can point at a demo account in development and a production account in deployment without changing a single line of code.

Create a .env file at the project root:

VITE_CLOUDINARY_CLOUD_NAME=your-cloud-name

The VITE_ prefix is required Vite only exposes variables with that prefix to the browser bundle. Variables without it stay server-side only. Override it locally with .env.local (which is gitignored, so credentials never get committed):

# .env.local  your real account, never committed
VITE_CLOUDINARY_CLOUD_NAME=demo-article-projects
Code language: PHP (php)

Tell TypeScript that this variable exists by adding types to src/vite-env.d.ts:

interface ImportMetaEnv {
  readonly VITE_CLOUDINARY_CLOUD_NAME: string;
}
Code language: PHP (php)

Without this, TypeScript would complain that import.meta.env.VITE_CLOUDINARY_CLOUD_NAME is an unknown property. The readonly modifier also prevents you from accidentally reassigning it.

Now create a single shared Cloudinary instance in src/lib/cloudinary.ts. Every component that needs to talk to Cloudinary imports from here one place to update if anything changes:

import { Cloudinary } from '@cloudinary/url-gen';

export const CLOUD_NAME = import.meta.env.VITE_CLOUDINARY_CLOUD_NAME;

export const cld = new Cloudinary({
  cloud: { cloudName: CLOUD_NAME },
  url: { secure: true },  // always HTTPS
});
Code language: JavaScript (javascript)

secure: true ensures every generated URL uses HTTPS. Without it Cloudinary would default to HTTP in some contexts, which triggers mixed-content warnings in browsers.

View the full file here: src/lib/cloudinary.ts

Before building any UI, you’ll define the shape of our data. A typed Course interface means TypeScript will catch mistakes at compile time if you forget a required field or pass the wrong type anywhere, you know before the browser ever runs the code.

src/types/course.ts:

export interface Chapter {
  title: string;
  startTime: number;  // seconds from start
  duration: number;
}

export interface Course {
  id: string;
  title: string;
  instructor: string;
  videoPublicId: string;   // the path in your Cloudinary media library
  videoVersion?: string;   // explained in Step 6  needed for certain path formats
  duration: number;
  level: 'Beginner' | 'Intermediate' | 'Advanced';
  category: string;
  rating: number;
  students: number;
  description: string;
  chapters: Chapter[];
  tags: string[];
}
Code language: PHP (php)

videoPublicId is the key field it is the identifier Cloudinary assigns to every asset. You can find it in the Media Library. It often looks like folder/subfolder/filename. This single string is all you’ll need to derive the thumbnail, the poster frame, and to stream the video.

videoVersion is optional and only needed for certain public ID formats. We’ll explain exactly why in Step 6.

The actual course data lives in src/data/courses.ts. Here is one entry to illustrate the shape:

{
  id: 'react-fundamentals',
  title: ',
  instructor: 'Sarah Chen',
  videoPublicId: 'cld_video_upload/tzvekhwhsll7c8sjb40v',
  videoVersion: '1779134980',
  duration: 7320,
  level: 'Beginner',
  category: 'Frontend',
  rating: 4.9,
  students: 24_831,
  chapters: [
    { title: 'Introduction & Setup',   startTime: 0,   duration: 300 },
    { title: 'JSX & Components',       startTime: 300, duration: 480 },
  ],
  tags: ['React', 'JavaScript', 'Hooks'],
}
Code language: PHP (php)

View the full file here: src/data/courses.ts

This is the first place Cloudinary genuinely changes your workflow. Normally you’d have to generate thumbnails yourself, export a frame in video editing software, upload it separately, and manage two assets per video (forever). With Cloudinary, you don’t have to.

Cloudinary has a little-known trick: Append .jpg to a video URL and it returns a single frame as an image. Add transformation parameters before the filename to control exactly which frame you get and how it’s shaped.

src/components/VideoThumbnail.tsx:

export function VideoThumbnail({ publicId, version, alt, className }) {
  const vSeg = version ? `v${version}/` : '';
  const src =
    `https://res.cloudinary.com/${CLOUD_NAME}/video/upload/` +
    `so_2,c_fill,w_640,h_360,g_auto/f_jpg,q_auto/` +
    `${vSeg}${publicId}.jpg`;

  return (
    <img
      src={src}
      alt={alt}
      width={640}
      height={360}
      loading="lazy"     // browser skips this until it's near the viewport
      decoding="async"   // doesn't block the main thread while decoding
      className={className}
    />
  );
}
Code language: JavaScript (javascript)

Let’s break down the transformation string so_2,c_fill,w_640,h_360,g_auto/f_jpg,q_auto:

Transformation What It Does
so_2 Grab the frame at exactly 2 seconds. This skips opening slates, countdown screens, or blank intros that often appear in the first second.
c_fill Fill the exact dimensions requested no letterboxing, no empty space.
w_640,h_360 Output at 640×360 (16:9).
g_auto AI smart-crop Cloudinary detects the main subject and keeps it centred in the frame, even when cropping aggressively.
f_jpg Output as JPEG the right format for photographic video frames.
q_auto Automatically picks the highest quality that still compresses well. Cloudinary analyses the image content and chooses the setting you never manually tune quality again.

The / between groups creates a chained transformation Cloudinary processes them in order. First it crops the frame, then it applies format and quality settings to the output of that crop.

The result: one URL, zero extra uploads, and every card thumbnail is always in sync with the video. When Cloudinary changes the video, the thumbnail updates automatically on the next request.

View the full file here: src/components/VideoThumbnail.tsx

Each course card uses the VideoThumbnail component as its background image and adds a play button overlay that animates in on hover. The entire card is a clickable button accessible via keyboard as well as mouse.

src/components/CourseCard.tsx:

<Card
  role="button"
  tabIndex={0}
  onClick={() => onPlay(course)}
  onKeyDown={e => e.key === 'Enter' && onPlay(course)}
  className="group overflow-hidden cursor-pointer hover:-translate-y-1 hover:shadow-lg transition-all"
>
  <div className="relative overflow-hidden" style={{ aspectRatio: '16/9' }}>
    <VideoThumbnail
      publicId={course.videoPublicId}
      version={course.videoVersion}
      alt={`${course.title} thumbnail`}
      className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
    />

    {/* Overlay appears on hover  the `group` class on the card makes this possible */}
    <div className="absolute inset-0 bg-background/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
      <div className="size-14 rounded-full bg-primary flex items-center justify-center shadow-xl">
        <PlayCircle className="size-7 text-primary-foreground" />
      </div>
    </div>
  </div>
</Card>
Code language: HTML, XML (xml)

The group class on the parent card is a Tailwind feature, which lets child elements respond to the parent’s hover state with group-hover:. When the card is hovered, the overlay fades in (opacity-0opacity-100) and the thumbnail zooms slightly (scale-[1.04]). These two small CSS transitions together create the polished “interactive card” feel without a single line of JavaScript.

View the full file here: src/components/CourseCard.tsx

When a user clicks a card, a dialog opens containing the full Cloudinary Video Player. This is where a plain black box becomes a branded, professional experience.

Here’s something that trips up most React developers working with dialogs. The dialog renders into a portal and React mounts it as a child of document.body, completely outside the component tree that holds your useEffect.

This creates a timing problem: When useEffect fires after the component mounts, the <div> inside the dialog doesn’t exist yet because the portal hasn’t attached to the DOM. A plain useRef reads as null every single time, and the player never initialises.

The fix is a callback ref instead of a ref object, you’ll pass React a function. React calls that function with the DOM element the instant it is attached, and with null when it’s removed. Wrapping it in useState means the state update triggers a re-render, which reruns useEffect with a real, live DOM element.

src/components/CoursePlayer.tsx:

// useState gives us a way to re-trigger useEffect when the element attaches
const [container, setContainer] = useState<HTMLDivElement | null>(null);

// useCallback with [] ensures setContainer is stable across renders
const containerRef = useCallback((node: HTMLDivElement | null) => {
  setContainer(node);
}, []);

useEffect(() => {
  if (!container) return; // null on the first render  safely skip

  // One requestAnimationFrame lets the dialog's entry animation
  // complete before the SDK manipulates the DOM
  const rafId = requestAnimationFrame(() => {
    // Create the <video> element imperatively and hand it to the SDK
    const el = document.createElement('video');
    el.className = 'cld-video-player cld-fluid';
    container.appendChild(el);

    const player = videoPlayer(el, {
      cloudName: CLOUD_NAME,
      controls: true,
      fluid: true,           // fills whatever container it is placed in
      seekThumbnails: true,  // frame previews on scrub 
      qualitySelector: true, // adaptive bitrate 
      skin: 'dark',
      playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
    });

    // Tell the player which video to load  just the publicId, no URL building needed
    player.source({ publicId: course.videoPublicId, info: { title: course.title } });

    // Set the poster image directly on the <video> element
    el.poster = posterSrc;

    // Update React state so the loading skeleton hides when the player is ready
    player.on('ready', () => setPlayerState('ready'));
    player.on('error', () => setPlayerState('error'));
  });

  // Cleanup: runs when the dialog closes or the course changes
  return () => {
    cancelAnimationFrame(rafId);
    player.dispose(); // releases SDK resources
    el.remove();      // removes the <video> element from the DOM
  };
}, [container, course.id]); // re-runs whenever the container attaches or the course changes

// The div that receives the callback ref
<div ref={containerRef} className="w-full h-full" />
Code language: JavaScript (javascript)

Setting seekThumbnails: true tells the Cloudinary player to request a storyboard sprite — a single image that contains many small frames from across the video timeline. When the user hovers over the progress bar, the player crops the right section of the sprite and shows it as a tooltip preview.

This is a feature users expect from premium platforms like YouTube and Netflix. With Cloudinary you get it with one boolean.

The poster is the still image shown before the user presses play. You’ll build its URL with the same version-anchored pattern as the card thumbnail, but at a larger size (w_1280,h_720) since it fills the full player area:

const vSeg = course.videoVersion ? `v${course.videoVersion}/` : '';
const posterSrc =
  `https://res.cloudinary.com/${CLOUD_NAME}/video/upload/` +
  `so_2,c_fill,w_1280,h_720,g_auto/f_jpg,q_auto/` +
  `${vSeg}${course.videoPublicId}.jpg`;
Code language: JavaScript (javascript)

Set this directly on el.poster after the player initialises. This bypasses the SDK’s own poster mechanism, which would prepend so_2 without the version anchor and break the URL for certain public ID formats (explained next).

Some Cloudinary public IDs start with a path that begins with a string like cld_video_upload/. Cloudinary’s URL parser treats everything between /upload/ and the version or public ID as transformation parameters. When it sees cld at the start of what should be the public ID, it reads cld as a transformation name and video_upload as its value and rejects the request with a 400 error.

# ❌ Parser misreads "cld" as a transformation param → 400
/video/upload/so_2/cld_video_upload/my-video.jpg

# ✅ Version number (v + digits) acts as a hard boundary
#    Parser sees: transforms → version → publicId
/video/upload/so_2,c_fill,.../v1779134980/cld_video_upload/my-video.jpg

The same problem hits the player’s default adaptive streaming URL. By default the player tries to load an HLS manifest at a URL like sp_auto/cld_video_upload/... and gets a 400 for the same reason. The fix is to tell the player to use MP4-only delivery for these courses, which skips HLS entirely:

player.source({
  publicId: course.videoPublicId,
  // Without this, the player tries HLS first → URL parsing bug → 400 → black screen
  sourceTypes: ['mp4'],
});
Code language: CSS (css)

You’ll also disable seekThumbnails and qualitySelector for these courses because both generate URLs with transforms prepended to the public ID. Four of the six courses get the full feature set; the two with cld_video_upload/ paths get a clean MP4 stream. This is the only reason the videoVersion field exists on the Course type; it’s the flag that tells the player and thumbnail components to apply the version anchor.

View the full file here: src/components/CoursePlayer.tsx

The Cloudinary Video Player SDK is approximately 500 KB of JavaScript. If you import it at the top of your app, every visitor downloads that bundle on the initial page load even if they never click a single course card.

React.lazy() solves this by code-splitting CoursePlayer into its own separate chunk. Vite sees the dynamic import and creates a separate file during the build. That file is only requested when the component is first rendered which in our case means when a user actually clicks a course card.

src/App.tsx:

// The SDK bundle is excluded from the initial page load entirely
const CoursePlayer = lazy(() =>
  import('@/components/CoursePlayer').then(m => ({ default: m.CoursePlayer }))
);

// Suspense handles the brief moment between click and chunk download
// fallback={null} because the CoursePlayer has its own skeleton loading state
{activeCourse && (
  <Suspense fallback={null}>
    <CoursePlayer course={activeCourse} onClose={() => setActiveCourse(null)} />
  </Suspense>
)}
Code language: JavaScript (javascript)

The .then(m => ({ default: m.CoursePlayer })) is needed because CoursePlayer is a named export, not a default export. React.lazy() requires a default export, so we reshape the module on the fly.

To verify this is working: Open the Network tab in DevTools and hard-refresh the page. You’ll see no Cloudinary SDK chunk on load. Click a course card and a new chunk appears in the network waterfall. That’s lazy loading working exactly as intended your initial bundle stays lean and fast.

View the full file here: src/App.tsx

Two CSS lines prevent horizontal scroll on mobile a common issue when mixing fixed-width elements with fluid layouts:

/* src/index.css */
html { overflow-x: clip; }   /* clips overflow without breaking position:fixed elements */
body { overflow-x: hidden; } /* belt and suspenders */
Code language: CSS (css)

overflow-x: clip is important here overflow-x: hidden on html breaks position: fixed elements like modals and sticky headers. clip hides the overflow without creating a new scroll container, so fixed elements continue to work correctly.

The feature strip converts from a CSS grid on desktop to a horizontal scroll carousel on mobile, without any JavaScript:

/* Mobile: scrollable horizontal carousel */
.feature-strip {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}

/* Desktop: back to a normal grid */
@media (min-width: 640px) {
  .feature-strip {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    overflow-x: visible;
  }
}
Code language: CSS (css)
Feature What It Does Cloudinary Docs
Video Player SDK Full branded player, not plain HTML5 cloudinary_video_player
so_2 frame extraction Poster and thumbnail from 2-second mark Video manipulation
c_fill, g_auto AI smart-crop to 16:9, subject-aware Auto gravity
q_auto Optimal quality chosen automatically Image optimization
seekThumbnails Frame previews on progress bar scrub Player customization
qualitySelector Adaptive bitrate based on connection Player API reference

Starting from a blank Vite project, you now have a:

  • Course catalog where thumbnails are generated automatically from the video no design work, no separate image uploads, no synchronization headaches.
  • Professional branded video player with seek thumbnails, quality selection, and playback speed control.
  • Poster frame that matches the thumbnail — same frame, same smart-crop — automatically.
  • Player that adds zero bytes to the initial page load, and the 500 KB SDK only downloads when a user actually opens a video.
  • Layout that works on every screen size with just CSS.

The best part: point it at any video already in your Cloudinary account and everything thumbnail, poster, seek thumbnails is generated on the fly. No extra steps, no extra tools.

Clone it, swap in your cloud name and video public IDs, and you have a production-ready course preview player running in minutes. Ready to start building with Cloudinary? Sign up for a free account today.

Why is a standard HTML5 video element insufficient for modern e-learning platforms?

Standard HTML5 video elements often present users with a blank black box on load. They require manual creation and management of separate image files for custom posters and video seek previews, which drastically complicates asset management.

How does Cloudinary generate video thumbnails and poster frames on demand?

Cloudinary extracts frames directly from video assets by altering the file extension to an image format like JPG. Adding URL transformation parameters allows you to specify an exact timestamp, size, and smart-cropping rules on the fly.

How do you resolve timing issues when initializing a third-party player inside a React dialog portal?

You can use a React callback ref instead of a standard useRef. The callback function is invoked precisely when the DOM element mounts or unmounts, providing a stable container instance for secure player initialization.

What are seek thumbnails and how are they implemented in Cloudinary?

Seek thumbnails are hover previews shown when scrubbing the progress bar. Cloudinary automatically generates a single storyboard sprite sheet containing timed video frames, which the player displays dynamically as the user hovers over the timeline.

How do you prevent a large video player SDK from impacting initial web page loading performance?

You can dynamically import the player component using React lazy loading and Suspense. This splits the heavy SDK into a separate bundle that the browser only downloads when a user clicks to play a video.

Start Using Cloudinary

Sign up for our free plan and start creating stunning visual experiences in minutes.

Sign Up for Free