DEV Community

Cover image for How I stream Mux video from Sanity to Next.js with signed HLS playback
Nayan Kyada
Nayan Kyada

Posted on • Originally published at nayankyada.com

How I stream Mux video from Sanity to Next.js with signed HLS playback

Sanity Mux video playback in Next.js is where most tutorials stop short. The upload side — pushing a file to Mux via Sanity Studio — is well documented. What I rarely see covered is the read path: pulling a stored Mux asset reference out of Sanity, generating a signed playback URL, initialising hls.js in a React component, and making sure the poster frame does not wreck your LCP score. This post covers exactly that.

What Sanity stores after a Mux upload

The sanity-plugin-mux-input plugin writes a mux.video asset object into your document. After a successful upload, your document will contain something like:

// GROQ — fetch just the fields the playback component needs
*[_type == "post" && slug.current == $slug][0] {
  title,
  "video": video.asset-> {
    playbackId,
    data {
      duration,
      aspect_ratio
    },
    "signingKeyId": @.data.signing_key_id
  }
}
Enter fullscreen mode Exit fullscreen mode

playbackId is the string you pass to https://stream.mux.com/{playbackId}.m3u8. If the asset was created with a signed playback policy (you toggled "Enable signed URLs" in the plugin settings or set mp4_support: "none" with playback_policy: ["signed"] via the Mux API), you cannot use that URL directly — Mux will return a 401. You need a short-lived JWT.

Sanity stores playbackId but not the signing key secret. That secret lives in an environment variable and never touches your CMS.

Generating a signed playback URL in a Route Handler

I generate tokens server-side in a Next.js Route Handler so the signing secret is never exposed to the browser.

// app/api/mux-token/route.ts
import { NextRequest, NextResponse } from "next/server";
import { SignJWT } from "jose";

const KEY_ID = process.env.MUX_SIGNING_KEY_ID!;
// Base64-encoded private key from Mux dashboard
const KEY_SECRET = Buffer.from(
  process.env.MUX_SIGNING_KEY_PRIVATE_KEY_BASE64!,
  "base64"
);

export async function GET(req: NextRequest) {
  const playbackId = req.nextUrl.searchParams.get("playbackId");
  if (!playbackId) {
    return NextResponse.json({ error: "missing playbackId" }, { status: 400 });
  }

  // Mux signed tokens use RS256 with a PKCS#8 private key
  const privateKey = await crypto.subtle.importKey(
    "pkcs8",
    KEY_SECRET,
    { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
    false,
    ["sign"]
  );

  const token = await new SignJWT({})
    .setProtectedHeader({ alg: "RS256", kid: KEY_ID })
    .setAudience("v")
    .setSubject(playbackId)
    .setExpirationTime("4h")
    .sign(privateKey);

  // Poster token needs a separate audience: "t" for thumbnail
  const thumbToken = await new SignJWT({})
    .setProtectedHeader({ alg: "RS256", kid: KEY_ID })
    .setAudience("t")
    .setSubject(playbackId)
    .setExpirationTime("4h")
    .sign(privateKey);

  return NextResponse.json(
    { token, thumbToken },
    {
      headers: {
        // Cache at the edge for 3 h; leave 1 h buffer before expiry
        "Cache-Control": "public, max-age=10800, s-maxage=10800",
      },
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

A few notes: jose runs in the Edge runtime without a Node.js polyfill. The audience field ("v" for video, "t" for thumbnails, "s" for storyboards) is required by Mux — getting it wrong is the most common 401 I see from developers copying JWT examples from unrelated docs.

The playback component with hls.js and LCP-safe poster

This is a client component. It fetches the tokens on mount, then initialises hls.js only when the browser cannot play HLS natively (Safari can; Chrome cannot).

// components/mux-player.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import Hls from "hls.js";

interface MuxPlayerProps {
  playbackId: string;
  aspectRatio?: string; // e.g. "16:9"
  title: string;
}

export function MuxPlayer({
  playbackId,
  aspectRatio = "16:9",
  title,
}: MuxPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [tokens, setTokens] = useState<{
    token: string;
    thumbToken: string;
  } | null>(null);

  // Convert "16:9" to a padding-bottom percentage for CLS-safe aspect ratio
  const [w, h] = aspectRatio.split(":").map(Number);
  const paddingBottom = `${((h / w) * 100).toFixed(4)}%`;

  useEffect(() => {
    fetch(`/api/mux-token?playbackId=${playbackId}`)
      .then((r) => r.json())
      .then(setTokens);
  }, [playbackId]);

  useEffect(() => {
    if (!tokens || !videoRef.current) return;

    const src = `https://stream.mux.com/${playbackId}.m3u8?token=${tokens.token}`;
    const video = videoRef.current;

    if (video.canPlayType("application/vnd.apple.mpegurl")) {
      // Safari native HLS
      video.src = src;
    } else if (Hls.isSupported()) {
      const hls = new Hls({
        // Start with lowest quality on mobile connections
        startLevel: -1,
        // Reduce initial buffer to get first frame faster
        maxBufferLength: 30,
      });
      hls.loadSource(src);
      hls.attachMedia(video);
      return () => hls.destroy();
    }
  }, [tokens, playbackId]);

  const posterSrc = tokens
    ? `https://image.mux.com/${playbackId}/thumbnail.webp?token=${tokens.thumbToken}&time=0&width=1280`
    : undefined;

  return (
    // Padding-bottom trick keeps the aspect ratio slot reserved before video loads
    // — eliminates CLS when the poster image has not yet arrived
    <div
      style={{ position: "relative", paddingBottom, height: 0, overflow: "hidden" }}
    >
      <video
        ref={videoRef}
        poster={posterSrc}
        controls
        playsInline
        aria-label={title}
        style={{
          position: "absolute",
          inset: 0,
          width: "100%",
          height: "100%",
        }}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

LCP: the poster image is your real target

If a video is above the fold, the browser picks the poster frame as the LCP candidate. Two things hurt LCP here.

First, the poster URL is only available after the client fetches the token, which means it is blank on first render. Fix: on the server-side page component, fetch the tokens once during SSR and pass posterSrc as a prop so the <video poster> attribute is present in the initial HTML. You can do this with fetch inside a React Server Component, writing the result to a short-lived cache entry, then passing the resolved poster URL as a prop to the client component. The token fetch is already cached for 3 hours at the edge so the RSC does not add latency per request.

Second, the image itself — Mux thumbnail URLs return WebP when you append &width=1280. Use <link rel="preload" as="image"> in your page <head> via Next.js metadata's other field, or via generateMetadata, to push the poster to the browser before it parses the video element:

// app/posts/[slug]/page.tsx (excerpt)
export async function generateMetadata({ params }: Props) {
  const { playbackId, thumbToken } = await getVideoTokens(params.slug);
  return {
    other: {
      // Preloads the poster so it is ready when the <video> element renders
      "link-preload-poster": `<link rel="preload" as="image" href="https://image.mux.com/${playbackId}/thumbnail.webp?token=${thumbToken}&width=1280">`,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

This is admittedly a rough edge in Next.js metadata — arbitrary <link> tags in <head> require the other map or a custom <Head> in a layout. The preload alone typically moves poster LCP from ~2.8 s to under 1 s on a fast 4G connection in my testing.

Aspect ratio and CLS

Mux stores aspect_ratio as a string like "16:9" in the asset data. Pull it out of your GROQ projection (shown above) and pass it straight to the component. The padding-bottom wrapper reserves the correct height before the video element loads, which eliminates layout shift. Do not skip this step — a video element with no intrinsic size is one of the most common CLS offenders I find in audits.

If aspect_ratio is missing (older assets, external ingest), fall back to "16:9" and add a Sanity validation rule that enforces the field is populated before the document can be published.

Top comments (0)