Sanity + Mux Signed HLS Video Playback in Next.js

By Nayan Kyada · · 6 min read

Part of The Sanity + Next.js Guide

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
  }
}

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 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 { createPrivateKey } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { SignJWT } from "jose";
 
const KEY_ID = process.env.MUX_SIGNING_KEY_ID!;
// Mux gives you the private key as a base64-encoded PEM file
const privateKey = createPrivateKey(
  Buffer.from(process.env.MUX_SIGNING_KEY_PRIVATE_KEY_BASE64!, "base64").toString("utf8")
);
 
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
  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",
      },
    }
  );
}

A few notes: this route uses node:crypto to parse the PEM key, so keep it on the default Node.js runtime. If you'd rather not hand-roll JWTs, Mux's Node SDK exposes mux.jwt.signPlaybackId(playbackId, { keyId, keySecret, type: 'video' }), which does the same thing. The audience field ("v" for video, "t" for thumbnails, "g" for GIFs, "s" for storyboards) is required by Mux — getting it wrong is a common cause of 401s when 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 and iOS can; most desktop Chrome and Firefox builds need hls.js).

// 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>
  );
}

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: generate the tokens in the server-side page component and pass posterSrc as a prop so the <video poster> attribute is present in the initial HTML. Don't fetch your own /api/mux-token route from a Server Component — move the signing logic into a shared server-only function (e.g. getVideoTokens()) and call it directly from both the route handler and the page.

Second, the image itself — Mux serves the thumbnail as WebP because the URL uses thumbnail.webp, and width=1280 keeps it a reasonable size. The other field in Next.js's generateMetadata renders <meta> tags, not arbitrary HTML, so it can't inject a <link rel="preload"> element. The App Router way to do this is simpler than it looks: render the <link> tag directly in your page component's JSX. Next.js automatically hoists any <link> or <meta> element rendered anywhere in the component tree up into <head>:

// app/posts/[slug]/page.tsx (excerpt)
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const { playbackId, thumbToken } = await getVideoTokens(slug);
  const posterSrc = `https://image.mux.com/${playbackId}/thumbnail.webp?token=${thumbToken}&width=1280`;
 
  return (
    <>
      {/* Rendered anywhere in the tree, Next.js hoists this into <head> */}
      <link rel="preload" as="image" href={posterSrc} />
      <article>
        <MuxPlayer playbackId={playbackId} title="…" />
      </article>
    </>
  );
}

Preloading lets the browser start fetching the poster before it reaches the <video> element, which usually improves poster LCP noticeably — measure it on your own page.

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 reserved size is a common CLS offender.

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.

Frequently asked questions

Why does my signed Mux playback URL return a 401?

The most common cause is a wrong JWT audience claim — Mux requires `"v"` for video streams, `"t"` for thumbnails, and `"s"` for storyboards, and mismatching them (a common copy-paste error from unrelated JWT examples) fails silently as a 401 rather than a clear error. Double-check the `setAudience()` call matches what you're actually requesting.

How do I preload a Mux poster image for LCP in the Next.js App Router?

Render a `<link rel="preload" as="image" href={posterSrc} />` element directly in your page component's JSX — Next.js automatically hoists any `<link>` or `<meta>` tag rendered anywhere in the component tree into `<head>`. `generateMetadata`'s `other` field can't do this since it only renders `<meta>` tags, not arbitrary HTML.