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:
-
Provides a polished Next.js dashboard with Shadcn/UI for uploading and managing a video library.
-
Uses a fast, server-side Hono API to securely fetch the list of video assets from your Cloudinary account.
-
Features a customization page with a real-time
<iframe>
preview where users can adjust player dimensions and accent colors. -
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.
-
Log in to your Cloudinary account.
-
Navigate to Settings > Upload tab.
-
Scroll down to Upload presets and click Add upload preset.
-
Change the Signing Mode from
Signed
toUnsigned
. This is the most important step. -
Give it a memorable name (e.g.,
smart-video-uploads
). -
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 callonUploadSuccess(result.info)
. This passes the data for the newly uploaded video (like itspublic_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, whilef_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:
-
A base embed URL.
-
An iFrame code snippet.
-
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)
-
Dynamic source. When a user changes the accent color, the
useEffect
hook from the previous step runs, creating a newembedUrl
with the updated color parameter (e.g.,...&player[colors][accent]=#ff0000
). -
The
key
prop is crucial. We’ve setkey={embedUrl}
on the<iframe>
. In React, when a component’skey
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 newsrc
, guaranteeing that the player re-initializes with the latest settings. -
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:
-
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.
- Resource: Cloudinary Video Player API Reference
- 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.
- Resource: Google AI Video Transcription Add-on
- 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.
-
View the final code on GitHub.
-
See the live application.