Webflow Cloud and Cloudinary pair up cleanly for adaptive video once you route uploads through REST instead of the Node SDK.
How to configure Cloudinary video transcoding on Webflow Cloud
Wiring Cloudinary into a Webflow Cloud app for video delivery surfaces two failures that local development never triggers. The Node.js SDK imports modules that crash on the Cloudflare Workers runtime, and requesting an HLS URL before Cloudinary finishes eager transcoding returns a 423 to the first viewer.
Both problems have clean solutions. Cloudinary's entire upload and delivery pipeline is accessible via its REST API and CDN, with no SDK required. Eager transformations solve the 423 problem by pre-generating the transcoded versions at upload time, so the first real viewer always receives a CDN-cached result rather than triggering on-demand processing.
The combination of fetch-based uploads, upload preset configuration, and transformation URLs is what makes this work correctly on Webflow Cloud's Workers runtime.
In this guide, we explore how to configure a Cloudinary upload preset with eager transcoding, add a video upload Route Handler using plain fetch, build a URL utility to generate optimized delivery URLs, and wire up HLS adaptive bitrate streaming with Cloudinary's sp_auto streaming profile.
What do you need to set up Cloudinary video transcoding on Webflow Cloud?
You need a Webflow Cloud Next.js app, a Cloudinary account with a video upload preset configured, and three environment variables. No additional npm packages are required beyond your existing Next.js project.
Cloudinary video transcoding runs entirely through their REST API and CDN, which means the full pipeline runs cleanly on Webflow Cloud's edge runtime without compatibility workarounds.
Confirm you have Node.js 22.13.0 or higher locally with node --version before starting. If you are adding video to a larger app alongside authentication, a database, and payments, our guide on building a full-stack app on Webflow Cloud with Supabase, Auth0, and Stripe shows how to layer multiple services in the same project.
6 steps to set up Cloudinary video transcoding on Webflow Cloud
The setup is divided into three pieces:
- A URL utility that constructs optimized Cloudinary delivery URLs
- A Route Handler that uploads videos to Cloudinary using plain
fetch - The HLS player configuration that handles adaptive streaming across all major browsers. The upload preset's eager transforms are what make the Route Handler simple. Instead of generating and passing transcoding parameters on every request, the preset applies them automatically, and Cloudinary handles the rest asynchronously.
Here is the full setup from environment variables to the first HLS delivery.
1. Create a Cloudinary account and configure a video upload preset
Log in to Cloudinary and navigate to Settings, then Upload, then Upload presets. Click Add upload preset. Set the preset name to webflow_video and the Signing mode to Unsigned. In the preset's transformation settings, add the following eager transformation string:
sp_auto,f_m3u8|vc_auto,f_mp4
The pipe character separates two independent eager outputs:
(1) The first, sp_auto,f_m3u8, tells Cloudinary to generate an HLS master playlist and all required segment files using the automatic streaming profile. Note that automatic profile selection resolves when a representation is first requested, so if you need every representation pre-generated at upload time, name a profile such as sp_full_hd instead.
(2) The second, vc_auto,f_mp4, generates a web-normalized MP4 as a fallback format. Enable asynchronous processing for eager transforms so the upload response returns immediately.
If you also set a default folder in the preset, such as webflow-cloud-videos, uploads using this preset will be organized into that subfolder in the Media Library. This matters for delivery URLs: your public IDs will include the folder path, for example, webflow-cloud-videos/my-video, when you construct URLs in the steps below.
Choose a consistent folder naming pattern and keep it in mind throughout the guide.
Before saving the preset, confirm two things:
(1) The Signing mode must be set to Unsigned. A Signed preset requires a timestamp and HMAC signature in every upload request, which the Route Handler in Step 4 does not generate and should not generate for this use case.
(2) Confirm unsigned uploading is enabled for your account, under Settings then Upload. If it is off, the request fails with a 400 that looks identical to a missing preset, which is an easy hour to lose on a first deployment.
2. Add Cloudinary credentials to Webflow Cloud environment variables
With the upload preset created, add the three variables to .env.local at the root of your Next.js project:
# .env.local
CLOUDINARY_CLOUD_NAME=your-cloud-name
CLOUDINARY_VIDEO_UPLOAD_PRESET=webflow_video
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
Confirm .env.local is listed in .gitignore before continuing. Then, for production, open the Webflow Cloud dashboard, navigate to your project's Settings, select Environment Variables, and add each value. The cloud name and preset name do not need to be marked as secrets.
3. Create a Cloudinary video URL utility
Cloudinary applies transformations at the URL level. You construct a URL with the transformation parameters embedded in the path, Cloudinary generates the transformed video on the first request, and every subsequent request for that same URL is served directly from CDN cache.
Your Webflow Cloud app never proxies video bytes. It generates URLs, and Cloudinary handles delivery and caching entirely.
Create lib/cloudinary-video.ts in your project:
// lib/cloudinary-video.ts
type VideoTransformOptions = {
codec?: 'auto' | 'h264' | 'h265' | 'vp9' | 'av1'
quality?: 'auto' | number
width?: number
height?: number
streamingProfile?: 'auto' | 'hd' | 'full_hd' | 'sd'
format?: 'mp4' | 'webm' | 'm3u8' | 'mpd'
}
export function cloudinaryVideoUrl(
publicId: string,
options: VideoTransformOptions = {}
): string {
const cloudName = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME!
const transforms: string[] = []
if (options.streamingProfile) {
// sp_auto selects representations automatically up to 1080p by default.
// Named profiles like 'hd' or 'full_hd' use a predefined representation set.
transforms.push(`sp_${options.streamingProfile}`)
} else {
if (options.codec) transforms.push(`vc_${options.codec}`)
if (options.quality) transforms.push(`q_${options.quality}`)
}
if (options.width) transforms.push(`w_${options.width}`)
if (options.height) transforms.push(`h_${options.height}`)
const transformStr = transforms.length > 0 ? transforms.join(',') + '/' : ''
// HLS uses .m3u8; MPEG-DASH uses .mpd; standard delivery defaults to .mp4
const ext = options.format ?? (options.streamingProfile ? 'm3u8' : 'mp4')
return `https://res.cloudinary.com/${cloudName}/video/upload/${transformStr}${publicId}.${ext}`
}
The streamingProfile option handles the two most important delivery patterns. Passing streamingProfile: 'auto' generates an HLS URL using sp_auto, which creates a ladder of representations up to 1080p by default (the exact count depends on the source video's resolution) and switches between them in real time as the viewer's bandwidth changes.
Named profiles like 'hd' or 'full_hd' use a predefined set of representations tuned to those quality tiers, with H.264, H.265, VP9, and AV1 codec variants available for each.
Delivering an optimized MP4 fallback
For direct MP4 delivery without adaptive streaming, passing codec: 'auto' applies the vc_auto transformation. This normalizes the video for web delivery by selecting the codec that matches the output format, H.264 for MP4, VP9 for WebM, and Theora for OGV, along with matching audio and quality settings, without requiring you to specify codec profiles or levels manually.
Adding quality: 'auto' lets Cloudinary pick the compression level per video based on its content, trading file size against visual quality automatically.
Here are both patterns in use:
// HLS adaptive streaming: up to 1080p by default, bandwidth-adaptive
const hlsUrl = cloudinaryVideoUrl('webflow-cloud-videos/product-demo', {
streamingProfile: 'auto',
})
// → https://res.cloudinary.com/your-cloud/video/upload/sp_auto/webflow-cloud-videos/product-demo.m3u8
// Optimized MP4 for direct playback or browsers without native HLS support
const mp4Url = cloudinaryVideoUrl('webflow-cloud-videos/product-demo', {
codec: 'auto',
quality: 'auto',
})
// → https://res.cloudinary.com/your-cloud/video/upload/vc_auto,q_auto/webflow-cloud-videos/product-demo.mp4
These two URLs are what you pass to your video player. The HLS URL delivers adaptive streaming. The MP4 URL serves as a fallback for browsers without native HLS support.
4. Build a video upload Route Handler
The upload Route Handler accepts a video file from a client form, passes it to Cloudinary's REST API via fetch, and returns the public ID and delivery URLs.
Because eager transformations are configured in the upload preset rather than generated dynamically in the request, the Route Handler remains straightforward: upload the file, capture the public ID and return the delivery URLs.
Create app/api/video-upload/route.ts:
// app/api/video-upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
// Videos larger than 100 MB exceed the Workers runtime request body limit.
// For large files, upload directly from the browser to Cloudinary's upload API
// using NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME and the same unsigned upload preset.
// Files over 100 MB must be sent in chunks, and Cloudinary's Free plan caps
// video files at 100 MB regardless.
const MAX_VIDEO_SIZE = 100 * 1024 * 1024 // 100 MB
type CloudinaryVideoUploadResult = {
public_id: string
secure_url: string
format: string
duration: number
width: number
height: number
eager?: Array<{ secure_url: string; transformation: string }>
error?: { message: string }
}
export async function POST(request: NextRequest) {
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
if (file.size > MAX_VIDEO_SIZE) {
return NextResponse.json(
{ error: 'File exceeds 100 MB. Upload large videos directly from the browser.' },
{ status: 413 }
)
}
const cloudName = process.env.CLOUDINARY_CLOUD_NAME!
const uploadPreset = process.env.CLOUDINARY_VIDEO_UPLOAD_PRESET!
const uploadData = new FormData()
uploadData.append('file', file)
uploadData.append('upload_preset', uploadPreset)
const response = await fetch(
`https://api.cloudinary.com/v1_1/${cloudName}/video/upload`,
{ method: 'POST', body: uploadData }
)
const result = (await response.json()) as CloudinaryVideoUploadResult
if (!response.ok) {
return NextResponse.json(
{ error: result.error?.message ?? 'Cloudinary upload failed' },
{ status: response.status }
)
}
return NextResponse.json({
publicId: result.public_id,
url: result.secure_url,
format: result.format,
duration: result.duration,
width: result.width,
height: result.height,
// Eager transforms run asynchronously. These URLs are valid immediately
// but return 423 until transcoding completes.
hlsUrl: `https://res.cloudinary.com/${cloudName}/video/upload/sp_auto/${result.public_id}.m3u8`,
mp4Url: `https://res.cloudinary.com/${cloudName}/video/upload/vc_auto,q_auto/${result.public_id}.mp4`,
})
}
The /video/upload segment in the Cloudinary API URL specifies the resource type as video, so you do not need to pass a separate resource_type field in the FormData body. The uploadPreset value links the request to the eager transformation configuration from Step 1.
When Cloudinary receives the upload, it applies the preset-defined eager transforms automatically and processes them asynchronously. The Webflow Cloud runtime enforces resource limits on request body size, which is why files above 100 MB return a 413 before the handler runs.
For production apps handling longer videos, the correct pattern is a direct upload from the browser. The client calls Cloudinary's upload API directly using NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME and the unsigned preset name.
The upload preset's eager transforms still apply, so the transcoding pipeline is unchanged.
From a Client Component, a direct upload to Cloudinary looks like this:
// In a Client Component — for large video files
async function uploadVideoDirectly(file: File) {
const cloudName = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME!
const preset = 'webflow_video'
const formData = new FormData()
formData.append('file', file)
formData.append('upload_preset', preset)
const res = await fetch(
`https://api.cloudinary.com/v1_1/${cloudName}/video/upload`,
{ method: 'POST', body: formData }
)
const { public_id, secure_url } = await res.json()
// Store public_id in Webflow CMS, a KV Store, or SQLite for later use.
return { publicId: public_id, url: secure_url }
}
The returned public_id is what you store in your data layer for later use. Regenerate delivery URLs from it at render time using the cloudinaryVideoUrl utility, rather than storing full URLs. This means you can change transformation parameters later without migrating stored data.
5. Deliver transcoded video from Cloudinary's CDN
With the upload handled and eager transcoding queued in the background, delivering the video requires only the correct Cloudinary URL. Your Webflow Cloud app constructs the URL, and the browser fetches the video directly from Cloudinary's CDN. No video bytes pass through your server.
A Server Component rendering a video page looks like this:
// app/videos/[slug]/page.tsx
import { cloudinaryVideoUrl } from '@/lib/cloudinary-video'
type Props = { params: Promise<{ slug: string }> }
export default async function VideoPage({ params }: Props) {
const { slug } = await params
const publicId = `webflow-cloud-videos/${slug}`
const hlsUrl = cloudinaryVideoUrl(publicId, { streamingProfile: 'auto' })
const mp4Url = cloudinaryVideoUrl(publicId, { codec: 'auto', quality: 'auto' })
return (
<video controls width={1280} height={720}>
{/* HLS: natively supported in Safari on macOS/iOS and in Chrome for Android. */}
<source src={hlsUrl} type="application/x-mpegURL" />
{/* MP4 fallback for Chrome desktop and Firefox, which require hls.js for HLS. */}
<source src={mp4Url} type="video/mp4" />
</video>
)
}
The application/x-mpegURL MIME type on the first source tells the browser this is an HLS manifest.
Safari on macOS and iOS, and Chrome for Android, play HLS natively from the first source. Firefox and Chrome on desktop do not, and fall back to MP4.
That fallback uses vc_auto,q_auto, which H.264-normalizes the video and adjusts compression automatically.
If you are serving responsive images from Cloudinary on the same site alongside videos, the guide on serving responsive images from Cloudinary on a Webflow site covers the f_auto, q_auto, and w_auto delivery parameters for images that complement the video setup here.
6. Enable HLS adaptive bitrate streaming with sp_auto
For marketing pages, product demos, or any video longer than a few seconds viewed on variable bandwidth connections, single-file MP4 delivery performs poorly. A static MP4 cannot adapt mid-playback when bandwidth drops.
HLS adaptive bitrate streaming solves this by generating multiple video representations at different resolutions and bitrates, then automatically switching between them based on the viewer's available bandwidth at any given moment.
Cloudinary's sp_auto parameter handles the complexity of profile selection automatically. By default, it generates representations up to 1080p using the CMAF format, which supports both HLS and MPEG-DASH delivery, selecting from a pool of representations based on source resolution.
You can cap the maximum resolution to control how many representations Cloudinary generates. sp_auto:maxres_720p limits the ceiling to 720p, producing fewer representations and a smaller transformation quota footprint.
For 4K delivery, sp_auto:maxres_2160p is available, though initial transcoding for 4K representations is asynchronous and returns 423 until complete. This is precisely why the upload preset defines these as eager transforms rather than relying on on-demand processing.
Playing HLS video withhls.js
Native HLS playback works without a JavaScript library in Safari on macOS and iOS. Firefox and Chrome on desktop before version 142 require a player that implements HLS in JavaScript, and even on newer Chrome versions, native playback isn't fully reliable across every stream. The more reliable choice across browsers is hls.js.
A Client Component using hls.js with a native HLS fallback:
'use client'
import { useEffect, useRef } from 'react'
type HlsVideoProps = { src: string }
export function HlsVideo({ src }: HlsVideoProps) {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
const video = videoRef.current
if (!video) return
if (
video.canPlayType('application/vnd.apple.mpegurl') &&
'ManagedMediaSource' in window
) {
// Safari on macOS and iOS: ManagedMediaSource is a reliable Safari signal
// for stable native HLS. Everything else goes through hls.js.
video.src = src
} else {
// Chrome, Firefox, Edge, and other browsers: load hls.js dynamically
// to keep the initial bundle small.
let hls: import('hls.js').default | null = null
import('hls.js').then(({ default: Hls }) => {
if (!Hls.isSupported()) return
hls = new Hls()
hls.loadSource(src)
hls.attachMedia(video)
})
return () => { hls?.destroy() }
}
}, [src])
return <video ref={videoRef} controls width={1280} height={720} />
}
Install hls.js with npm install hls.js. The dynamic import('hls.js') inside the effect keeps it out of the initial bundle, loading only when the browser needs it. The canPlayType check ensures Safari uses its built-in player.
This pattern works reliably across Safari, iOS, Chrome, Firefox, and Edge in Webflow Cloud deployments. The ManagedMediaSource check is the key addition. It narrows native playback to Safari, where it is well established, and routes every other browser to hls.js.
What causes Cloudinary video transcoding to fail on Webflow Cloud?
Most failures in this integration stem from four causes: the Cloudinary Node.js SDK importing core modules that the Workers runtime does not support, eager transcoding returning 423 before processing completes, an upload preset configured for Signed rather than Unsigned mode, and video files exceeding the runtime's request body limit before they reach Cloudinary.
Each problem below pairs the symptom you will see with the specific fix.
The Cloudinary Node.js SDK throws a build or runtime error
The symptom is a build log error reading Could not resolve 'http' or Unexpected external import of 'http', or a Worker threw a JavaScript exception entry in the deployment logs.
The Cloudinary Node.js SDK imports from node:http and node:https for its HTTP transport. These modules are not fully available in all Webflow Cloud deployments, and any SDK method that triggers an HTTP request may throw at runtime.
The fix is to remove the SDK and replace it with direct fetch calls to Cloudinary's REST API, exactly as the Route Handler in Step 4 demonstrates. Every operation the SDK exposes, including upload, transformation URL generation, asset management, and signed requests, has a direct REST equivalent.
There is no feature loss from switching to fetch. If you need to run SDK-based batch operations or migration scripts, do so outside of Webflow Cloud in a standard Node.js environment where the SDK operates without issue.
Eager transcoding returns 423 on the first delivery request
A 423 status code from Cloudinary means the resource exists, but the requested transformation has not finished processing. This happens when a delivery URL is requested before the asynchronous eager transform job completes. Cloudinary also processes asynchronously, and returns 423 until the job is done, for any adaptive-streaming output longer than 60 minutes or any progressive output longer than 30 minutes.
With async processing enabled in the upload preset, Cloudinary queues the transcoding job after upload and returns the original video URL immediately. If a delivery request arrives at the HLS or MP4 transformation URL before the job finishes, Cloudinary returns 423.
There are two clean approaches:
(1) The first is to set an eager notification URL on the upload preset itself, pointing at a webhook Route Handler in your Webflow Cloud app. Unsigned uploads cannot pass eager_notification_url in the request body, so it has to live on the preset. Cloudinary calls that endpoint when all eager transforms are complete.
Your app then marks the video as ready in Webflow CMS, a KV store, or a database, and the player only renders the HLS URL once the status transitions to ready. For applications that also need real-time status updates, the approach in a serverless app with Neon and Webflow Cloud can be adapted to persist transcoding status information.
(2) The second approach is to deliver the original unprocessed video using the secure_url from the upload response as an immediate fallback, then swap in the HLS URL once the eager job completes.
The upload Route Handler returns 400 with an upload preset error
The symptom is a 400 response from Cloudinary with an error message about an upload preset not found or a missing signature. This is almost always one of two things: the upload preset name in CLOUDINARY_VIDEO_UPLOAD_PRESET does not exactly match the name in Cloudinary Settings, or the preset's Signing mode is set to Signed rather than Unsigned.
Open Cloudinary Settings, then Upload, then Upload presets, and confirm the Signing mode is Unsigned. If it shows Signed, edit the preset, change the mode to Unsigned, and save. Then confirm the preset name in your environment variable is a character-for-character match with the name in the list, including any underscores, hyphens, and casing.
Copy the preset name directly from the Cloudinary dashboard rather than typing it. Retest the upload after making both corrections.
Large video files fail before reaching Cloudinary
The symptom is a 413 response or a connection reset when uploading a video through the Route Handler, with no corresponding error or upload record in Cloudinary's Media Library. This is not a Cloudinary error.
Webflow Cloud's runtime enforces a request body size limit, and requests that exceed it are rejected before the handler runs. Cloudinary never receives the file.
For production apps handling long-form video, the correct pattern is direct browser upload: the browser calls Cloudinary's upload API directly with the unsigned preset, bypassing the Route Handler for the file transfer.
The preset's eager transforms still apply, and the transcoding pipeline is unchanged. The Route Handler in Step 4 is then used only for smaller files or as a coordination layer for metadata. Our guide on sending transactional emails and SMS covers a related pattern of routing heavyweight operations away from the Route Handler to stay within runtime limits.
Start delivering production-quality video on Webflow Cloud
Video is one of the highest-impact content types on product pages and marketing sites, but it only performs well when it loads quickly, adapts to the viewer's bandwidth, and doesn't make your server do the heavy lifting.
The setup in this guide handles all three: Cloudinary hosts and transcodes the video, eager transforms pre-generate every format before the first viewer arrives, and your Webflow Cloud app generates URLs only, with no video bytes passing through the edge runtime.
Explore Webflow + Cloudinary for delivery templates, URL-based transformation parameters, and additional integration patterns beyond what this guide covers.
If you are adding observability as you ship video features, adding Sentry error tracking to a Webflow Cloud app shows how to capture upload failures and player errors in production before they affect users at scale.
And for teams building a real-time dashboard with Supabase and Webflow Cloud to track video views, upload status, or engagement metrics, Cloudinary's webhook notifications for eager transform completion can feed that pipeline using the same Route Handler patterns from this guide.
Frequently asked questions
Does the Cloudinary Node.js SDK work on Webflow Cloud?
Not reliably. The SDK imports from node:http and node:https, which the Cloudflare Workers runtime does not fully support. Use plain fetch to call Cloudinary's REST API directly instead. Every SDK operation has a direct REST equivalent, with no feature loss.
What is eager transcoding, and why does it matter for video delivery?
Eager transcoding tells Cloudinary to generate specified formats immediately at upload time rather than on the first delivery request. Without it, the first viewer triggers on-demand processing and receives a 423 response while Cloudinary works. With eager transforms in place, every viewer gets a pre-generated, CDN-cached version.
Can I upload videos larger than 100 MB through a Webflow Cloud Route Handler?
No. The Workers runtime enforces a request body size limit. For large files, upload directly from the browser to Cloudinary's upload API using the unsigned upload preset and NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME, chunking anything over 100 MB. Cloudinary's Free plan caps video files at 100 MB. The preset's eager transforms still apply, so the transcoding pipeline is unchanged.
Does sp_auto for HLS work in all browsers?
Not natively. Safari on macOS and iOS, and Chrome for Android, support HLS natively. Firefox and desktop Chrome require hls.js, which remains the more reliable choice for broad compatibility. The <source> fallback pattern in Step 5 handles both cases.
What video formats does Cloudinary accept for upload?
Cloudinary accepts MP4, MOV, AVI, MKV, WebM, OGV, 3GP, and many other formats. Most source videos are uploaded as MP4 or MOV. From any accepted format, you can transcode to any supported delivery format using URL transformation parameters or upload preset eager transforms.
Can I use AV1 codec profiles with sp_auto on Webflow Cloud?
AV1 streaming profiles require your Cloudinary account plan to use the video seconds billing metric. If your plan uses video bandwidth as the metric, AV1 profiles are not available by default. Contact Cloudinary support to discuss enabling AV1. For most apps, H.264 delivered via sp_auto is the right starting point.




