How I wire Mux video into a Next.js + Sanity CMS upload workflow

Jun 03, 2026 · 6 min read

Wiring a Mux video upload workflow into a Sanity + Next.js site sounds straightforward until you hit the details: signed URLs expiring mid-session, poster images that tank LCP, and the Mux plugin's schema colliding with your TypeGen setup. This post walks through the exact steps I use on production sites — plugin config, GROQ projection, signed URL generation in a route handler, and pulling a Mux thumbnail as a next/image source to protect LCP.

Why Mux instead of storing video in Sanity assets

Sanity's asset pipeline is built for images and files, not adaptive bitrate video. Uploading a 200 MB .mp4 directly bloats your dataset, gives you zero transcoding, and means the browser downloads the original rather than a codec-optimised stream. Mux solves all three: it transcodes on ingest, serves HLS via its own CDN, and gives you a stable asset ID you can store in Sanity as a reference object. The Sanity Mux plugin (sanity-plugin-mux-input) wraps this into a custom input component so editors upload from Studio without touching the Mux dashboard.

Installing and configuring the Mux plugin

Install the plugin and Mux's Node SDK:

npm install sanity-plugin-mux-input @mux/mux-node

Then register it in your Sanity config. The plugin needs your Mux token ID and secret — store them in Sanity's secret management so they never touch the client bundle:

// sanity.config.ts
import { defineConfig } from 'sanity'
import { muxInput } from 'sanity-plugin-mux-input'
 
export default defineConfig({
  name: 'default',
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  plugins: [
    muxInput({
      mp4_support: 'standard', // enables static MP4 rendition for fallback
    }),
  ],
  schema: { types: [] },
})

The plugin reads your Mux credentials from Sanity's secrets document (it prompts on first load inside Studio). That keeps the token out of process.env on the client side.

Add a mux.video typed field to the schema for any document that needs video:

// schemas/post.ts
import { defineField, defineType } from 'sanity'
 
export const post = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({ name: 'title', type: 'string' }),
    defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
    defineField({
      name: 'video',
      title: 'Video',
      type: 'mux.video',
    }),
  ],
})

When an editor uploads a file, the plugin posts it to Mux via a direct upload URL (no bytes go through Sanity's servers), then writes the resulting playbackId and assetId back into the document. That stored object is what you query in GROQ.

GROQ projection for Mux video data

The mux.video type stores a nested object. Project only what you need:

// queries/post.groq (used by sanity-typegen)
*[_type == "post" && slug.current == $slug][0] {
  title,
  "video": video.asset->{
    playbackId,
    assetId,
    "status": data.status,
    "duration": data.duration,
    "aspectRatio": data.aspect_ratio
  }
}

The -> dereference follows the reference Sanity stores to the mux.videoAsset document. data.status tells you whether Mux has finished transcoding — useful for showing a placeholder in the UI before the asset is ready.

Serving signed playback URLs from a route handler

For public marketing pages you can use unsigned playback. For gated content — course lessons, client deliverables — you need signed URLs. Generate them server-side in a Next.js route handler so the signing key never reaches the browser:

// app/api/video-token/route.ts
import { NextRequest, 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 GET(req: NextRequest) {
  const playbackId = req.nextUrl.searchParams.get('playbackId')
  if (!playbackId) {
    return NextResponse.json({ error: 'Missing playbackId' }, { status: 400 })
  }
 
  const token = await mux.jwt.signPlaybackId(playbackId, {
    type: 'video',
    expiration: '1h',
    // Add your own auth check here before signing
  })
 
  // Cache the token at the edge — it's valid for 1 h
  return NextResponse.json(
    { token },
    {
      headers: {
         'Cache-Control': 'private, max-age=3300', // slightly under 1 h
      },
    }
  )
}

The client fetches this token, then appends ?token=<jwt> to the Mux stream URL passed to @mux/mux-player-react. Keep the signing key in server-only env — prefix it without NEXT_PUBLIC_.

Generating a poster image for LCP

Mux exposes a thumbnail endpoint at https://image.mux.com/{playbackId}/thumbnail.webp. You can pass time=4 to grab a frame at 4 seconds instead of frame 0 (which is often a black frame on cuts). Feed that URL directly to next/image with priority for above-the-fold video heroes:

// components/VideoHero.tsx
import Image from 'next/image'
 
type Props = {
  playbackId: string
  aspectRatio: string // e.g. "16:9"
  title: string
}
 
export function VideoHero({ playbackId, aspectRatio, title }: Props) {
  const [w, h] = aspectRatio.split(':').map(Number)
  const posterUrl = `https://image.mux.com/${playbackId}/thumbnail.webp?time=4&width=1280`
 
  return (
    <div style={{ aspectRatio: `${w}/${h}` }} className="relative w-full overflow-hidden">
      <Image
        src={posterUrl}
        alt={title}
        fill
        sizes="100vw"
        priority
        className="object-cover"
      />
    </div>
  )
}

Using fill with a parent that has aspect-ratio set avoids the layout shift you get when the browser doesn't know the image dimensions upfront. Because aspectRatio comes straight from the Mux asset metadata in the GROQ query, you don't have to hardcode anything — CLS stays at zero. Pass priority when the poster is the largest contentful element; omit it for below-the-fold videos.

For the actual player, swap the Image out for @mux/mux-player-react once the user interacts, or lazy-load the player component behind a dynamic import so it doesn't inflate your main bundle:

import dynamic from 'next/dynamic'
const MuxPlayer = dynamic(() => import('@mux/mux-player-react'), { ssr: false })

This keeps the initial JS payload small — the Mux player is around 50 kB gzipped — while still delivering the poster immediately via SSR.

Handling upload status in the editor

One workflow detail that trips up editors: Mux transcoding takes 10–60 seconds after upload. During that window the status field is preparing, not ready, and the playback URL 404s. I add a document-level validation rule that prevents publishing if video.asset.data.status !== 'ready':

defineField({
  name: 'video',
  type: 'mux.video',
  validation: (Rule) =>
    Rule.custom(async (value, context) => {
      if (!value?.asset?._ref) return true // optional field
      const asset = await context.getClient({ apiVersion: '2024-01-01' })
        .fetch(`*[_id == $id][0]{ "status": data.status }`, {
          id: value.asset._ref,
        })
      return asset?.status === 'ready' ? true : 'Video is still transcoding. Wait for "ready" before publishing.'
    }),
})

Editors see an inline warning rather than publishing a broken page. Once Mux webhooks fire and the asset document updates, the Studio re-validates and clears the warning automatically.

Related posts

All posts →