Skip to content

RESOURCES / BLOG

Build a Smart Video Embed Service With Next.js, Hono, and Cloudinary

Why It Matters

This blog post:

  • Demonstrates how to combine Next.js, Hono, and Cloudinary for a fast, secure, and scalable video solution.
  • Generates live previews and embed codes (URL, iframe, JavaScript) with real-time customization.
  • Leverages Cloudinary’s transformations for fast-loading, adaptive video delivery.
  • Includes GitHub links and live demos to help you follow along and implement in your own projects.

GitHub Repository | Live Demo

Imagine giving your users or clients a polished dashboard where they can simply upload a video and instantly get a powerful, customizable player to embed on any website, no coding required. This is the promise of a “smart” video embed as a service. Instead of manually wrestling with video tags and complex player configurations, you provide a seamless, self-service experience.

This platform provides a drop-in <iframe> or <script> widget that renders Cloudinary’s high-performance, adaptive video player. It’s all powered by a modern, full-stack setup: a beautiful Next.js dashboard, a lightning-fast Hono edge API, and Cloudinary for all the heavy lifting of video storage, optimization, and delivery.

In this tutorial, you’ll build a complete video platform that:

  1. Provides a polished Next.js dashboard with Shadcn/UI for uploading and managing a video library.

  2. Uses a fast, server-side Hono API to securely fetch the list of video assets from your Cloudinary account.

  3. Features a customization page with a real-time <iframe> preview where users can adjust player dimensions and accent colors.

  4. Generates multiple, production-ready embed codes (URL, iFrame, and Javascript) for maximum flexibility.

Let’s dive in!

Before we write any application code, we need to lay the foundation. This involves scaffolding our Next.js + Hono project, installing our core dependencies, and configuring our Cloudinary account to allow for secure, client-side uploads.

We’ll start with the official Vercel template for a seamless Next.js and Hono integration.

npx  create-next-app@latest  my-video-service  --example  https://github.com/honojs/hono-nextjs-template

cd  my-video-service
Code language: CSS (css)

Next, install the Cloudinary SDK and the libraries for our UI: next-themes for dark mode and lucide-react for icons.

npm  install  cloudinary  next-themes  lucide-react

Now, let’s set up Shadcn/UI, which will provide our beautiful, accessible components.

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

Follow the command-line prompts, the default options are fine. After initialization, add the components we’ll use throughout the project:

npx  shadcn@latest  add  table  dropdown-menu  dialog  textarea  card  button  sheet  skeleton  tabs  label  input  separator
Code language: CSS (css)

For our app to allow users to upload videos directly from their browser, we need an Unsigned Upload Preset. This is a special rule in Cloudinary that defines how to handle incoming files without requiring a secure signature from our server for every single upload.

  1. Log in to your Cloudinary account.

  2. Navigate to Settings > Upload tab.

  3. Scroll down to Upload presets and click Add upload preset.

  4. Change the Signing Mode from Signed to Unsigned. This is the most important step.

  5. Give it a memorable name (e.g., smart-video-uploads).

  6. Click Save and copy the preset’s name.

Create a file named .env.local in the root of your project and add your Cloudinary credentials.

# Get these from your Cloudinary Dashboard homepage

NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME="your_cloud_name"
CLOUDINARY_API_KEY="your_api_key"
CLOUDINARY_API_SECRET="your_api_secret"

# The name of the unsigned upload preset you just created
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET="smart-video-uploads"
Code language: PHP (php)

Important: Never commit your .env.local file to version control, as it contains your secret keys.

To display a list of existing videos, our dashboard needs to securely ask Cloudinary for the data. We can’t do this from the user’s browser, as it would expose our secret API key. Instead, we’ll create a server-side API endpoint using Hono to act as a secure proxy.

First, we need a helper file that configures the Cloudinary Node.js SDK using our private API_KEY and API_SECRET. This keeps our configuration in one place and separates it from our application logic.

Create a new file at lib/cloudinary/server.ts:

// lib/cloudinary/server.ts

import { v2 as cloudinary } from "cloudinary";

// Configure the SDK with your server-side credentials
cloudinary.config({
  cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
});

export { cloudinary };
Code language: JavaScript (javascript)

This configured cloudinary instance has the power to use the Admin API for tasks like searching, deleting, or tagging assets.

Now, let’s edit our Hono API file at app/api/[[...route]]/route.ts to create the GET /videos route. This endpoint uses our secure client to perform a search and return the results as JSON.

The core of this endpoint is the Cloudinary Search API call:

// app/api/[[...route]]/route.ts (snippet)

app.get("/videos", async (c) => {
  const results = await cloudinary.search
    .expression("resource_type:video AND folder=smart-video-uploads")
    .sort_by("created_at", "desc")
    .max_results(50)
    .execute();

  return c.json(results.resources);
});
Code language: JavaScript (javascript)

Let’s break down the Cloudinary Search part in more detail:

  • .expression('...'): This is the powerful filter for your search.

  • resource_type:video tells Cloudinary to only return video assets, ignoring images or other files.

  • folder=smart-video-uploads scopes the search to the specific folder our upload widget uses.

  • .sort_by('created_at', 'desc'): This organizes the results to show the most recently uploaded videos first, which is exactly what a user expects to see on a dashboard.

  • .max_results(50): A crucial performance optimization. It limits the response to a maximum of 50 videos, preventing the API from getting bogged down if you have thousands of assets.

  • .execute(): This final command runs the search query with all the specified parameters and returns the results.

Important Note: Do Not Use the Edge Runtime!

The official Cloudinary Node.js library uses native Node modules (like http) to function. Because of this, we must run this API route in the standard Node.js serverless environment, not Vercel’s Edge Runtime (which does not support these modules). Ensure your route file does not contain export const runtime = ‘edge’;.

This approach provides a fast, secure, and scalable way to fetch your video library.

See the full API route on GitHub: app/api/[[…route]]/route.ts

With our API ready to fetch videos, we now need a way to get them into Cloudinary in the first place. Instead of building a complex, multi-part form uploader from scratch, we’ll leverage the Cloudinary Upload Widget. This powerful, prebuilt component handles the entire upload UI, including drag-and-drop, progress bars, and multiple sources, and sends files directly to Cloudinary, which is faster and more scalable than routing them through our own server.

To keep our code clean and organized, we’ll wrap the widget’s logic in its own client component.

Create a new file at app/components/upload-button.tsx. Inside, we’ll write the logic to load the widget’s script and configure its behavior.

The core of this component is the function that creates and opens the widget:

// app/components/upload-button.tsx (snippet)

"use client";
import { useEffect } from 'react';

// ... component setup ...

const handleUpload = () => {
  const myWidget = window.cloudinary.createUploadWidget({
    cloudName: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
    uploadPreset: process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET,
    sources: ['local', 'url', 'google_drive', 'dropbox'],
    folder: 'smart-video-uploads',
    clientAllowedFormats: ['video'],
  }, (error, result) => {
    if (!error && result && result.event === "success") {
      onUploadSuccess(result.info);
    }
  });

  myWidget.open();
};

Code language: JavaScript (javascript)

Let’s break down the key parameters we’re passing to createUploadWidget:

  • cloudName. This is your specific Cloudinary account identifier, pulled from our environment variables.

  • uploadPreset. This is the most important part. It tells the widget to use the unsigned preset we created in Section 2, which grants it permission to upload files without a secure server-side signature.

  • sources. This array controls which upload options the user sees. We’re enabling local files, URLs, and popular cloud storage providers.

  • folder. We specify a folder (smart-video-uploads) to keep all our uploaded videos organized within the Cloudinary Media Library. This also makes them easy to find with our Hono API.

  • clientAllowedFormats A helpful validation rule that restricts uploads to only video file types.

  • The Callback Function. The final argument is a function that fires on different upload events. We specifically check for result.event === "success" and, when it occurs, we call onUploadSuccess(result.info). This passes the data for the newly uploaded video (like its public_id) back up to our main dashboard page so we can update the UI in real-time.

This component now provides a complete, production-ready upload experience that can be dropped anywhere in our application.

See the full component on GitHub: app/components/upload-button.tsx

With our API and upload component ready, it’s time to build the heart of our application: the main dashboard. To create a professional and scalable UI, we’ll move away from a simple grid of cards to a data-rich Table View using Shadcn’s components. This layout is much better for displaying metadata and providing quick actions for each video.

The first step on our page.tsx is to fetch the video library from the Hono API we just built. We use a standard React useEffect hook to call the /api/videos endpoint when the page loads. This hook also manages our loading and error states to ensure a smooth user experience.

// app/page.tsx (snippet)

"use client";
import { useState, useEffect } from 'react';

// ...

export default function DashboardPage() {
  const [videos, setVideos] = useState<CloudinaryResource[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    async function fetchVideos() {
      setIsLoading(true);
      try {
        const res = await fetch('/api/videos');
        if (!res.ok) throw new Error(`API call failed: ${res.status}`);
        const data = await res.json();
        setVideos(Array.isArray(data) ? data : []);
      } catch (error) {
        // Handle errors gracefully
      } finally {
        setIsLoading(false);
      }
    }
    fetchVideos();
  }, []);

  // ... rest of the component
}
Code language: JavaScript (javascript)

Inside our renderVideoContent function, we map over the videos state to create a <TableRow> for each asset. The most interesting part here is how we generate the video thumbnails on the fly.


// app/page.tsx (snippet)

// ... inside the videos.map function ...
<TableRow key={video.asset_id}>
  <TableCell className="hidden sm:table-cell">
    <img
      alt="Video thumbnail"
      className="aspect-video rounded-md object-cover"
      src={`https://res.cloudinary.com/.../video/upload/w_100,h_64,c_fill,q_auto,f_auto/${video.public_id}.jpg`}
    />
  </TableCell>
  <TableCell className="font-medium">
    {video.original_filename}
  </TableCell>
  {/* ... other cells ... */}
</TableRow>

Code language: HTML, XML (xml)

Let’s break down the Cloudinary URL transformation used for the src attribute. This is where Cloudinary’s power shines:

  • .../video/upload/...: This tells Cloudinary we are transforming a video asset.

  • w_100,h_64,c_fill. These are chained transformations. We’re requesting a width of 100px, a height of 64px, and telling Cloudinary to intelligently crop the video to fill those exact dimensions without distortion.

  • q_auto,f_auto. These are performance optimizations. q_auto tells Cloudinary to choose the optimal compression quality, while f_auto tells it to deliver the image in the most efficient format for the user’s browser (like WebP or AVIF).

  • /${video.public_id}.jpg. This is the magic trick. By appending .jpg to the video’s public ID, we are asking Cloudinary to extract the first frame of the video and deliver it as a static JPEG image.

This entire process happens in real-time on Cloudinary’s servers, giving us perfectly sized thumbnails without any extra work.

See the full dashboard component on GitHub.

While a quick embed code is useful, the real power of our service lies in customization. This is where we build the dedicated settings page, a dynamic route that allows users to fine-tune the player’s appearance and dimensions and see their changes reflected in real-time.

First, we create a dynamic route in Next.js at app/video/[publicId]/page.tsx. This structure tells Next.js that any URL matching the pattern /video/... should render this page, passing the part after /video/ as the publicId parameter. This publicId is crucial, as it tells us which video to load.

On this page, we’ll use React’s useState hooks to manage every customizable setting. This is the “brain” of our customization engine. Any change to these state variables will automatically trigger a re-render of the component and update our preview.

// app/video/[publicId]/page.tsx (snippet)

"use client";
import { useState, useEffect } from 'react';

export default function VideoSettingsPage({ params }: { params: { publicId: string } }) {
  const decodedPublicId = decodeURIComponent(params.publicId);

  // State for all our customizable settings
  const [accentColor, setAccentColor] = useState("#4f46e5");
  const [width, setWidth] = useState(640);
  const [height, setHeight] = useState(360);

  // ... rest of the component
}

Code language: JavaScript (javascript)

Each piece of state is then linked to a UI input. For example, the accentColor state is bound to an <Input type="color">. When the user picks a new color, the input’s onChange event fires, calling setAccentColor and updating the state with the new value.

// app/video/[publicId]/page.tsx (snippet)

<div className="grid gap-3">
  <Label htmlFor="color">Accent Color / Border</Label>
  <Input
    type="color"
    id="color"
    value={accentColor}
    onChange={(e) => setAccentColor(e.target.value)}
    className="w-16 h-10 p-1"
  />
</div>
Code language: HTML, XML (xml)

The same principle applies to the width and height inputs, providing an interactive and responsive user experience. The page is designed with a spacious two-column layout: settings are on the left, and a large, sticky preview area stays visible on the right as you scroll, giving immediate visual feedback.

Explore the complete settings page component on GitHub.

The core of this project is not writing a custom video player. Instead, we construct parameters for Cloudinary’s native player. This is faster, more reliable, and unlocks advanced features automatically.

All logic lives inside a useEffect hook on the video settings page. When the user changes options like accentColor, width, or height, the hook regenerates three things:

  1. A base embed URL.

  2. An iFrame code snippet.

  3. A JavaScript player snippet.

useEffect(() => {
  const cloudName = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME;

  const url = new URL('https://player.cloudinary.com/embed/');
  url.searchParams.set('cloud_name', cloudName || '');
  url.searchParams.set('public_id', decodedPublicId);
  url.searchParams.set('player[colors][accent]', accentColor);

  setEmbedUrl(url.toString());

Code language: JavaScript (javascript)
  • Start by reading your Cloudinary cloud name.

  • Build the base embed URL.

  • Add account name, video ID, and accent color.

setIframeCode(
  `<iframe
      src="${url.toString()}"
      width="${width}"
      height="${height}"
      allow="autoplay; fullscreen; encrypted-media"
      frameborder="0">
    </iframe>`
);
Code language: HTML, XML (xml)
  • Wrap the embed URL in an iFrame.

  • Sets width, height, and permissions.

  • Copy-paste ready for any HTML page.

  setJsCode(
    `const player = cloudinary.player('player', {
      cloudName: '${cloudName}',
      publicId: '${decodedPublicId}',
      colors: { accent: '${accentColor}' },
      width: ${width},
      height: ${height}
    });`
  );
}, [decodedPublicId, accentColor, width, height]);

Code language: JavaScript (javascript)
  • Builds a snippet for Cloudinary’s JS Player API.

  • Updates when video ID, color, or size changes.

  • Lets developers embed the player programmatically.

The system revolves around the embed URL:

  • https://player.cloudinary.com/embed/. Base endpoint.

  • ?cloud_name=.... Which Cloudinary account to use.

  • &public_id=.... Which video asset to load.

  • &player[colors][accent]=#4f46e5. Customize UI styling.

With this foundation, generating the iFrame and JavaScript snippets becomes straightforward. The URL is the single source of truth.

View the full code on GitHub.

A settings page is only useful if the user can see the immediate impact of their changes. A simulated preview can often be misleading, so our application provides a 100% accurate, real-time preview by rendering the actual embeddable player inside an <iframe>. What you see is exactly what you get.

The magic lies in a simple but powerful React pattern. The <iframe> on our settings page doesn’t have a static source; its src attribute is directly bound to the embedUrl state variable we generated in the previous step.

// app/video/[publicId]/page.tsx (snippet)

<div
  style={{ aspectRatio: `${width} / ${height}` }}
  className="rounded-lg bg-slate-900 overflow-hidden"
>
  <iframe
    key={embedUrl}
    src={embedUrl}
    className="w-full h-full"
    frameBorder="0"
    allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
    allowFullScreen
  ></iframe>
</div>;
Code language: HTML, XML (xml)
  1. Dynamic source. When a user changes the accent color, the useEffect hook from the previous step runs, creating a new embedUrl with the updated color parameter (e.g., ...&player[colors][accent]=#ff0000).

  2. The key prop is crucial. We’ve set key={embedUrl} on the <iframe>. In React, when a component’s key changes, React unmounts the old component and mounts a completely new one. This forces the browser to discard the old <iframe> and load a fresh one with the new src, guaranteeing that the player re-initializes with the latest settings.

  3. Granting permissions. The allow="autoplay; ..." attribute is a critical security feature. It’s our way of telling the browser, “I trust the content at this source, and I give it permission to autoplay (muted) inside this frame.” This is what prevents the preview from showing a black screen or getting blocked by browser security policies.

This technique ensures that our live preview is not a guess, it’s a perfect, real-world representation of the final embedded player.

Check out the full implementation on GitHub.

By combining Next.js frontend, a fast Hono API, and Cloudinary’s robust media infrastructure, you’ve created a professional tool for smart video embedding.

This architecture isn’t just a demo; it’s a solid foundation for a real-world SaaS product. You now have a powerful system that you can extend and build upon.

Here are some ideas for taking this project to the next level:

  1. Expand player customizations. The Cloudinary Player supports dozens of options. You could easily add more controls to your settings page for features like autoplay, loop, showLogo, or even adding your own watermark.
  1. Integrate AI features. Make the player even “smarter” by leveraging Cloudinary’s AI add-ons. You could add a button to your settings page that triggers an API call to automatically generate video captions using the Google AI Video Transcription add-on.
  1. Add user accounts and a database. To make this a true multi-tenant service, integrate an authentication provider (like NextAuth.js or Clerk) and a database (like Vercel Postgres or Supabase) to save user-specific settings and video metadata.

Start Using Cloudinary

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

Sign Up for Free