{"id":40312,"date":"2026-08-11T07:00:00","date_gmt":"2026-08-11T14:00:00","guid":{"rendered":"https:\/\/cloudinary.com\/blog\/?p=40312"},"modified":"2026-08-11T11:03:16","modified_gmt":"2026-08-11T18:03:16","slug":"stream-video-like-a-pro-hls-cloudinary-nextjs","status":"publish","type":"post","link":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs","title":{"rendered":"Adaptive Bitrate HLS Delivery With Cloudinary and Next.js"},"content":{"rendered":"<div class=\"wp-block-cloudinary-markdown \"><p>Your video buffers. Netflix doesn\u2019t. Here\u2019s the difference, and how to close it with one URL parameter.<\/p>\n<ul>\n<li>\n<strong>Live demo:<\/strong> <a href=\"https:\/\/stream-video-like-a-pro-hls-cloudin.vercel.app\/\">stream-video-like-a-pro-hls-cloudin.vercel.app<\/a>\n<\/li>\n<li>\n<strong>Source code:<\/strong> <a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\">github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs<\/a>\n<\/li>\n<\/ul>\n<h2>The Problem: Buffering<\/h2>\n<p>Every tutorial ships a <code>&lt;video src=&quot;my-video.mp4&quot; \/&gt;<\/code> and calls it done. That works fine until your user is on a train with two bars of signal. Then it buffers. Then they leave.<\/p>\n<p>The uncomfortable truth:<\/p>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Delivery Method<\/th>\n<th>What Happens on Slow 3G<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Progressive MP4<\/td>\n<td>Downloads at full bitrate or stalls<\/td>\n<\/tr>\n<tr>\n<td>Adaptive HLS<\/td>\n<td>Drops to 360p, keeps playing<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p>Progressive MP4 is a single file at a fixed bitrate. The player either downloads it fast enough to keep up, or it doesn\u2019t. There\u2019s no middle ground.<\/p>\n<p>Netflix, YouTube, Twitch, and every other major streaming platform use adaptive bitrate (ABR) streaming. The player measures your bandwidth every few seconds and switches quality seamlessly. You never notice the switch. You just never buffer.<\/p>\n<p>Setting this up used to mean FFmpeg pipelines, manifest generators, CDN configuration, and a headache. With Cloudinary, it\u2019s a simple URL change.<\/p>\n<h2>What is Adaptive Bitrate Streaming?<\/h2>\n<p>ABR streaming works by pre-encoding the same video at multiple quality levels (the <strong>quality ladder<\/strong>) and packaging them into a manifest file the player reads.<\/p>\n<h3>The Quality Ladder<\/h3>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Level<\/th>\n<th>Resolution<\/th>\n<th>Typical Bitrate<\/th>\n<th>Target Audience<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>1<\/td>\n<td>360p<\/td>\n<td>~400 kbps<\/td>\n<td>Slow 3G<\/td>\n<\/tr>\n<tr>\n<td>2<\/td>\n<td>480p<\/td>\n<td>~800 kbps<\/td>\n<td>Fast 3G<\/td>\n<\/tr>\n<tr>\n<td>3<\/td>\n<td>720p<\/td>\n<td>~2,000 kbps<\/td>\n<td>4G \/ WiFi<\/td>\n<\/tr>\n<tr>\n<td>4<\/td>\n<td>1080p<\/td>\n<td>~4,500 kbps<\/td>\n<td>Home broadband<\/td>\n<\/tr>\n<tr>\n<td>5<\/td>\n<td>4K<\/td>\n<td>~15,000 kbps<\/td>\n<td>Gigabit<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p>Every few seconds the player measures bandwidth, picks the highest quality tier it can sustain without buffering, and fetches the next segment from that tier. The switch is invisible.<\/p>\n<h3>HLS: The Dominant Protocol<\/h3>\n<p><strong>HTTP Live Streaming (HLS)<\/strong>, originally from Apple and now an <a href=\"https:\/\/datatracker.ietf.org\/doc\/html\/rfc8216\">IETF standard (RFC 8216)<\/a>, is the most widely supported ABR format on the web. It delivers content as short <code>.ts<\/code> segments (typically two to six seconds each), indexed by an <code>.m3u8<\/code> playlist file.<\/p>\n<pre class=\"js-syntax-highlighted\"><code># A simplified HLS master manifest\n#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=400000,RESOLUTION=640x360\n360p\/index.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1280x720\n720p\/index.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1920x1080\n1080p\/index.m3u8\n<\/code><\/pre>\n<p>The player fetches this manifest, reads the available variants, picks one, and starts loading segments. When bandwidth changes, it switches variant. No interruption.<\/p>\n<h2>Cloudinary\u2019s <code>sp_auto<\/code> \u2013 One Parameter to Rule Them All<\/h2>\n<p>Here\u2019s the entire Cloudinary HLS setup:<\/p>\n<pre class=\"js-syntax-highlighted\"><code># Before \u2014 fixed quality MP4\nhttps:\/\/res.cloudinary.com\/&lt;cloud&gt;\/video\/upload\/my-video.mp4\n\n# After \u2014 full adaptive HLS\nhttps:\/\/res.cloudinary.com\/&lt;cloud&gt;\/video\/upload\/sp_auto\/my-video.m3u8\n<\/code><\/pre>\n<p><code>sp<\/code> = <strong>streaming profile<\/strong>. <code>auto<\/code> = let Cloudinary choose the optimal quality ladder for the source video.<\/p>\n<h3>What <code>sp_auto<\/code> Does Behind the Scenes<\/h3>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Step<\/th>\n<th>What Cloudinary Does<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>First request<\/td>\n<td>Lazy-transcodes the source into multiple bitrate variants<\/td>\n<\/tr>\n<tr>\n<td>Manifest<\/td>\n<td>Generates an HLS master <code>.m3u8<\/code> listing all variants<\/td>\n<\/tr>\n<tr>\n<td>Segments<\/td>\n<td>Produces <code>.ts<\/code> chunks for each quality tier<\/td>\n<\/tr>\n<tr>\n<td>Delivery<\/td>\n<td>Serves everything via Cloudinary\u2019s CDN (300+ global PoPs)<\/td>\n<\/tr>\n<tr>\n<td>Subsequent requests<\/td>\n<td>Served instantly from CDN edge cache<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p>No FFmpeg, pipeline, or manifest generator. One URL change.<\/p>\n<blockquote>\n<p>View the Cloudinary docs: <a href=\"https:\/\/cloudinary.com\/documentation\/video_manipulation_and_delivery#adaptive_bitrate_streaming\">Adaptive Bitrate Streaming<\/a><\/p>\n<\/blockquote>\n<h2>What We\u2019re Building: StreamLab<\/h2>\n<p>Instead of a boring embed, you built <strong>StreamLab<\/strong>, a gamified comparison arena where readers can feel the difference between HLS and MP4, not just read about it.<\/p>\n<ul>\n<li>\n<strong>Try it live:<\/strong> <a href=\"https:\/\/stream-video-like-a-pro-hls-cloudin.vercel.app\/\">stream-video-like-a-pro-hls-cloudin.vercel.app<\/a>\n<\/li>\n<li>\n<strong>Full source:<\/strong> <a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\">github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs<\/a>\n<\/li>\n<\/ul>\n<h3>Feature Map<\/h3>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Feature<\/th>\n<th>What It Shows<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Side-by-side comparison<\/td>\n<td>HLS <code>sp_auto<\/code> vs progressive MP4, same video<\/td>\n<\/tr>\n<tr>\n<td>Network challenge board<\/td>\n<td>Pick Fiber \/ WiFi \/ 4G \/ Slow 3G<\/td>\n<\/tr>\n<tr>\n<td>Live stream intel<\/td>\n<td>Bandwidth, buffer depth, dropped frames, quality switches<\/td>\n<\/tr>\n<tr>\n<td>Quality badge<\/td>\n<td>Animates through tiers as HLS.js switches levels<\/td>\n<\/tr>\n<tr>\n<td>Score system<\/td>\n<td>Points per second based on quality tier<\/td>\n<\/tr>\n<tr>\n<td>Achievement badges<\/td>\n<td>5 badges tied to real streaming events<\/td>\n<\/tr>\n<tr>\n<td>Interactive URL anatomy<\/td>\n<td>Click each URL segment to learn what it does<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<h3>Architecture: Where Code Runs<\/h3>\n<pre class=\"js-syntax-highlighted\"><code>Server (page.tsx)\n\u2514\u2500\u2500 Builds Cloudinary URLs from env vars\n\u2514\u2500\u2500 Passes hlsSrc, mp4Src, poster to VideoArena\n\nClient (video-arena.tsx \u2192 hls-player.tsx)\n\u2514\u2500\u2500 useHls() attaches HLS.js to &lt;video&gt;\n\u2514\u2500\u2500 Safari detected \u2192 native HLS instead\n\u2514\u2500\u2500 Stats, scores, achievements update in real time\n<\/code><\/pre>\n<p>The URL construction happens server-side; no <code>env vars<\/code> leak to the client, and the heavy HLS.js library is dynamically imported only when the component mounts.<\/p>\n<h2>Project Setup<\/h2>\n<h3>Your Stack<\/h3>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Layer<\/th>\n<th>Choice<\/th>\n<th>Why<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Framework<\/td>\n<td>Next.js 16.2 (Turbopack)<\/td>\n<td>App Router, server components, fast HMR<\/td>\n<\/tr>\n<tr>\n<td>Language<\/td>\n<td>TypeScript 5<\/td>\n<td>Type-safe HLS quality levels and stats<\/td>\n<\/tr>\n<tr>\n<td>Styling<\/td>\n<td>Tailwind CSS v4 + Shadcn UI<\/td>\n<td>Dark theme, zero config<\/td>\n<\/tr>\n<tr>\n<td>HLS playback<\/td>\n<td>HLS.js 1.6<\/td>\n<td>Full ABR in Chrome\/Firefox<\/td>\n<\/tr>\n<tr>\n<td>Native HLS<\/td>\n<td>Browser-native<\/td>\n<td>Zero-JS for Safari\/iOS<\/td>\n<\/tr>\n<tr>\n<td>Performance<\/td>\n<td>web-vitals 5<\/td>\n<td>LCP measurement<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<h3>Scaffold<\/h3>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-selector-tag\">npx<\/span> <span class=\"hljs-selector-tag\">create-next-app<\/span><span class=\"hljs-keyword\">@latest<\/span> . --typescript --tailwind --app --src-dir\nnpx shadcn@latest init --defaults\nnpx shadcn@latest add badge card progress separator tabs tooltip\nnpm install hls.js web-vitals\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>The Turbopack <code>workspace-root gotcha<\/code><\/h3>\n<p>If you have a <code>package-lock.json<\/code> anywhere up your directory tree (common on developer machines), Turbopack may adopt the wrong workspace root and throw a <code>React Client Manifest<\/code> 500 error.<\/p>\n<p>Fix it in <a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/next.config.ts\"><code>next.config.ts<\/code><\/a>:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">const<\/span> nextConfig: NextConfig = {\n  <span class=\"hljs-attr\">turbopack<\/span>: {\n    <span class=\"hljs-attr\">root<\/span>: process.cwd(), <span class=\"hljs-comment\">\/\/ anchor to the project, not the filesystem root<\/span>\n  },\n};\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Then delete <code>.next\/<\/code> and restart. One-time fix.<\/p>\n<h2>Cloudinary URL Helpers<\/h2>\n<p>All URL construction lives in <a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/lib\/cloudinary.ts\"><code>src\/lib\/cloudinary.ts<\/code><\/a>. Pure functions, no SDK dependency.<\/p>\n<h3>The 3 Functions You Need<\/h3>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml shcb-wrap-lines\">hlsUrl({ cloudName, publicId })\n\/\/ \u2192 https:\/\/res.cloudinary.com\/<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">cloud<\/span>&gt;<\/span>\/video\/upload\/sp_auto\/<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">id<\/span>&gt;<\/span>.m3u8\n\nmp4Url({ cloudName, publicId })\n\/\/ \u2192 https:\/\/res.cloudinary.com\/<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">cloud<\/span>&gt;<\/span>\/video\/upload\/<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">id<\/span>&gt;<\/span>.mp4\n\nposterUrl({ cloudName, publicId }, offsetSeconds = 2)\n\/\/ \u2192 https:\/\/res.cloudinary.com\/<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">cloud<\/span>&gt;<\/span>\/video\/upload\/so_2.0,pg_1\/<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">id<\/span>&gt;<\/span>.jpg\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The poster URL is worth highlighting. <code>so_2.0<\/code> seeks 2 seconds in; <code>pg_1<\/code> extracts that frame as an image. <strong>No separate upload. No pre-processing step.<\/strong> Cloudinary generates it on the first request and caches it.<\/p>\n<h3>Why <code>server-side<\/code>?<\/h3>\n<p>URLs are built in <code>page.tsx<\/code> (a React Server Component) and passed down as props. This means:<\/p>\n<ul>\n<li>Env <code>vars<\/code> never reach the browser bundle.<\/li>\n<li>URL computation has zero client-side cost.<\/li>\n<li>The HTML already contains the correct <code>poster<\/code> src on first paint, improving LCP.<\/li>\n<\/ul>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/app\/page.tsx<\/span>\n<span class=\"hljs-keyword\">const<\/span> config = { cloudName, publicId };\n\n<span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">VideoArena<\/span>\n  <span class=\"hljs-attr\">hlsSrc<\/span>=<span class=\"hljs-string\">{hlsUrl(config)}<\/span>\n  <span class=\"hljs-attr\">mp4Src<\/span>=<span class=\"hljs-string\">{mp4Url(config)}<\/span>\n  <span class=\"hljs-attr\">poster<\/span>=<span class=\"hljs-string\">{posterUrl(config,<\/span> <span class=\"hljs-attr\">2<\/span>)}\n\/&gt;<\/span><\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<blockquote>\n<p>See the full helpers: <a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/lib\/cloudinary.ts\"><code>src\/lib\/cloudinary.ts<\/code><\/a><\/p>\n<\/blockquote>\n<h2>The <code>useHls<\/code> Hook \u2013 HLS.js + Safari Native Fallback<\/h2>\n<p>The entire HLS lifecycle lives in <a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/hooks\/use-hls.ts\"><code>src\/hooks\/use-hls.ts<\/code><\/a>. Three design decisions made this nontrivial.<\/p>\n<h3>Decision 1: Chrome Fails the <code>canPlayType<\/code> Test<\/h3>\n<p>Chrome on macOS passes <code>video.canPlayType(&quot;application\/vnd.apple.mpegurl&quot;)<\/code>, but stalls on Cloudinary\u2019s HLS segments and emits no quality-level events. Using native HLS for Chrome produces a broken experience.<\/p>\n<p>The fix: a user-agent regex that detects <em>actual<\/em> Safari:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">isActualSafari<\/span>(<span class=\"hljs-params\"><\/span>): <span class=\"hljs-title\">boolean<\/span> <\/span>{\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-regexp\">\/^((?!chrome|android|crios|fxios).)*safari\/i<\/span>.test(navigator.userAgent);\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Browser<\/th>\n<th>Strategy<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Safari, iOS WebKit<\/td>\n<td>Native <code>&lt;video src=&quot;.m3u8&quot;&gt;<\/code><\/td>\n<\/tr>\n<tr>\n<td>Chrome, Firefox, Edge<\/td>\n<td>HLS.js via dynamic <code>import()<\/code><\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<h3>Decision 2: The <code>cancelled<\/code> Flag for Async Cleanup<\/h3>\n<p>HLS.js loads via an asynchronous dynamic import. If the component unmounts before the import resolves, the callback fires on a dead component and calls <code>setState<\/code> into the void (React warning) or, worse, into a freshly mounted replacement (wrong data).<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\">useEffect(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n  <span class=\"hljs-keyword\">let<\/span> cancelled = <span class=\"hljs-literal\">false<\/span>;\n\n  <span class=\"hljs-keyword\">import<\/span>(<span class=\"hljs-string\">\"hls.js\"<\/span>).then(<span class=\"hljs-function\">(<span class=\"hljs-params\">{ <span class=\"hljs-keyword\">default<\/span>: Hls }<\/span>) =&gt;<\/span> {\n    <span class=\"hljs-keyword\">if<\/span> (cancelled) <span class=\"hljs-keyword\">return<\/span>; <span class=\"hljs-comment\">\/\/ component already gone<\/span>\n\n    hls.on(Hls.Events.MANIFEST_PARSED, (_, data) =&gt; {\n      <span class=\"hljs-keyword\">if<\/span> (cancelled) <span class=\"hljs-keyword\">return<\/span>; <span class=\"hljs-comment\">\/\/ guard every async callback<\/span>\n      setLevels(data.levels.map(<span class=\"hljs-comment\">\/* ... *\/<\/span>));\n      setIsLoaded(<span class=\"hljs-literal\">true<\/span>);\n    });\n  });\n\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n    cancelled = <span class=\"hljs-literal\">true<\/span>; <span class=\"hljs-comment\">\/\/ all callbacks check this before setState<\/span>\n    hlsRef.current?.destroy();\n  };\n}, &#91;src, autoPlay]);\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>Decision 3: Callback <code>refs<\/code> to Break the <code>dep-array<\/code> Cycle<\/h3>\n<p><code>onLevelSwitch<\/code> and <code>onEnded<\/code> are callbacks from the parent. If they\u2019re in the <code>useEffect<\/code> dep array, any time the parent re-renders with a new inline function reference, HLS tears down and re-initialises. Infinite loop.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Store latest version in a ref \u2014 always current, never a dep<\/span>\n<span class=\"hljs-keyword\">const<\/span> onLevelSwitchRef = useRef(onLevelSwitch);\nonLevelSwitchRef.current = onLevelSwitch;\n\n<span class=\"hljs-comment\">\/\/ Inside the effect, call via ref \u2014 zero dep-array impact<\/span>\nhls.on(Hls.Events.LEVEL_SWITCHED, (_, data) =&gt; {\n  onLevelSwitchRef.current?.(data.level);\n});\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Dep array stays <code>[src, autoPlay]<\/code>. HLS only restarts when the source actually changes.<\/p>\n<blockquote>\n<p>Full hook implementation: <a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/hooks\/use-hls.ts\"><code>src\/hooks\/use-hls.ts<\/code><\/a><\/p>\n<\/blockquote>\n<h2>The Players: HLS vs. MP4 Side by Side<\/h2>\n<h3>HLS Player: <a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/components\/hls-player.tsx\"><code>src\/components\/hls-player.tsx<\/code><\/a><\/h3>\n<p>Three things the native <code>&lt;video&gt;<\/code> element can\u2019t do on its own:<\/p>\n<ol>\n<li>\n<strong>Quality badge overlay.<\/strong> Reads <code>stats.qualityLabel<\/code> from <code>useHls<\/code> and renders a <code>QualityBadge<\/code> that animates through tiers<\/li>\n<li>\n<strong>Score accumulation.<\/strong> A <code>setInterval<\/code> ticks every second and awards points based on the current quality level<\/li>\n<li>\n<strong>Quality level pills.<\/strong> Shows every available HLS variant below the player, highlights the active one<\/li>\n<\/ol>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml shcb-wrap-lines\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">video<\/span>\n  <span class=\"hljs-attr\">ref<\/span>=<span class=\"hljs-string\">{videoRef}<\/span>\n  <span class=\"hljs-attr\">poster<\/span>=<span class=\"hljs-string\">{poster}<\/span>\n  <span class=\"hljs-attr\">controls<\/span>\n  <span class=\"hljs-attr\">playsInline<\/span>\n  <span class=\"hljs-attr\">preload<\/span>=<span class=\"hljs-string\">\"auto\"<\/span>   \/\/ <span class=\"hljs-attr\">start<\/span> <span class=\"hljs-attr\">buffering<\/span> <span class=\"hljs-attr\">immediately<\/span> <span class=\"hljs-attr\">on<\/span> <span class=\"hljs-attr\">mount<\/span>\n  <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"w-full rounded-lg aspect-video bg-zinc-900\"<\/span>\n\/&gt;<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p><code>preload=&quot;auto&quot;<\/code> is the often-forgotten attribute that tells the browser to start filling the buffer without waiting for the user to hit play. Critical for LCP.<\/p>\n<h3>MP4 Player: <a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/components\/mp4-player.tsx\"><code>src\/components\/mp4-player.tsx<\/code><\/a><\/h3>\n<p>A standard <code>&lt;video&gt;<\/code> element with a stats panel that shows buffer depth, dropped frames, and load time, using the same metrics as the HLS player so the comparison is honest.<\/p>\n<p>The key difference the stats reveal: <strong>MP4 shows no quality label<\/strong> because there\u2019s only one. HLS shows the current tier and how many times it switched.<\/p>\n<h2>Stats Panel: Real-Time Stream Intel<\/h2>\n<p><a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/components\/stats-panel.tsx\"><code>src\/components\/stats-panel.tsx<\/code><\/a> surfaces six numbers that tell the real story:<\/p>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Stat<\/th>\n<th>Source<\/th>\n<th>What It Reveals<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Quality<\/td>\n<td><code>hls.levels[hls.currentLevel].height<\/code><\/td>\n<td>Current tier (360p \u2192 4K)<\/td>\n<\/tr>\n<tr>\n<td>Bandwidth<\/td>\n<td><code>hls.bandwidthEstimate \/ 1000<\/code><\/td>\n<td>HLS.js measured throughput in kbps<\/td>\n<\/tr>\n<tr>\n<td>Buffer<\/td>\n<td><code>video.buffered.end(n) - video.currentTime<\/code><\/td>\n<td>Seconds of video ready to play<\/td>\n<\/tr>\n<tr>\n<td>Dropped frames<\/td>\n<td><code>video.webkitDroppedFrameCount<\/code><\/td>\n<td>Decode failures (Chromium only)<\/td>\n<\/tr>\n<tr>\n<td>Switches<\/td>\n<td>Local counter on <code>LEVEL_SWITCHED<\/code> event<\/td>\n<td>How many times ABR kicked in<\/td>\n<\/tr>\n<tr>\n<td>Score<\/td>\n<td>Calculated in <code>HlsPlayer<\/code><\/td>\n<td>Gamification metric<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p><strong>One honest caveat:<\/strong> <code>webkitDroppedFrameCount<\/code> is a Chromium-only API. On Firefox and Safari it returns <code>undefined<\/code> and shows <code>0<\/code>. The stats panel handles this gracefully with a nullish fallback.<\/p>\n<p>Stats are updated two ways:<\/p>\n<ul>\n<li>\n<strong>Event-driven:<\/strong> On <code>FRAG_LOADED<\/code> (every segment fetch).<\/li>\n<li>\n<strong>Polled:<\/strong> <code>setInterval<\/code> every 1 second for buffer depth (the video element doesn\u2019t fire events for this).<\/li>\n<\/ul>\n<h2>The Gamification Layer<\/h2>\n<p>The gamification lives in <a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/components\/video-arena.tsx\"><code>src\/components\/video-arena.tsx<\/code><\/a> and <a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/types\/video.ts\"><code>src\/types\/video.ts<\/code><\/a>.<\/p>\n<h3>Scoring by Quality Tier<\/h3>\n<p>Higher quality = more points per second. This makes the advantage of HLS immediately visible: a smooth 1080p stream scores 4x more than a buffering 360p one.<\/p>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Quality<\/th>\n<th>pts\/s<\/th>\n<th>Why<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Auto<\/td>\n<td>5<\/td>\n<td>Buffering \/ undetermined<\/td>\n<\/tr>\n<tr>\n<td>360p<\/td>\n<td>10<\/td>\n<td>Minimum viable<\/td>\n<\/tr>\n<tr>\n<td>480p<\/td>\n<td>15<\/td>\n<td>\u2014<\/td>\n<\/tr>\n<tr>\n<td>720p<\/td>\n<td>25<\/td>\n<td>HD threshold<\/td>\n<\/tr>\n<tr>\n<td>1080p<\/td>\n<td>40<\/td>\n<td>Full HD<\/td>\n<\/tr>\n<tr>\n<td>4K<\/td>\n<td>60<\/td>\n<td>Maximum<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<h3>Achievement Triggers<\/h3>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Badge<\/th>\n<th>Trigger<\/th>\n<th>What It Teaches<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>\ud83d\ude80 First Stream<\/td>\n<td>Play starts<\/td>\n<td>HLS.js loaded and attached<\/td>\n<\/tr>\n<tr>\n<td>\ud83c\udfc4 Quality Surfer<\/td>\n<td>3+ level switches<\/td>\n<td>ABR is actively adapting<\/td>\n<\/tr>\n<tr>\n<td>\ud83c\udfc6 Bandwidth Master<\/td>\n<td>All 4 presets tested<\/td>\n<td>ABR works across conditions<\/td>\n<\/tr>\n<tr>\n<td>\u2705 HLS Convert<\/td>\n<td>Stream reaches end<\/td>\n<td>Reliable delivery end-to-end<\/td>\n<\/tr>\n<tr>\n<td>\ud83c\udfaf Score Hunter<\/td>\n<td>500 points<\/td>\n<td>Higher quality = sustained reward<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<h3>The <code>unlockedRef<\/code> Pattern<\/h3>\n<p>The achievement <code>unlock<\/code> function needs to check whether a badge is already unlocked. The naive approach (closing over <code>unlockedIds<\/code> state) creates a new function identity every time an achievement unlocks, cascading new identities into every callback and triggering an infinite HLS re-init loop.<\/p>\n<p>The fix: track unlocked IDs in a ref, not state, for the deduplication check.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">const<\/span> unlockedRef = useRef(<span class=\"hljs-keyword\">new<\/span> <span class=\"hljs-built_in\">Set<\/span>&lt;string&gt;());\n\n<span class=\"hljs-keyword\">const<\/span> unlock = useCallback(<span class=\"hljs-function\">(<span class=\"hljs-params\">id: string<\/span>) =&gt;<\/span> {\n  <span class=\"hljs-keyword\">if<\/span> (unlockedRef.current.has(id)) <span class=\"hljs-keyword\">return<\/span>; <span class=\"hljs-comment\">\/\/ ref check, not state<\/span>\n  unlockedRef.current = <span class=\"hljs-keyword\">new<\/span> <span class=\"hljs-built_in\">Set<\/span>(&#91;...unlockedRef.current, id]);\n  setUnlockedIds(unlockedRef.current); <span class=\"hljs-comment\">\/\/ update state for the UI<\/span>\n  setPendingAchievement(achievement);\n}, &#91;]); <span class=\"hljs-comment\">\/\/ &#91;] \u2014 permanently stable, no cascade<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<blockquote>\n<p>Full implementation: <a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/components\/video-arena.tsx\"><code>src\/components\/video-arena.tsx<\/code><\/a><\/p>\n<\/blockquote>\n<h2>Interactive URL Anatomy<\/h2>\n<p>The most effective way to teach a URL parameter is to make it clickable.<\/p>\n<p><a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/components\/url-anatomy.tsx\"><code>src\/components\/url-anatomy.tsx<\/code><\/a> renders the full HLS URL as a row of interactive segments. Click any segment and a panel below explains exactly what it does.<\/p>\n<pre class=\"js-syntax-highlighted\"><code>https:\/\/res.cloudinary.com\/&lt;cloud&gt;\/video\/upload\/sp_auto\/&lt;id&gt;.m3u8\n\u2502                           \u2502       \u2502             \u2502       \u2502     \u2502\nCDN base                    Cloud   Resource      KEY     ID    Manifest\n                            name    type          \u2191\n                                             Click to learn\n<\/code><\/pre>\n<p>Each segment is a <code>&lt;button&gt;<\/code> with a colour, a label, and a description. No third-party library. Zero dependencies. Pure <code>useState<\/code> with a lookup table.<\/p>\n<p>The <code>sp_auto<\/code> segment starts highlighted by default, as that\u2019s the one thing readers need to remember.<\/p>\n<hr \/>\n<h2>React Hook Pitfalls: 3 Bugs You Encountered and Fixed<\/h2>\n<p>Building this demo surfaced three nonobvious React hook bugs worth documenting.<\/p>\n<h3>Bug 1: <code>setState<\/code> Inside a State Updater<\/h3>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ \u274c Calls parent setState inside a child setState updater<\/span>\nsetScore(<span class=\"hljs-function\">(<span class=\"hljs-params\">prev<\/span>) =&gt;<\/span> {\n  <span class=\"hljs-keyword\">const<\/span> next = prev + rate;\n  onScoreUpdate?.(next); <span class=\"hljs-comment\">\/\/ triggers parent's setScore \u2192 renders during render<\/span>\n  <span class=\"hljs-keyword\">return<\/span> next;\n});\n\n<span class=\"hljs-comment\">\/\/ \u2705 Call them sequentially, not nested<\/span>\nscoreRef.current += rate;\nsetScore(scoreRef.current);\nonScoreUpdate?.(scoreRef.current);\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>React\u2019s state updater function must be pure. Calling another component\u2019s <code>setState<\/code> inside it violates that contract and logs \u201cCannot update a component while rendering a different component.\u201d<\/p>\n<h3>Bug 2: Unstable Callbacks in <code>useCallback<\/code> Deps<\/h3>\n<p>The original <code>unlock<\/code> closed over <code>unlockedIds<\/code> state to check for duplicates:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ \u274c unlock closes over state \u2014 new identity on every unlock<\/span>\n<span class=\"hljs-keyword\">const<\/span> unlock = useCallback(<span class=\"hljs-function\">(<span class=\"hljs-params\">id: string<\/span>) =&gt;<\/span> {\n  <span class=\"hljs-keyword\">if<\/span> (unlockedIds.has(id)) <span class=\"hljs-keyword\">return<\/span>;           <span class=\"hljs-comment\">\/\/ state in dep array<\/span>\n  setUnlockedIds(<span class=\"hljs-function\"><span class=\"hljs-params\">prev<\/span> =&gt;<\/span> <span class=\"hljs-keyword\">new<\/span> <span class=\"hljs-built_in\">Set<\/span>(&#91;...prev, id]));\n}, &#91;unlockedIds]);                            <span class=\"hljs-comment\">\/\/ \u2190 triggers the loop<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Calling <code>unlock<\/code> updates <code>unlockedIds<\/code>. That creates a new <code>unlock<\/code>. A new <code>unlock<\/code> creates a new <code>handleScoreUpdate<\/code>. A new <code>handleScoreUpdate<\/code> changes the <code>onScoreUpdate<\/code> prop. That prop change restarts the score <code>useEffect<\/code>, resets the interval, and fires <code>onScoreUpdate<\/code> again. Full circle.<\/p>\n<p>The fix: move the duplicate check into a ref so <code>unlock<\/code> never needs state in its dep array.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ \u2705 unlock reads a ref \u2014 identity never changes<\/span>\n<span class=\"hljs-keyword\">const<\/span> unlockedRef = useRef(<span class=\"hljs-keyword\">new<\/span> <span class=\"hljs-built_in\">Set<\/span>&lt;string&gt;());\n\n<span class=\"hljs-keyword\">const<\/span> unlock = useCallback(<span class=\"hljs-function\">(<span class=\"hljs-params\">id: string<\/span>) =&gt;<\/span> {\n  <span class=\"hljs-keyword\">if<\/span> (unlockedRef.current.has(id)) <span class=\"hljs-keyword\">return<\/span>;  <span class=\"hljs-comment\">\/\/ ref, not state<\/span>\n  unlockedRef.current = <span class=\"hljs-keyword\">new<\/span> <span class=\"hljs-built_in\">Set<\/span>(&#91;...unlockedRef.current, id]);\n  setUnlockedIds(unlockedRef.current);       <span class=\"hljs-comment\">\/\/ state only for the UI<\/span>\n}, &#91;]);                                       <span class=\"hljs-comment\">\/\/ \u2190 permanently stable<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p><strong>Rule:<\/strong> if a <code>useCallback<\/code> dep is state that <em>changes as a result<\/em> of calling that callback, you have a self-reinforcing loop. Move the check to a ref.<\/p>\n<h3>Bug 3: Cleanup State Resets Without a <code>cancelled<\/code> Flag<\/h3>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ \u274c The async callback ignores whether the effect was cleaned up<\/span>\n<span class=\"hljs-keyword\">import<\/span>(<span class=\"hljs-string\">\"hls.js\"<\/span>).then(<span class=\"hljs-function\">(<span class=\"hljs-params\">{ <span class=\"hljs-keyword\">default<\/span>: Hls }<\/span>) =&gt;<\/span> {\n  hls.on(Hls.Events.MANIFEST_PARSED, () =&gt; {\n    setIsLoaded(<span class=\"hljs-literal\">true<\/span>); <span class=\"hljs-comment\">\/\/ fires on the old (cleaned-up) effect's HLS instance<\/span>\n  });\n});\n\n<span class=\"hljs-comment\">\/\/ \u2705 Cancelled flag blocks all post-cleanup setState calls<\/span>\n<span class=\"hljs-keyword\">let<\/span> cancelled = <span class=\"hljs-literal\">false<\/span>;\n<span class=\"hljs-keyword\">import<\/span>(<span class=\"hljs-string\">\"hls.js\"<\/span>).then(<span class=\"hljs-function\">(<span class=\"hljs-params\">{ <span class=\"hljs-keyword\">default<\/span>: Hls }<\/span>) =&gt;<\/span> {\n  hls.on(Hls.Events.MANIFEST_PARSED, () =&gt; {\n    <span class=\"hljs-keyword\">if<\/span> (cancelled) <span class=\"hljs-keyword\">return<\/span>;\n    setIsLoaded(<span class=\"hljs-literal\">true<\/span>);\n  });\n});\n<span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> { cancelled = <span class=\"hljs-literal\">true<\/span>; hls.destroy(); };\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>In React 18 Strict Mode, effects intentionally mount \u2192 cleanup \u2192 remount. Without the <code>cancelled<\/code> flag, the first effect\u2019s async callback sets state on the second mount\u2019s component, producing the \u201cMaximum update depth exceeded\u201d loop.<\/p>\n<h2>Measuring the Difference: LCP and Core Web Vitals<\/h2>\n<p><strong>Largest Contentful Paint (LCP)<\/strong> is the Core Web Vitals metric most affected by video delivery. A video with a slow-loading poster, or one that blocks the main thread, tanks LCP.<\/p>\n<h3>What Moves the Needle<\/h3>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Practice<\/th>\n<th>Impact on LCP<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>poster<\/code> attribute from <code>posterUrl()<\/code><\/td>\n<td>Image rendered before video loads, a direct LCP candidate<\/td>\n<\/tr>\n<tr>\n<td><code>preload=&quot;auto&quot;<\/code><\/td>\n<td>Browser starts buffering on mount, not on play<\/td>\n<\/tr>\n<tr>\n<td>Poster URL built server-side<\/td>\n<td>In HTML on first paint, no client round-trip<\/td>\n<\/tr>\n<tr>\n<td>HLS segments vs full MP4<\/td>\n<td>First segment (2\u20136 s of video) loads fast vs full file<\/td>\n<\/tr>\n<tr>\n<td>Cloudinary CDN<\/td>\n<td>Edge delivery reduces TTFB<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<h3>HLS vs. MP4 on Simulated Slow 3G<\/h3>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Metric<\/th>\n<th>Progressive MP4<\/th>\n<th>HLS <code>sp_auto<\/code><\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Time to first frame<\/td>\n<td>Waits for enough of the file<\/td>\n<td>Loads first 360p segment (~100 KB)<\/td>\n<\/tr>\n<tr>\n<td>Buffer behaviour<\/td>\n<td>Stalls if download can\u2019t keep up<\/td>\n<td>Drops quality tier, keeps playing<\/td>\n<\/tr>\n<tr>\n<td>LCP (poster)<\/td>\n<td>Same<\/td>\n<td>Same<\/td>\n<\/tr>\n<tr>\n<td>Rebuffering events<\/td>\n<td>Frequent on slow connections<\/td>\n<td>Near-zero<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<blockquote>\n<p>Install <code>web-vitals<\/code> to instrument LCP in your own pages: <a href=\"https:\/\/web.dev\/vitals\/\">web.dev\/vitals<\/a>\nCloudinary\u2019s <a href=\"https:\/\/cloudinary.com\/documentation\/video_optimization\">Video Optimization guide<\/a> covers additional LCP wins like format selection and codec settings.<\/p>\n<\/blockquote>\n<h2>Deployment<\/h2>\n<h3>Environment Variables &gt; Vercel<\/h3>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-14\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\"><span class=\"hljs-comment\"># Local<\/span>\nNEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud\nNEXT_PUBLIC_CLOUDINARY_VIDEO_PUBLIC_ID=your-video-id\n\n<span class=\"hljs-comment\"># Vercel dashboard \u2192 Settings \u2192 Environment Variables \u2192 same keys<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-14\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Both variables are <code>NEXT_PUBLIC_<\/code>, inlined at build time into the client bundle. Safe because they\u2019re already public (they appear in every video URL your browser loads).<\/p>\n<h3>The First-Load Transcoding Delay<\/h3>\n<p><code>sp_auto<\/code> is a <strong>lazy transformation<\/strong>. The first request to <code>your-video.m3u8<\/code> triggers Cloudinary to transcode the source into HLS variants. The manifest returns quickly; the first segments may buffer for 10\u201330 seconds while transcoding completes.<\/p>\n<p>A pre-warm strategy would be to make an HTTP request to the <code>.m3u8<\/code> URL from your CI\/CD pipeline or a serverless function after each deploy. Subsequent loads hit the CDN edge cache instantly.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-15\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\"><span class=\"hljs-comment\"># Simple pre-warm in your deploy script<\/span>\ncurl -s <span class=\"hljs-string\">\"https:\/\/res.cloudinary.com\/&lt;cloud&gt;\/video\/upload\/sp_auto\/&lt;id&gt;.m3u8\"<\/span> &gt; \/dev\/<span class=\"hljs-keyword\">null<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-15\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<blockquote>\n<p>Alternatively, use <a href=\"https:\/\/cloudinary.com\/documentation\/upload_parameters#eager\">Cloudinary\u2019s eager transformations<\/a> at upload time to pregenerate HLS segments before any user requests them.<\/p>\n<\/blockquote>\n<h3>Production Demo<\/h3>\n<p>The StreamLab demo is live at <a href=\"https:\/\/stream-video-like-a-pro-hls-cloudin.vercel.app\/\">stream-video-like-a-pro-hls-cloudin.vercel.app<\/a>, deployed on Vercel\u2019s Edge Network backed by Cloudinary\u2019s CDN for the media assets.<\/p>\n<h2>Key Takeaways<\/h2>\n<h3>What <code>sp_auto<\/code> Gives You for Free<\/h3>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Capability<\/th>\n<th>DIY setup<\/th>\n<th>Cloudinary <code>sp_auto<\/code><\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Multi-bitrate encoding<\/td>\n<td>FFmpeg pipeline<\/td>\n<td>\u2705 Automatic<\/td>\n<\/tr>\n<tr>\n<td>HLS manifest generation<\/td>\n<td>Custom tooling<\/td>\n<td>\u2705 Automatic<\/td>\n<\/tr>\n<tr>\n<td>Quality ladder selection<\/td>\n<td>Manual<\/td>\n<td>\u2705 Optimised per source<\/td>\n<\/tr>\n<tr>\n<td>CDN delivery<\/td>\n<td>Separate CDN config<\/td>\n<td>\u2705 Included<\/td>\n<\/tr>\n<tr>\n<td>Poster frame extraction<\/td>\n<td>Separate encode<\/td>\n<td>\u2705 <code>so_N.0,pg_1<\/code> param<\/td>\n<\/tr>\n<tr>\n<td>First-load processing<\/td>\n<td>Pre-run pipeline<\/td>\n<td>\u2705 Lazy on first request<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<h3>When to Use HLS vs. MP4<\/h3>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Situation<\/th>\n<th>Recommendation<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Short clips &lt; 30 s<\/td>\n<td>MP4 \u2014 simpler, fast enough<\/td>\n<\/tr>\n<tr>\n<td>Course \/ tutorial videos<\/td>\n<td>HLS \u2014 viewers on all connections<\/td>\n<\/tr>\n<tr>\n<td>Live streaming<\/td>\n<td>HLS (with low-latency mode)<\/td>\n<\/tr>\n<tr>\n<td>Mobile-first audience<\/td>\n<td>HLS \u2014 non-negotiable<\/td>\n<\/tr>\n<tr>\n<td>Background \/ hero video<\/td>\n<td>MP4 + <code>muted autoplay playsInline<\/code><\/td>\n<\/tr>\n<tr>\n<td>Logged-in product video<\/td>\n<td>HLS \u2014 you care about completion rate<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<h3>The 2 Lines That Matter<\/h3>\n<pre class=\"js-syntax-highlighted\"><code>Before: https:\/\/res.cloudinary.com\/&lt;cloud&gt;\/video\/upload\/&lt;id&gt;.mp4\nAfter:  https:\/\/res.cloudinary.com\/&lt;cloud&gt;\/video\/upload\/sp_auto\/&lt;id&gt;.m3u8\n<\/code><\/pre>\n<p>That\u2019s it. With one parameter, you get full adaptive streaming.<\/p>\n<p>Ready to start building with Cloudinary? <a href=\"https:\/\/cloudinary.com\/users\/register_free\">Sign up<\/a> for a free account today.<\/p>\n<h3>Further Reading<\/h3>\n<ul>\n<li>\n<a href=\"https:\/\/cloudinary.com\/documentation\/video_manipulation_and_delivery#adaptive_bitrate_streaming\">Cloudinary Adaptive Bitrate Streaming<\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/cloudinary.com\/documentation\/video_optimization\">Cloudinary Video Optimization Guide<\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/cloudinary.com\/documentation\/upload_parameters#eager\">Cloudinary Eager Transformations<\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/github.com\/video-dev\/hls.js\">HLS.js Documentation<\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/datatracker.ietf.org\/doc\/html\/rfc8216\">IETF RFC 8216 \u2014 HTTP Live Streaming<\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/web.dev\/lcp\/\">Core Web Vitals \u2014 LCP<\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/nextjs.org\/docs\/app\">Next.js App Router<\/a>\n<\/li>\n<\/ul>\n<h3>Source Code Reference<\/h3>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>File<\/th>\n<th>What\u2019s Inside<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/lib\/cloudinary.ts\"><code>src\/lib\/cloudinary.ts<\/code><\/a><\/td>\n<td>URL builders<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/hooks\/use-hls.ts\"><code>src\/hooks\/use-hls.ts<\/code><\/a><\/td>\n<td>HLS.js hook<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/components\/hls-player.tsx\"><code>src\/components\/hls-player.tsx<\/code><\/a><\/td>\n<td>HLS player<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/components\/mp4-player.tsx\"><code>src\/components\/mp4-player.tsx<\/code><\/a><\/td>\n<td>MP4 baseline<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/components\/video-arena.tsx\"><code>src\/components\/video-arena.tsx<\/code><\/a><\/td>\n<td>Main orchestrator<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/components\/url-anatomy.tsx\"><code>src\/components\/url-anatomy.tsx<\/code><\/a><\/td>\n<td>Interactive URL breakdown<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/src\/types\/video.ts\"><code>src\/types\/video.ts<\/code><\/a><\/td>\n<td>Types, scoring, achievements<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\/blob\/main\/next.config.ts\"><code>next.config.ts<\/code><\/a><\/td>\n<td>Turbopack root fix<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p><em>Built with Next.js 16.2, Cloudinary, HLS.js, Tailwind CSS, and Shadcn UI.<\/em>\n<em>Full source at <a href=\"https:\/\/github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs\">github.com\/musebe\/stream-video-like-a-pro-hls-cloudinary-nextjs<\/a>.<\/em><\/p>\n<\/div>\n\n\n<style>\n.faqs {\n    padding: 30px 60px;\n    margin-top: 40px;\n    background: var(--color-background-offset);\n    border-radius: 20px;\n}\n\n#frequently_asked_questions {\n    margin-bottom: 20px;\n}\n\n.question {\n    margin-bottom: 12px;\n    border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n}\n\n.question summary {\n    position: relative;\n    padding: 18px 40px 18px 0;\n    cursor: pointer;\n    font-weight: 700;\n    list-style: none;\n}\n\n.question summary::-webkit-details-marker {\n    display: none;\n}\n\n.question summary::after {\n    content: \"+\";\n    position: absolute;\n    top: 50%;\n    right: 0;\n    transform: translateY(-50%);\n    font-size: 24px;\n    font-weight: 400;\n    line-height: 1;\n}\n\n.question[open] summary::after {\n    content: \"\u2212\";\n}\n\n.answer {\n    padding: 0 40px 20px 0;\n}\n<\/style>\n\n<div class=\"faqs\">\n\n<h2 id=\"frequently_asked_questions\">Frequently Asked Questions<\/h2>\n\n<details class=\"question\">\n    <summary>Why does a progressive MP4 buffer on slow network connections compared to Adaptive HLS?<\/summary>\n    <div class=\"answer\">\n        A progressive MP4 is encoded as a single static file at a fixed bitrate. The browser must download the chunks sequentially and fast enough to maintain playback. If network throughput falls below this fixed bitrate, the video stalls and buffers.\n        <br><br>\n        Adaptive HLS (HTTP Live Streaming) solves this by splitting the video into multiple quality tiers, known as a quality ladder, and chunking them into short 2\u20136 second segments. The player continually measures real-time network bandwidth and switches seamlessly to lower-bitrate segments, such as 360p, on slow connections to prevent playback interruption. It raises the quality back to 1080p or 4K when bandwidth recovers.\n    <\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>What is Cloudinary&#8217;s <code>sp_auto<\/code> parameter, and how does it generate HLS files?<\/summary>\n    <div class=\"answer\">\n        <code>sp_auto<\/code> stands for \u201cStreaming Profile: Auto.\u201d When appended as a transformation segment to a Cloudinary delivery URL, such as <code>\/video\/upload\/sp_auto\/video-id.m3u8<\/code>, Cloudinary handles the adaptive bitrate workflow.\n        <br><br>\n        It analyzes the source video, creates an optimized multi-bitrate quality ladder, transcodes the video into those variants, generates the master <code>.m3u8<\/code> playlist, and splits the variants into streaming segments. The resulting assets are delivered and cached through the CDN, eliminating the need for manual FFmpeg configurations or custom origin infrastructure.\n    <\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>How do you handle Safari native HLS playback versus Chrome using HLS.js?<\/summary>\n    <div class=\"answer\">\n        Most modern browsers support Media Source Extensions (MSE), which allow libraries such as HLS.js to play <code>.m3u8<\/code> manifests. Safari and iOS WebKit can play HLS natively through the HTML5 <code>&lt;video&gt;<\/code> element.\n        <br><br>\n        A custom hook such as <code>useHls<\/code> should perform a browser capability check. Because Chrome on macOS can return a false positive for native HLS support while failing to provide required stream statistics events, the implementation should detect Safari explicitly. For example:\n        <br><br>\n        <code>\/^((?!chrome|android|crios|fxios).)*safari\/i.test(navigator.userAgent)<\/code>\n        <br><br>\n        Native Safari can use <code>&lt;video src=\"...m3u8\"&gt;<\/code>, while Chrome, Edge, and Firefox can dynamically load HLS.js and bind it to the video element.\n    <\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>How do you solve React re-rendering loops caused by state updates in asynchronous HLS.js callbacks?<\/summary>\n    <div class=\"answer\">\n        Re-rendering loops can occur when parent-level callbacks, such as <code>onLevelSwitch<\/code> or <code>onEnded<\/code>, are included in the dependency array of the <code>useEffect<\/code> that manages the HLS.js lifecycle.\n        <br><br>\n        If the parent recreates those inline functions on every render, the effect repeatedly tears down and reinitializes HLS.js. The solution is the callback ref pattern: Store the latest callback references in a React <code>useRef<\/code>, such as <code>onLevelSwitchRef.current = onLevelSwitch<\/code>.\n        <br><br>\n        The ref always contains the latest function without triggering the effect again, allowing the dependency array to remain stable, such as <code>[src, autoPlay]<\/code>.\n    <\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>Why does updating parent state inside a state updater function cause a React error, and how do you resolve it?<\/summary>\n    <div class=\"answer\">\n        React state updater functions, such as the callback inside <code>setScore(prev =&gt; ...)<\/code>, must remain pure and free of side effects. Calling a parent state setter, such as <code>onScoreUpdate?.(next)<\/code>, from inside another component&#8217;s state updater can schedule a new render before the current render finishes.\n        <br><br>\n        This can produce the error: <code>Cannot update a component while rendering a different component.<\/code>\n        <br><br>\n        To resolve it, separate the state update from the side effect. Calculate or track the next value using a local variable or <code>useRef<\/code>, update the component&#8217;s state, and invoke the parent callback separately rather than nesting it inside the updater function.\n    <\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>How do you optimize Largest Contentful Paint (LCP) for video elements using Cloudinary posters?<\/summary>\n    <div class=\"answer\">\n        To prevent a video element from negatively affecting LCP, display an optimized poster image during the initial page render. Cloudinary can dynamically extract a frame from the video using URL transformation parameters such as <code>so_2.0<\/code> and <code>pg_1<\/code>, which seek to two seconds and extract a frame.\n        <br><br>\n        The resulting poster can be delivered as a compressed JPG, WebP, or another optimized image format. When the poster URL is generated server-side, such as within a Next.js App Router Server Component, the image source is included in the initial HTML response.\n        <br><br>\n        Pairing the poster with an appropriate video preload strategy can improve time to first frame and overall Core Web Vitals performance.\n    <\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>What is the difference between lazy and eager transcoding in Cloudinary&#8217;s HLS generation?<\/summary>\n    <div class=\"answer\">\n        With lazy transcoding, the first request for the transformed <code>.m3u8<\/code> URL triggers the HLS generation process. The manifest may become available quickly, but the first playback request can experience a delay while the initial variants and segments are generated.\n        <br><br>\n        To avoid this first-request penalty in production, you can prewarm the HLS URL after upload or deployment by requesting it before users do. You can also configure eager transformations during upload so Cloudinary generates the HLS quality layers and manifest assets in advance.\n    <\/div>\n<\/details>\n\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Frequently Asked Questions Why does a progressive MP4 buffer on slow network connections compared to Adaptive HLS? A progressive MP4 is encoded as a single static file at a fixed bitrate. The browser must download the chunks sequentially and fast enough to maintain playback. If network throughput falls below this fixed bitrate, the video stalls [&hellip;]<\/p>\n","protected":false},"author":87,"featured_media":40350,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"_cloudinary_featured_overwrite":false,"footnotes":""},"categories":[1],"tags":[212,227,303],"class_list":["post-40312","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","tag-next-js","tag-performance-optimization","tag-video"],"acf":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO Premium plugin v25.6 (Yoast SEO v26.9) - https:\/\/yoast.com\/product\/yoast-seo-premium-wordpress\/ -->\n<title>Stream Video Like a Pro: Adaptive Bitrate HLS With Cloudinary &amp; Next.js<\/title>\n<meta name=\"description\" content=\"Stop buffering. Learn how to implement adaptive HLS streaming in Next.js using Cloudinary&#039;s simple sp_auto parameter. Build a robust React player, resolve deep dynamic hook re-renders, and optimize LCP.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Adaptive Bitrate HLS Delivery With Cloudinary and Next.js\" \/>\n<meta property=\"og:description\" content=\"Stop buffering. Learn how to implement adaptive HLS streaming in Next.js using Cloudinary&#039;s simple sp_auto parameter. Build a robust React player, resolve deep dynamic hook re-renders, and optimize LCP.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs\" \/>\n<meta property=\"og:site_name\" content=\"Cloudinary Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-08-11T14:00:00+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-08-11T18:03:16+00:00\" \/>\n<meta name=\"author\" content=\"melindapham\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:image\" content=\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\/\/schema.org\",\"@graph\":[{\"@type\":\"NewsArticle\",\"@id\":\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#article\",\"isPartOf\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs\"},\"author\":{\"name\":\"melindapham\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9\"},\"headline\":\"Adaptive Bitrate HLS Delivery With Cloudinary and Next.js\",\"datePublished\":\"2026-08-11T14:00:00+00:00\",\"dateModified\":\"2026-08-11T18:03:16+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs\"},\"wordCount\":738,\"publisher\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#primaryimage\"},\"thumbnailUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA\",\"keywords\":[\"Next.js\",\"Performance Optimization\",\"Video\"],\"inLanguage\":\"en-US\",\"copyrightYear\":\"2026\",\"copyrightHolder\":{\"@id\":\"https:\/\/cloudinary.com\/#organization\"}},{\"@type\":\"WebPage\",\"@id\":\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs\",\"url\":\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs\",\"name\":\"Stream Video Like a Pro: Adaptive Bitrate HLS With Cloudinary & Next.js\",\"isPartOf\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#primaryimage\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#primaryimage\"},\"thumbnailUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA\",\"datePublished\":\"2026-08-11T14:00:00+00:00\",\"dateModified\":\"2026-08-11T18:03:16+00:00\",\"description\":\"Stop buffering. Learn how to implement adaptive HLS streaming in Next.js using Cloudinary's simple sp_auto parameter. Build a robust React player, resolve deep dynamic hook re-renders, and optimize LCP.\",\"breadcrumb\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#primaryimage\",\"url\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA\",\"contentUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA\",\"width\":4000,\"height\":2200},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/cloudinary.com\/blog\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Adaptive Bitrate HLS Delivery With Cloudinary and Next.js\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#website\",\"url\":\"https:\/\/cloudinary.com\/blog\/\",\"name\":\"Cloudinary Blog\",\"description\":\"\",\"publisher\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\/\/cloudinary.com\/blog\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\",\"name\":\"Cloudinary Blog\",\"url\":\"https:\/\/cloudinary.com\/blog\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/\",\"url\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"contentUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"width\":312,\"height\":60,\"caption\":\"Cloudinary Blog\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/\"}},{\"@type\":\"Person\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9\",\"name\":\"melindapham\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/image\/\",\"url\":\"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"contentUrl\":\"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"caption\":\"melindapham\"}}]}<\/script>\n<!-- \/ Yoast SEO Premium plugin. -->","yoast_head_json":{"title":"Stream Video Like a Pro: Adaptive Bitrate HLS With Cloudinary & Next.js","description":"Stop buffering. Learn how to implement adaptive HLS streaming in Next.js using Cloudinary's simple sp_auto parameter. Build a robust React player, resolve deep dynamic hook re-renders, and optimize LCP.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs","og_locale":"en_US","og_type":"article","og_title":"Adaptive Bitrate HLS Delivery With Cloudinary and Next.js","og_description":"Stop buffering. Learn how to implement adaptive HLS streaming in Next.js using Cloudinary's simple sp_auto parameter. Build a robust React player, resolve deep dynamic hook re-renders, and optimize LCP.","og_url":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs","og_site_name":"Cloudinary Blog","article_published_time":"2026-08-11T14:00:00+00:00","article_modified_time":"2026-08-11T18:03:16+00:00","author":"melindapham","twitter_card":"summary_large_image","twitter_image":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA","schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"NewsArticle","@id":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#article","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs"},"author":{"name":"melindapham","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9"},"headline":"Adaptive Bitrate HLS Delivery With Cloudinary and Next.js","datePublished":"2026-08-11T14:00:00+00:00","dateModified":"2026-08-11T18:03:16+00:00","mainEntityOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs"},"wordCount":738,"publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA","keywords":["Next.js","Performance Optimization","Video"],"inLanguage":"en-US","copyrightYear":"2026","copyrightHolder":{"@id":"https:\/\/cloudinary.com\/#organization"}},{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs","url":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs","name":"Stream Video Like a Pro: Adaptive Bitrate HLS With Cloudinary & Next.js","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#primaryimage"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA","datePublished":"2026-08-11T14:00:00+00:00","dateModified":"2026-08-11T18:03:16+00:00","description":"Stop buffering. Learn how to implement adaptive HLS streaming in Next.js using Cloudinary's simple sp_auto parameter. Build a robust React player, resolve deep dynamic hook re-renders, and optimize LCP.","breadcrumb":{"@id":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#primaryimage","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA","width":4000,"height":2200},{"@type":"BreadcrumbList","@id":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/cloudinary.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Adaptive Bitrate HLS Delivery With Cloudinary and Next.js"}]},{"@type":"WebSite","@id":"https:\/\/cloudinary.com\/blog\/#website","url":"https:\/\/cloudinary.com\/blog\/","name":"Cloudinary Blog","description":"","publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/cloudinary.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/cloudinary.com\/blog\/#organization","name":"Cloudinary Blog","url":"https:\/\/cloudinary.com\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","width":312,"height":60,"caption":"Cloudinary Blog"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/"}},{"@type":"Person","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9","name":"melindapham","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/image\/","url":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","caption":"melindapham"}}]}},"parsely":{"version":"1.1.0","canonical_url":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs","smart_links":{"inbound":0,"outbound":0},"traffic_boost_suggestions_count":0,"meta":{"@context":"https:\/\/schema.org","@type":"NewsArticle","headline":"Adaptive Bitrate HLS Delivery With Cloudinary and Next.js","url":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs","mainEntityOfPage":{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA&w=150&h=150&crop=1","image":{"@type":"ImageObject","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA"},"articleSection":"Uncategorized","author":[{"@type":"Person","name":"melindapham"}],"creator":["melindapham"],"publisher":{"@type":"Organization","name":"Cloudinary Blog","logo":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA"},"keywords":["next.js","performance optimization","video"],"dateCreated":"2026-08-11T14:00:00Z","datePublished":"2026-08-11T14:00:00Z","dateModified":"2026-08-11T18:03:16Z"},"rendered":"<meta name=\"parsely-title\" content=\"Adaptive Bitrate HLS Delivery With Cloudinary and Next.js\" \/>\n<meta name=\"parsely-link\" content=\"https:\/\/cloudinary.com\/blog\/stream-video-like-a-pro-hls-cloudinary-nextjs\" \/>\n<meta name=\"parsely-type\" content=\"post\" \/>\n<meta name=\"parsely-image-url\" content=\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA&w=150&amp;h=150&amp;crop=1\" \/>\n<meta name=\"parsely-pub-date\" content=\"2026-08-11T14:00:00Z\" \/>\n<meta name=\"parsely-section\" content=\"Uncategorized\" \/>\n<meta name=\"parsely-tags\" content=\"next.js,performance optimization,video\" \/>\n<meta name=\"parsely-author\" content=\"melindapham\" \/>","tracker_url":"https:\/\/cdn.parsely.com\/keys\/cloudinary.com\/p.js"},"jetpack_featured_media_url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1786401588\/Web_Assets\/blog\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_\/Blog_Adaptive_Bitrate_HLS_Delivery_with_Cloudinary_and_Next.js_.jpg?_i=AA","_links":{"self":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40312","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/users\/87"}],"replies":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/comments?post=40312"}],"version-history":[{"count":4,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40312\/revisions"}],"predecessor-version":[{"id":40352,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40312\/revisions\/40352"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media\/40350"}],"wp:attachment":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media?parent=40312"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/categories?post=40312"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/tags?post=40312"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}