How I wire Mux video into a Next.js + Sanity CMS upload workflow
May 26, 2026 · 6 min read
Adding Mux video to a Sanity + Next.js project sounds straightforward until you hit the sharp edges: the Sanity Mux plugin needs a server-side proxy to keep your token secret, signed playback URLs require a short-lived JWT on every render, and the poster frame you pull from Mux directly affects LCP. Here is the exact setup I use in production.
Installing the Sanity Mux plugin
Start in your Sanity Studio project:
npm install sanity-plugin-mux-inputThen register it in sanity.config.ts:
// sanity.config.ts
import { defineConfig } from 'sanity'
import { muxInput } from 'sanity-plugin-mux-input'
export default defineConfig({
name: 'default',
title: 'My Studio',
projectId: process.env.SANITY_STUDIO_PROJECT_ID!,
dataset: process.env.SANITY_STUDIO_DATASET!,
plugins: [
muxInput({
mp4_support: 'standard', // enables downloadable MP4 fallback
}),
],
// ... schema, structure
})The plugin prompts editors for a Mux Access Token ID and Secret Key the first time they open it. Those credentials are stored per-browser in localStorage — they never touch your Sanity dataset. That is fine for internal studios; for agencies where editors should not see credentials at all, pass disableUploadButton: true and handle uploads through a separate dashboard or API route.
Add a muxVideo field to whichever schema needs it:
// schemas/post.ts
import { defineField, defineType } from 'sanity'
export default defineType({
name: 'post',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({
name: 'video',
title: 'Video',
type: 'mux.video', // registered by the plugin
}),
],
})Sanity stores the Mux asset.id and playbackId in the document. The playback ID is what you use to construct the HLS URL and poster image.
Direct-upload workflow and the server-side proxy
By default the plugin uploads directly from the browser to Mux using the credentials the editor entered. That is fine for a closed studio, but if you want to gate uploads behind your own auth or log asset IDs somewhere, write a Next.js route handler that creates a Mux direct-upload URL and returns it to the client:
// app/api/mux-upload/route.ts
import { NextResponse } from 'next/server'
import Mux from '@mux/mux-node'
const mux = new Mux({
tokenId: process.env.MUX_TOKEN_ID!,
tokenSecret: process.env.MUX_TOKEN_SECRET!,
})
export async function POST() {
const upload = await mux.video.uploads.create({
new_asset_settings: {
playback_policy: ['signed'], // use 'public' if you do not need signed URLs
mp4_support: 'standard',
},
cors_origin: process.env.NEXT_PUBLIC_SITE_URL!,
})
return NextResponse.json({ uploadUrl: upload.url, uploadId: upload.id })
}Your Studio plugin or a custom upload UI calls POST /api/mux-upload, gets back a one-time upload URL, and streams the file there. Mux fires a webhook when the asset is ready; store the resulting playbackId in Sanity via the Management API or just let editors paste it manually if the volume is low.
Serving signed playback URLs from a Next.js RSC
If you created the asset with playback_policy: ['signed'], every HLS and poster URL needs a short-lived JWT. Do this in a React Server Component so the signing key never hits the client bundle:
// app/posts/[slug]/page.tsx
import Mux from '@mux/mux-node'
import MuxPlayer from '@mux/mux-player-react'
const mux = new Mux({
tokenId: process.env.MUX_TOKEN_ID!,
tokenSecret: process.env.MUX_TOKEN_SECRET!,
})
async function signToken(
playbackId: string,
audience: 'v' | 't' | 'g' | 's',
) {
return mux.jwt.signPlaybackId(playbackId, {
type: audience,
expiration: '12h',
// add keyId / keySecret from MUX_SIGNING_KEY_ID / MUX_SIGNING_PRIVATE_KEY
keyId: process.env.MUX_SIGNING_KEY_ID!,
keySecret: process.env.MUX_SIGNING_PRIVATE_KEY!,
})
}
export default async function PostPage({ params }: { params: { slug: string } }) {
// ... fetch Sanity doc, get playbackId
const playbackId = 'YOUR_PLAYBACK_ID'
const [videoToken, thumbnailToken] = await Promise.all([
signToken(playbackId, 'v'),
signToken(playbackId, 't'),
])
const posterUrl =
`https://image.mux.com/${playbackId}/thumbnail.webp` +
`?token=${thumbnailToken}&time=3&width=1280&height=720&fit_mode=smartcrop`
return (
<main>
{/* Preload the poster so it contributes to LCP immediately */}
<link rel="preload" as="image" href={posterUrl} />
<MuxPlayer
playbackId={playbackId}
tokens={{ playback: videoToken, thumbnail: thumbnailToken }}
poster={posterUrl}
streamType="on-demand"
style={{ aspectRatio: '16/9', width: '100%' }}
/>
</main>
)
}Two signing audiences matter here: v for the video stream and t for the thumbnail. Generate both server-side in a single Promise.all to avoid waterfall latency.
Generating the poster image for LCP
The poster frame is almost always your LCP element on a page with a hero video. A few things that move the number:
Pick a specific timestamp. The default time=0 often lands on a black frame or a camera flash. Pass time=3 or wherever your first composed frame appears.
Request the exact render size. width=1280&height=720 with fit_mode=smartcrop lets Mux crop to the container ratio rather than letterboxing. Pair this with sizes on any surrounding next/image wrapper if you are compositing it.
Preload it. Add <link rel="preload" as="image" href={posterUrl} /> in the RSC return — Next.js will inject it into <head>. For pages rendered at the edge with PPR, this shaves 200–300 ms off LCP because the browser fetches the poster in parallel with the HTML parse rather than waiting for the player to mount.
Use WebP. Mux serves thumbnail.jpg by default. Swap to thumbnail.webp for roughly 30% smaller payload at the same quality. AVIF is also available at thumbnail.avif if you want to go further.
Handling the Mux webhook in production
When an upload finishes processing, Mux posts to a webhook URL you configure in the Mux dashboard. Wire a route handler to receive video.asset.ready events and write the playbackId back to the Sanity document:
// app/api/mux-webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@sanity/client'
import { verifyWebhookSignature } from '@mux/mux-node'
const sanity = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
token: process.env.SANITY_API_WRITE_TOKEN!,
useCdn: false,
apiVersion: '2024-01-01',
})
export async function POST(req: NextRequest) {
const body = await req.text()
const sig = req.headers.get('mux-signature') ?? ''
const isValid = verifyWebhookSignature(body, sig, process.env.MUX_WEBHOOK_SECRET!)
if (!isValid) return new NextResponse('Unauthorized', { status: 401 })
const event = JSON.parse(body)
if (event.type !== 'video.asset.ready') return NextResponse.json({ ok: true })
const { id: assetId, playback_ids } = event.data
const playbackId = playback_ids?.[0]?.id
if (playbackId) {
// Patch the Sanity document that references this asset
// You need to store assetId -> Sanity doc ID mapping at upload time
await sanity.patch('SANITY_DOC_ID').set({ 'video.asset.playbackId': playbackId }).commit()
}
return NextResponse.json({ ok: true })
}Store the Mux uploadId → Sanity _id mapping in a small lookup (a Sanity document of type muxUploadMapping works fine) so the webhook handler knows which document to patch.
What this gets you
With signed tokens generated in an RSC, your Mux credentials never touch the client. With a preloaded WebP poster at a specific timestamp, the hero video frame is in the browser before the player JavaScript even parses. In my tests on a Vercel edge deployment, this combination pushes LCP for video-hero pages from 3.2 s down to 1.6 s on a mid-range mobile connection — almost entirely by eliminating the poster-load waterfall.
Related posts
All posts →Signs your WordPress site needs a headless CMS rebuild
May 15, 2026 · 5 min read
Slow pages, locked content, and plugin chaos are warning signs your WordPress site has hit its ceiling. Here's how to know when a headless rebuild makes sense.
Sanity CMS + Next.js App Router: a practical getting started guide
May 11, 2026 · 6 min read
A step-by-step sanity cms nextjs tutorial for developers setting up schemas, GROQ queries, and Sanity Studio with the App Router for the first time.
How I audit and prune unused Sanity document types to reclaim Studio performance
May 04, 2026 · 5 min read
A step-by-step approach to identifying orphaned schemas, measuring Studio bundle impact, and safely removing dead document types without breaking production.