How I stream Mux video from Sanity to Next.js with signed HLS playback
Jul 01, 2026 · 6 min read
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 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",
},
}
);
}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>
);
}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">`,
},
};
}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.