Skip to content

RESOURCES / BLOG

How to A/B Test Video Thumbnails Programmatically With Cloudinary, PostHog, and Next.js

An overlooked factor that can determine whether or not your video gets watched is… the thumbnail. It’s the digital handshake, movie poster, and book cover all in one. A compelling thumbnail can make a video go viral, but a poor one can get glossed over. But what style is more engaging? The one with the bright text overlay, or the simple, clean frame?

Guessing is a losing game, and manual testing is slow and inefficient. You’re leaving engagement and views on the table.

In this guide, you’ll build a powerful, automated A/B testing pipeline using Next.js, Cloudinary for dynamic media generation, and PostHog for robust analytics. You’ll learn how to programmatically serve different thumbnail variants to users, track every interaction, and build a custom dashboard to confidently declare a winning thumbnail that can boost click-through rate (CTR).

Before we start building, make sure you have:

  • Node.js v18+ to build the thumbnail component, create secure API routes that talk to PostHog, and render everything efficiently.
  • A free Cloudinary account to manage our video and generate A/B test thumbnail variants on the fly.
  • A free PostHog account for event tracking and analytics, e.g., impressions, clicks, and plays. You’ll query this data with its HogQL API to see which variant performs better.
  • Basic knowledge of Next.js, including the App Router.

With these tools in place, we’re ready to build a system that connects them into a fully functional A/B test.

Cloudinary Video Analytics tracks powerful metrics (e.g., views, engagement, playback quality) after you hit the play button. These insights are invaluable for understanding video delivery, performance, and audience retention. However, because our A/B test is focused on what happens before the click, we’ll use PostHog to track the specific user journey leading up to the play event:

  • Capture custom events like thumbnail_impression and thumbnail_clicked.
  • Measure the click-through rate (CTR) between our A and B variants.
  • Use HogQL (PostHog’s SQL-style query tool) to build a custom analytics dashboard.
  • Create funnels to visualize how many impressions convert into clicks, and then into plays.

In this project, Cloudinary and PostHog work together as a perfect team. Cloudinary handles the video delivery and dynamic thumbnail generation, while PostHog tracks the user behavior that tells us why they chose to play the video in the first place.

First, let’s lay the groundwork for our application. You’ll start with a new Next.js project and use shadcn/ui to build a clean interface quickly. You’ll also bring in libraries like Tailwind CSS, Lucide Icons, and Cloudinary’s SDKs as seen in the codebase.

Open your terminal and run this command to create the app with TypeScript and Tailwind CSS:

npx create-next-app@latest cloudinary-ab-test
Code language: CSS (css)

Accept the defaults when prompted. This gives you the standard project structure with everything you need to start coding.

Now, let’s set up shadcn/ui, a lightweight component system built on top of Tailwind. It helps you avoid the bulk of traditional UI libraries while keeping full design control.

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

Accept the default options. You can add UI components like Card, Dialog, Table, Alert, and Badge whenever you need them using this command:

npx shadcn@latest add card dialog table alert badge
Code language: CSS (css)

These are the same components used in the project files such as app/page.tsx, app/analytics/page.tsx, and the custom ABTestThumbnail component.

You’ll also need a few more packages used throughout the project:

npm install posthog-js @cloudinary/react @cloudinary/url-gen
Code language: CSS (css)

These include:

  • posthog-js for event tracking.
  • @cloudinary/react and @cloudinary/url-gen for video and thumbnail generation.
  • lucide-react for icons in the UI (like the play button and chart icons).

This step connects your app to Cloudinary and PostHog.

Create a new file named .env.local in the root of your project and add the following keys:

# .env.local

# Cloudinary (Find these in your Cloudinary dashboard)
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=YOUR_CLOUD_NAME

# PostHog (Public key for sending client events)
NEXT_PUBLIC_POSTHOG_KEY=YOUR_PUBLIC_CLIENT_KEY
NEXT_PUBLIC_POSTHOG_HOST=https://us.posthog.com # Or your regional host

# PostHog (Secret key for server analytics queries)
POSTHOG_API_KEY=YOUR_PERSONAL_API_KEY

This setup keeps your API keys private and prepares your app for backend analytics.

With the environment and base app ready, we can now move on to generating dynamic thumbnail variants with Cloudinary.

Now that your project is set up, let’s build the heart of the A/B test, dynamic video thumbnails using Cloudinary.

Cloudinary lets you transform media on the fly using URL-based transformations. This means you can generate multiple thumbnail styles from the same video without uploading extra files.

Create a helper file to manage your Cloudinary setup:

// lib/cloudinary.ts
import { Cloudinary } from "@cloudinary/url-gen";

export const cld = new Cloudinary({
  cloud: {
    cloudName: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
  },
});
Code language: JavaScript (javascript)

This gives you a reusable instance of Cloudinary’s client that can create image and video transformations anywhere in your app.

In the component ABTestThumbnail.tsx, two different thumbnails are generated from the same video source:

  • Variant A: A clean, unedited thumbnail.
  • Variant B: A version with a “New Episode!” overlay and dark gradient.

Here’s a simplified version of how that’s built:

const thumbnailA = cld
  .video(videoPublicId)
  .resize(fill().width(1280).height(720))
  .roundCorners(byRadius(15))
  .addTransformation("so_2,f_jpg,q_auto");

const thumbnailB = cld
  .video(videoPublicId)
  .resize(fill().width(1280).height(720))
  .roundCorners(byRadius(15))
  .addTransformation(
    "l_text:Arial_60_bold:New%20Episode!,co_rgb:FFFFFF,b_rgb:00000090/fl_layer_apply,g_south_east,x_20,y_20/so_8,f_jpg,q_auto"
  );
Code language: JavaScript (javascript)

Each variant uses Cloudinary’s URL transformation API to apply effects, cropping, rounding, overlays, and quality adjustments.

The app randomly assigns users to one of the variants using a simple randomizer and logs an impression event to PostHog:

const assigned = Math.random() < 0.5 ? "A" : "B";
posthog.capture("thumbnail_impression", { video_id, variant: assigned });
Code language: JavaScript (javascript)

When the user clicks the thumbnail, a second event is logged: thumbnail_clicked.

This lightweight setup allows Cloudinary to handle visuals while PostHog captures real engagement data.

You can view the full component code here: ABTestThumbnail Component

Now that your thumbnails are ready, let’s connect PostHog to track how users interact with them. This allows you to measure which variant attracts more clicks and views.

You’ll use a provider file to load PostHog on the client side. This keeps analytics lightweight and avoids server-side errors.

// app/providers.tsx
'use client';
import posthog from 'posthog-js';
import { PostHogProvider } from 'posthog-js/react';

if (typeof window !== 'undefined') {
  posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
    api_host: '/ingest',
    ui_host: process.env.NEXT_PUBLIC_POSTHOG_UI_HOST,
    person_profiles: 'always',
  });
}

export function CSPostHogProvider({ children }: { children: React.ReactNode }) {
  return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
}

Code language: JavaScript (javascript)

This file ensures PostHog only runs in the browser. The api_host points to a custom proxy route that you’ll create next.

Full file: app/providers.tsx on GitHub

Instead of sending data directly to PostHog’s public API, you’ll use your own Next.js route as a secure proxy.

// app/ingest/[[...path]]/route.ts
const POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST;

export async function POST(request: Request) {
  const payload = await request.text();
  const response = await fetch(POSTHOG_HOST!, {
    method: 'POST',
    headers: request.headers,
    body: payload,
  });
  return new Response(await response.text(), { status: response.status });
}

Code language: JavaScript (javascript)

This keeps your host URL private and helps avoid browser CORS issues.

Full file: app/ingest/[[…path]]/route.ts on GitHub

Once PostHog is set up, start tracking the main actions:

  • thumbnail_impression when a thumbnail appears.
  • thumbnail_clicked when a user selects it.
  • video_played when the video starts.

Example snippet:

posthog.capture("thumbnail_clicked", {
  video_id: videoPublicId,
  variant,
});
Code language: CSS (css)

These events will appear in your PostHog dashboard immediately. You can then analyze click-through rates, funnels, and trends for each thumbnail variant.

Now that PostHog is active, it’s time to wire up the logic that powers the actual A/B test. This is where your app randomly assigns a user to Variant A or Variant B and tracks their interactions.

The A/B logic lives inside the ABTestThumbnail component.

It will randomly assign users a thumbnail variant, display the correct image using Cloudinary transformations, and then capture impressions and click events with PostHog.

Here’s the key part of the logic:

const [variant, setVariant] = (useState < string) | (null > null);

useEffect(() => {
  const assigned = Math.random() < 0.5 ? "A" : "B";
  setVariant(assigned);
  posthog.capture("thumbnail_impression", {
    video_id: videoPublicId,
    variant: assigned,
  });
}, []);

const handleClick = () => {
  if (!variant) return;
  posthog.capture("thumbnail_clicked", {
    video_id: videoPublicId,
    variant,
  });
  const thumbnailUrl = variants[variant].toURL();
  onThumbnailClick(thumbnailUrl);
};
Code language: JavaScript (javascript)

This code randomly assigns the user to a variant, logs the first view, and sends a click event when they engage.

The component uses Cloudinary’s transformation API to create two thumbnail styles dynamically:

  • Variant A: Plain version.
  • Variant B: Version with a text overlay (“New Episode!”).

Example:

const thumbnailB = cld
  .video(videoPublicId)
  .resize(fill().width(1280).height(720))
  .roundCorners(byRadius(15))
  .addTransformation(
    "l_text:Arial_60_bold:New%20Episode!,co_rgb:FFFFFF,b_rgb:00000090/fl_layer_apply,g_south_east,x_20,y_20/so_8,f_jpg,q_auto"
  );
Code language: JavaScript (javascript)

Each user sees only one variant per session, giving you clean, unbiased data.

The thumbnail is displayed inside a styled card built with shadcn/ui components. It uses the AdvancedImage component from @cloudinary/react for optimized image rendering and lazy loading.

You can explore the complete code here: ABTestThumbnail Component on GitHub

With A/B assignment and tracking complete, we can now measure how each variant performs using analytics.

Once your app collects events from PostHog, you need a way to view results. The analytics dashboard does exactly that. It fetches event data from PostHog’s API and shows how each thumbnail variant performs.

The logic for fetching A/B test data lives inside app/api/analytics/route.ts.

It uses PostHog’s HogQL API to query recent events:

const hogqlQuery = `
  SELECT
    event,
    properties.variant as variant,
    count() as total
  FROM events
  WHERE
    event IN ('thumbnail_impression', 'thumbnail_clicked') AND
    timestamp >= now() - INTERVAL 7 DAY
  GROUP BY event, variant
`;

Code language: PHP (php)

This query counts impressions and clicks for both variants over the past seven days.

The API route then structures the data for the frontend:

const results = {
  A: { impressions: 0, clicks: 0 },
  B: { impressions: 0, clicks: 0 },
};
Code language: JavaScript (javascript)

The endpoint returns this JSON format to the analytics page.

The results are displayed on app/analytics/page.tsx.

It fetches data from the API and renders a clean results table using shadcn/ui components like Card, Table, and Alert.

Here’s the key rendering logic:

<TableBody>
  <TableRow>
    <TableCell>Variant A</TableCell>
    <TableCell>{results?.A.impressions ?? 0}</TableCell>
    <TableCell>{results?.A.clicks ?? 0}</TableCell>
    <TableCell>
      {calculateCTR(results?.A.clicks ?? 0, results?.A.impressions ?? 0)}
    </TableCell>
  </TableRow>
  <TableRow>
    <TableCell>Variant B</TableCell>
    <TableCell>{results?.B.impressions ?? 0}</TableCell>
    <TableCell>{results?.B.clicks ?? 0}</TableCell>
    <TableCell>
      {calculateCTR(results?.B.clicks ?? 0, results?.B.impressions ?? 0)}
    </TableCell>
  </TableRow>
</TableBody>;
Code language: HTML, XML (xml)

Each variant’s CTR is calculated and displayed clearly.

When you open /analytics, the dashboard loads data automatically. You’ll see impressions and clicks for each variant as well as the calculated CTR to show which one performs better.

This gives you real, measurable results to guide your creative decisions.

You can view the full files here:

Your app is ready. The thumbnails load, events track, and analytics display in real time. Now it’s time to run your experiment and interpret the data.

Deploy to Vercel for the best performance and easy environment management.

Add your environment variables in the Vercel dashboard under Settings → Environment Variables.

Use the same keys from your .env.local file.

Once deployed, visit your site and confirm that the thumbnails load correctly, PostHog events appear in your PostHog dashboard under Events, and the /analytics page fetches and displays data.

Live demo: cloudinary-video-thumbnails-ab-test.vercel.app

Open your site in a few browsers or incognito windows to simulate different users.

Each session randomly receives Variant A or Variant B.

Try the following actions:

  • Refresh the page to generate new thumbnail impressions.
  • Click each thumbnail a few times.
  • Watch the video briefly.

Then, check your PostHog dashboard. You should see events like thumbnail_impression, thumbnail_clicked, and video_played.

Visit your /analytics page to see how both variants perform.

Example result:

Variant Impressions Clicks CTR
A 42 10 23.8%
B 36 15 41.6%

In this example, Variant B performs better, so you’d keep that design for future videos.

Once your pipeline works, you can add more variants using additional Cloudinary transformations. Be sure to extend event tracking to include engagement depth (watch time) and connect results to your internal dashboards using PostHog’s API.

This workflow can scale easily across hundreds of thumbnails and campaigns.

You’ve built a full A/B testing system that helps you make informed creative decisions. With Cloudinary and PostHog, you can now see both sides of viewer behavior, i.e., how your digital media looks and how it’s received.

Test, measure, and improve your video thumbnails at scale. Sign up for a free Cloudinary account today to get started.

Start Using Cloudinary

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

Sign Up for Free