Skip to content

RESOURCES / BLOG

Dynamic Ad-Supported Video Playlist With Next.js and Cloudinary

Most video players handle one clip and stop there. They don’t handle playlists, mid-roll ads, or smooth handoffs between videos. This project fixes that.

You’ll build a small, flexible system that plays a set of videos, inserts ads, and switches sources without disrupting the viewer experience. Cloudinary delivers every asset, and Next.js handles the playback logic on the client.

The result is a simple, fast player that feels closer to a real streaming app.

  • A playlist that moves from one video to the next.
  • Mid-roll ads that trigger at any second you choose.
  • Tools to skip ads, test ads, and change settings on the fly.
  • Clean components you can extend or replace.

This setup is easy to adapt for learning platforms, tutorials, creator content, or lightweight streaming features.

This project uses three main parts that work together.

  1. Next.js for the player logic. Next.js gives you a fast client experience. You control playback with React state, effects, and small components. Each part of the player stays simple and easy to change.

  2. Cloudinary for video delivery. Cloudinary stores and serves every video. You only need the public IDs. The player pulls videos from folders like:

  • videos/main/ for your main content.
  • videos/ads/ for ad clips.

Cloudinary handles streaming, formats, and device support.

  1. A playlist system with mid roll ads. The player loads a list of videos. It plays them one by one. During playback, it checks the current time and switches to an ad clip when needed. After the ad ends, the player returns to the exact second it left off.

You can change the ad video, when it plays, the ad link, and the current item in the playlist. This gives you a small, flexible base that behaves like a simple streaming workflow.

Before we talk about ads, playlists, and timing, you need a stable base: a fresh Next.js app, small UI layer, and Cloudinary ready to serve video.

The goal for this section is simple:

  • A new Next.js project running.
  • shadcn wired in for UI.
  • Cloudinary env set up with your main and ad videos.
  • A blank AdPlayer on the home page, ready to evolve.

Start with a standard App Router setup.

npx create-next-app@latest ad-player
cd ad-player
Code language: CSS (css)

Use:

  • TypeScript: yes
  • Tailwind: yes
  • App Router: yes

You can run npm run dev now and see the default Next.js page at http://localhost:3000.

This project in the repo lives here: https://github.com/musebe/ad-player

You’ll use shadcn to avoid hand rolling basic components.

Initialize it:

npx shadcn@latest init
Code language: CSS (css)

Then pull in a few building blocks:

npx shadcn@latest add card button input
Code language: CSS (css)

These pieces give us the card for the player shell, the main play and skip buttons, and simple inputs for ad settings.

Next, you’ll make sure the app knows where your videos live.

Your .env.local is the contract between Next.js and Cloudinary.

Create .env.local in the project root and add:

NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=demo-article-projects

# Main videos
NEXT_PUBLIC_MAIN_1=videos/main/my-main-video
NEXT_PUBLIC_MAIN_2=videos/main/my-main-video2
NEXT_PUBLIC_MAIN_3=videos/main/my-main-video3

# Ads
NEXT_PUBLIC_AD_1=videos/ads/my-ad
NEXT_PUBLIC_AD_2=videos/ads/my-ad2
NEXT_PUBLIC_AD_3=videos/ads/my-ad3

A few important points:

  • Each value is a public id, not a full URL.

  • The folder layout in Cloudinary should match:

    • videos/main/my-main-video
    • videos/main/my-main-video2
    • videos/main/my-main-video3

    and

    • videos/ads/my-ad
    • videos/ads/my-ad2
    • videos/ads/my-ad3

You’ll turn these ids into real video URLs in one small helper.

Instead of hard coding URLs everywhere, we use a single function that builds them using your env and public id.

In lib/cloudinary.ts:

// lib/cloudinary.ts
export function getCloudinaryVideoUrl(publicId: string) {
  const cloudName = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME;
  if (!cloudName || !publicId) return '';

  return `https://res.cloudinary.com/${cloudName}/video/upload/${publicId}.mp4`;
}
Code language: JavaScript (javascript)

The rest of the app only needs to know the id:

const url = getCloudinaryVideoUrl('videos/main/my-main-video');
Code language: JavaScript (javascript)

You can check the full helper in the repo here: lib/cloudinary.ts

Now, you’ll give the UI something to render. Nothing advanced yet, just a card that will host the player.

In components/video/AdPlayer.tsx, the important part looks like:

'use client';

import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';

export function AdPlayer() {
  return (
    <Card className='w-full max-w-3xl mx-auto'>
      <CardHeader>
        <CardTitle>Ad supported video player</CardTitle>
      </CardHeader>
      <CardContent>
        {/* Video, controls, and settings will live here */}
      </CardContent>
    </Card>
  );
}
Code language: JavaScript (javascript)

Then, wire it into the home page:

// app/page.tsx
import { AdPlayer } from '@/components/video/AdPlayer';

export default function HomePage() {
  return (
    <main className='min-h-screen flex items-center justify-center bg-background'>
      <AdPlayer />
    </main>
  );
}
Code language: JavaScript (javascript)

You can see the final full versions in the repo:

  • app/page.tsx

  • components/video/AdPlayer.tsx

    inside https://github.com/musebe/ad-player

At this point, you’ll have:

  • A fresh Next.js app.
  • shadcn wired in.
  • Cloudinary env configured for three main videos and three ads.
  • A centered AdPlayer card ready to host the video logic.

Now that the project runs, you’ll connect it to Cloudinary. This gives the player real videos to work with.

Your .env.local has six IDs: three main videos and three ads. You’ll turn them into two simple lists: a content playlist and an ad list.

Inside AdPlayer.tsx, read the env values and convert each one into a Cloudinary URL using our small helper.

const mainIds = [
  process.env.NEXT_PUBLIC_MAIN_1,
  process.env.NEXT_PUBLIC_MAIN_2,
  process.env.NEXT_PUBLIC_MAIN_3,
];

const contentPlaylist = mainIds.map((id, index) => ({
  id: `video-${index + 1}`,
  title: `Main video ${index + 1}`,
  src: getCloudinaryVideoUrl(id),
}));
Code language: JavaScript (javascript)

These items drive the sidebar playlist and give the player its active source.

Full file: components/video/AdPlayer.tsx

Ads are handled as a simple rotating set.

const adIds = [
  process.env.NEXT_PUBLIC_AD_1,
  process.env.NEXT_PUBLIC_AD_2,
  process.env.NEXT_PUBLIC_AD_3,
];

const adList = adIds.map((id) => getCloudinaryVideoUrl(id));
Code language: JavaScript (javascript)

Later, you’ll pick the current ad like this:

const [adIndex, setAdIndex] = useState(0);
const currentAdSrc = adList[adIndex] || '';
Code language: JavaScript (javascript)

Choose which main item to show using simple state.

const [currentIndex, setCurrentIndex] = useState(0);
const currentItem = contentPlaylist[currentIndex];
const currentMainSrc = currentItem?.src || '';
Code language: JavaScript (javascript)

The UI updates whenever you click a playlist item.

Playlist component: components/video/VideoPlaylist.tsx

The <video> element lives in a separate component for clarity.

// VideoSurface.tsx
<video ref={videoRef} className="h-full w-full object-cover" controls />
Code language: HTML, XML (xml)

You’ll pass a ref from the player:

const videoRef = useRef<HTMLVideoElement | null>(null);
Code language: JavaScript (javascript)

This ref lets you inject the video source, control playback, and listen for timeupdate and ended events.

Video surface reference: components/video/VideoSurface.tsx

With our assets mapped and the playlist ready, the player now needs a sense of timing. It must know when to interrupt the show, how to deliver the ad, and how to guide the viewer back to their spot. This is where the actual behavior of the player comes to life.

All of this logic stays in a single place:

Full file: components/video/AdPlayer.tsx

Keep the app simple by using a single <video> element.

Instead of juggling separate players for ads and content, you’ll shift its behavior using one piece of state:

const [isPlayingAd, setIsPlayingAd] = useState(false);
Code language: JavaScript (javascript)

When this flag is off, the player behaves like a normal content viewer.

When it turns on, the same element becomes an ad player.

The UI only needs this flag to update labels like “Main Video” or “Ad”, handled inside:

Video surface: components/video/VideoSurface.tsx

The swap between content and ad needs to be invisible.

Never replace the video element. Only change the src underneath it.

Inside the effect that watches for mode changes, the player decides which URL to load:

const src = isPlayingAd ? currentAdSrc : currentMainSrc;
currentVideo.src = src;
Code language: JavaScript (javascript)

No flicker. No jump. The <video> tag stays rooted in place and simply loads its new source.

To interrupt the main video at the right second, you’ll use its natural timeline. The video fires a timeupdate event many times per second. Watch that and compare it to the chosen trigger time:

if (video.currentTime >= adStartTime) {
  setResumeTime(video.currentTime);
  setIsPlayingAd(true);
  setHasPlayedAd(true);
}
Code language: JavaScript (javascript)

This does three things at once:

  • Bookmarks the exact second the viewer reached.
  • Switches the player into ad mode.
  • Prevent repeats by marking the ad as “played” for this video.

The controls for setting the trigger time live here:

Ad settings: components/video/AdConfigPanel.tsx

Finishing a video clip, whether it’s the ad or the main content, signals what happens next.

Listen to the ended event and make a choice.

When an ad ends:

  • Switch back to content.
  • Restore where the viewer left.
  • Advance the ad index so the next video gets a different ad.

When the content ends:

  • Reset ad state.
  • Move to the next item in the playlist.

The core logic looks like:

if (isPlayingAd) {
  setIsPlayingAd(false);
  setAdIndex((prev) => (prev + 1) % adList.length);
} else {
  setCurrentIndex((prev) => prev + 1);
}
Code language: JavaScript (javascript)

The playlist UI itself lives here:

Playlist view: components/video/VideoPlaylist.tsx

Sometimes you’ll want to test behavior instantly without waiting for the trigger time. Sometimes the viewer wants to skip the ad. Both needs are covered.

The control panel gives you:

  • Play
  • Skip ad
  • Force ad
  • Apply new settings (resetting the state cleanly)

Here’s the reset logic:

function handleApplySettings() {
  setIsPlayingAd(false);
  setResumeTime(0);
  currentVideo.currentTime = 0;
}
Code language: JavaScript (javascript)

The control toolbar is here:

Player controls: components/video/ControlsBar.tsx

This completes the core loop that makes the player feel dynamic and intentional:

Watch → Interrupt → Show Ad → Resume → Next Video.

A single video with an ad break is a start, but a real platform needs a queue. The player needs a simple way to “change the channel” when a video ends or when the viewer picks a new one.

The playlist logic sits beside the player state so the core video logic stays focused on one thing, playing the current frame.

Full file: components/video/VideoPlaylist.tsx

The playlist stays small and predictable. It’s just an array of objects with three fields: id, title, and a Cloudinary URL. The player never looks at the full list during playback; it only needs the currentIndex.

type PlaylistItem = {
  id: string;
  title: string;
  src: string;
};

To keep track of where the viewer is:

const [currentIndex, setCurrentIndex] = useState(0);
Code language: JavaScript (javascript)

Changing currentIndex updates currentMainSrc, and the <video> element loads the proper source automatically.

This part matters the most. Switching videos isn’t enough.

You’ll have to clear any leftover ad state so the next video behaves like a fresh start.

When the user clicks a playlist item, you’ll reset the internal ad markers:

function handleSelectPlaylistIndex(i) {
  setCurrentIndex(i);
  setHasPlayedAd(false);
  setIsPlayingAd(false);
  setResumeTime(0);
}
Code language: JavaScript (javascript)

This prevents the new video from skipping its ad break or jumping to an old timestamp.

The player now treats the selection as brand new content.

A playlist should feel relaxing. When a video ends, the player checks if there’s another item lined up. If yes, it moves forward. If not, it stays at the last item.

setCurrentIndex((prev) => {
  const next = prev + 1;
  return next < contentPlaylist.length ? next : prev;
});
Code language: JavaScript (javascript)

This simple step creates a smooth flow:

  1. Video ends.
  2. Ad state resets.
  3. Next video loads.
  4. A new ad break waits at its timestamp.

The playlist UI is intentionally simple. It shows a list, highlights the active item, and reports back which index was clicked.

It doesn’t manage logic or touch ad state. Rather, it stays “dumb”, which keeps your system predictable.

<li key={item.id}>
  <button onClick={() => onSelect(index)}>
    {item.title}
    {isActive && <span>Now Playing</span>}
  </button>
</li>

Code language: HTML, XML (xml)

Since both a user click and auto-advance use the same callback, they both trigger the same reset flow. Every transition behaves consistently.

You can review the full playlist component in: components/video/VideoPlaylist.tsx

A player shifts roles as people use it. One second it shows the main story. In the next, it becomes an ad frame. Sometimes, it becomes a testing tool.

To keep things clear, each part of the UI has a single job. Each component reacts to the state the player controls.

Full file: components/video/AdPlayer.tsx

The <VideoSurface> component only cares about two things.

It needs the video ref and a simple flag that tells it whether the screen is in ad mode or main mode. This lets it update the badge, borders, or any other visual hint without touching playback logic.

<VideoSurface videoRef={videoRef} isPlayingAd={isPlayingAd} />
Code language: HTML, XML (xml)

Full component: components/video/VideoSurface.tsx

The Control Bar listens to the viewer (play, skip, interact). It doesn’t run the logic itself. It forwards intent to the parent player.

<ControlsBar
  onPlay={handlePlay}
  onSkipAd={handleSkipAd}
  isPlayingAd={isPlayingAd}
/>
Code language: HTML, XML (xml)

The loop stays simple.

You click, the parent updates state, the UI responds.

Full component: components/video/ControlsBar.tsx

This panel is the testing ground. You’ll set the ad start time and change the ad URL.

Press Apply, and the player resets itself and begins again with fresh settings.

<AdConfigPanel
  adStartTime={adStartTime}
  onAdStartTimeChange={setAdStartTime}
  onApply={handleApplySettings}
/>
Code language: HTML, XML (xml)

It’s a quiet, focused tool that helps you shape and test the ad experience.

Full component: components/video/AdConfigPanel.tsx

The header sets the tone. If the viewer is watching the main content, it shows the title. If an ad is playing, it offers a link that opens the advertiser’s page in a new tab.

<AdPlayerHeader isPlayingAd={isPlayingAd} onVisitAd={handleOpenAd} />

Code language: HTML, XML (xml)

A small detail, but one that keeps the viewer informed.

Full component: components/video/AdPlayerHeader.tsx

At this point, every part of the system knows its job. The playlist holds the data, the UI shows the current state, and the controls capture what the viewer wants to do.

The AdPlayer component is the conductor. It keeps everything in sync. Instead of many wrappers, the main logic lives in one place.

Full file: components/video/AdPlayer.tsx

  1. A main video loads. The player reads the currentIndex from the playlist and feeds that Cloudinary URL to the video element. The surface displays it without delay.

  2. Playback starts. The player listens for timeupdate. Nothing special happens yet. The state is clean, the ad has not played, and the viewer settles in.

  3. The trigger hits. When the current time crosses the adStartTime, the player bookmarks the exact second (resumeTime), switches the isPlayingAd flag to true, and replaces the source. The frame never flickers.

  4. The ad runs to completion. Once the ad ends, the player rotates the ad queue and returns to the main video, placing the viewer exactly where they left off.

  5. The content continues. If the content reaches its end, the player automatically moves to the next playlist item. The ad state resets (hasPlayedAd = false) so the next video can fire its ad at the right moment.

  6. The cycle repeats. Across all main videos, the state machine behaves the same. You can jump between items in the playlist, and the player always resets the logic so each video gets its own clean slate.

You can now play a main video, insert an ad mid-stream, resume from the right second, and move through a playlist. All of it is driven by simple React state, environment variables, and Cloudinary URLs.

Here are some ideas on how to make your video player even more advanced:

  1. Add more types of ad breaks. Right now, the player supports a single mid-roll per video. You can extend the same pattern to handle:
  • Pre-roll ads that run before the main content.
  • Post-roll ads that play after the main video ends.
  • Multiple mid-roll breaks using an array of trigger times.

Each of these can reuse the same isPlayingAd, resumeTime, and hasPlayedAd pattern, just with more structured data.

  1. Make ad logic server-driven. At the moment, the player reads its ad config from .env. For a real product, you might move this to a backend or CMS.

Ideas:

  • Expose an API route that returns ad rules per video id.
  • Store ad schedules, links, and targeting in a database.
  • Use Next.js Server Components to fetch config and feed it into AdPlayer as props.

This keeps the frontend clean while giving you full control over campaigns.

  1. Use more Cloudinary Video features. Right now, the player uses Cloudinary as a reliable video host. You can also lean into its media features.

For example:

  • Generate different transformations per device or network quality.
  • Use Cloudinary’s video player or widget for analytics and advanced controls.
  • Tag assets to group by campaign, category, or region.

If you already organize your media library in folders like videos/main and videos/ads, you are one step away from building dynamic playlists from Cloudinary’s Admin API.

  1. Track and test performance. Because the AdPlayer has a clear state, it is easy to wire in analytics.

You can log events, such as when an ad starts and finishes, which ad ran before which content, when users skip, and more. Over time, they help you tune how long your ad runs and its placements.

  1. Turn the pattern into a reusable module. The current code lives inside this one project, but the structure is reusable.

You can:

  • Extract AdPlayer and related components into a separate internal package.
  • Wrap it in a simple <AdPlaylistPlayer /> component that accepts props like items, ads, and defaultAdStartTime.
  • Drop it into other Next.js projects that also use Cloudinary.

That way, “ad-supported playlist” becomes a building block in your stack, not a one-off experiment. Ready to get started? Sign up for a free Cloudinary account today.

Start Using Cloudinary

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

Sign Up for Free