Payload Plugins
Plugins

payload-mux

Mux Video for Payload CMS. Upload, store, and play back video without leaving the admin.

For AI / LLMs: View Markdown

Based on @oversightstudio/mux-video (MIT), originally created by Idan Yekutiel. A port of that Mux plugin, restructured to our conventions and kept as a first-party package so we can track Payload and Mux updates directly. Full credit and thanks to the original author.

Video in Payload, done right. Drop a clip into the admin and it streams everywhere (adaptive HLS, posters, preview GIFs, public or signed), powered by Mux, the video platform the pros use. No media server to run, no huge files bloating your storage: Mux holds the video, Payload holds the data, and they stay in sync both ways. Upload, and it just plays.

pnpm add @pro-laico/payload-mux

What's included

Video hosting that lives inside Payload, powered by Mux:

  • Upload and it just plays: drop a clip in the admin and Mux encodes it to adaptive HLS with posters and preview GIFs — no media server, no huge files bloating your storage.
  • Direct-to-Mux uploads: the browser posts bytes straight to Mux, so video never touches your server or database.
  • Playback URLs computed on read: every video exposes virtual playbackUrl (HLS), posterUrl, and gifUrl from its stored playback id, JWT-signed automatically under a signed policy.
  • Kept in sync both ways: a webhook finishes slow-encoding videos and mirrors Mux-dashboard deletes back into Payload, so the two never drift.
  • Server ingest + declarative seeding: create a video from a file or URL in code, or seed one like any other doc through @pro-laico/payload-seed — both wait for the asset and write full metadata, no webhook required.

Quickstart

Set Mux credentials

The plugin and the Mux SDK read the standard MUX_* environment variables automatically, so the plugin call itself takes no arguments. Signed playback needs the two extra signing keys.

MUX_TOKEN_ID=...
MUX_TOKEN_SECRET=...
MUX_WEBHOOK_SECRET=...        # signed playback also: MUX_SIGNING_KEY, MUX_PRIVATE_KEY

Add the plugin

Zero-config reads the env vars above and defaults uploads' cors_origin to NEXT_PUBLIC_SERVER_URL. Pass options only to override: a non-standard env var name, a custom CORS origin, or signed playback.

import { buildConfig } from 'payload'
import { muxVideoPlugin } from '@pro-laico/payload-mux'

export default buildConfig({
  plugins: [muxVideoPlugin()], // credentials from MUX_*, cors_origin from NEXT_PUBLIC_SERVER_URL
})
// also set MUX_SIGNING_KEY + MUX_PRIVATE_KEY in the env
muxVideoPlugin({
  playbackPolicy: 'signed',               // new videos upload as signed
  signedUrlOptions: { expiration: '1d' }, // signed URL lifetime
})

This one setting governs every new video — admin uploads and server-side ingest / seeding alike. The virtual playbackUrl / posterUrl / gifUrl are then JWT-signed on every read.

Point a Mux webhook at the endpoint

In the Mux dashboard, send webhooks to /api/mux/webhook (or <your routes.api>/mux/webhook if you've customized Payload's API route). It needs a publicly reachable URL; see Webhook.

Generate the admin import map

The collection registers admin components (the uploader field and list-view thumbnail cell) by string path, so regenerate the import map:

pnpm payload generate:importmap

Plugin options

muxVideoPlugin(options?) is the single entry point. One call registers the mux-video collection, the upload + webhook endpoints, and the admin uploader. It's zero-config: every option falls back to a MUX_* env var or a sensible default.

Pass options to customize. The Reference tab is the interactive view (initSettings / uploadSettings expand to their nested fields); TypeScript is the same shape in code, every option at its default.

enabledbooleandefault true

When false, the plugin registers nothing: no collection, endpoints, or hooks.

initSettingsMuxVideoInitSettingsdefault MUX_* env vars

Mux credentials and secrets. Every field is optional; an omitted field is read from its standard MUX_* env var by the SDK. Pass a field only to override it (e.g. a non-standard env var name).

tokenIdstringdefault process.env.MUX_TOKEN_ID

Mux API token ID.

tokenSecretstringdefault process.env.MUX_TOKEN_SECRET

Mux API token secret.

webhookSecretstringdefault process.env.MUX_WEBHOOK_SECRET

Secret used to verify incoming Mux webhooks.

jwtSigningKeystringdefault process.env.MUX_SIGNING_KEY

JWT signing key ID, for signed playback.

jwtPrivateKeystringdefault process.env.MUX_PRIVATE_KEY

JWT private key, for signed playback.

uploadSettingsMuxVideoUploadSettings

Settings applied to every upload.

cors_originstringdefault NEXT_PUBLIC_SERVER_URL, then *

CORS origin for the direct-upload URL, usually your site URL.

new_asset_settingsMuxVideoNewAssetSettings

Extra new_asset_settings forwarded to Mux when the asset is created. Includes playback_policy; the top-level playbackPolicy option is the shorthand, and an explicit value here wins.

playbackPolicy'public' | 'signed'default 'public'

Playback policy for newly uploaded videos. Shorthand for uploadSettings.new_asset_settings.playback_policy; set 'signed' (plus MUX_SIGNING_KEY / MUX_PRIVATE_KEY) for JWT-signed URLs.

extendCollectionkeyof TypedCollection

Slug of an existing collection to extend with the Mux fields, hooks, and uploader instead of creating the mux-video collection.

access(req) => boolean | Promise<boolean>default logged-in admin

Gate who may request an upload and read videos. Create / update / delete fall back to Payload's collection access. Defaults to logged-in admins.

signedUrlOptions{ expiration?: string }default { expiration: '1d' }

Lifetime of the JWT-signed playback / poster / gif URLs under a signed policy.

posterExtension'webp' | 'jpg' | 'png'default 'png'

Image format for the poster (posterUrl).

animatedGifExtension'gif' | 'webp'default 'gif'

Format for the animated preview (gifUrl).

adminThumbnail'gif' | 'image' | 'none'default 'gif'

List-view thumbnail style: animated gif, static image, or none.

autoCreateOnWebhookbooleandefault false

When true, the webhook backfills a Payload doc for an asset uploaded directly in Mux (on created / ready / updated for an asset Payload doesn't have yet).

import { muxVideoPlugin } from '@pro-laico/payload-mux'

// Every option at its default — this is the zero-config behaviour, written out.
muxVideoPlugin({
  enabled: true,
  // initSettings: { /* … */ },   // optional, no default — all fields fall back to MUX_* env vars
  // uploadSettings: { /* … */ }, // optional — cors_origin defaults to NEXT_PUBLIC_SERVER_URL
  // extendCollection: 'media',   // optional, no default — extend an existing collection instead of creating `mux-video`
  // access: (req) => Boolean(req.user), // default — logged-in admin
  // playbackPolicy: 'signed',   // optional — default 'public'; 'signed' issues JWT-signed URLs
  signedUrlOptions: { expiration: '1d' },
  posterExtension: 'png',
  animatedGifExtension: 'gif',
  adminThumbnail: 'gif',
  autoCreateOnWebhook: false,
})

Collections

The plugin registers one collection. With extendCollection, it instead folds the same fields, hooks, and uploader into a collection you already have.

mux-video

The Videos collection (under the Assets admin group, alongside the other Pro Laico asset collections), and the only one you touch. You set title and an optional posterTimestamp; Mux fills in everything else (asset id, duration, dimensions, playback ids) through the upload poll and the webhook. Read is gated by the access option (logged-in admin by default). Pass a transient source to create one server-side; see Server-side ingest.

A mux-video doc once the asset is ready: the inline player on the Mux playback URL, with the read-only status in the sidebarA mux-video doc once the asset is ready: the inline player on the Mux playback URL, with the read-only status in the sidebar

Fields

Prop

Type

Hooks

Prop

Type

The resolved options are stashed at config.custom.payloadMux.options, so external tooling can build a Mux client from the configured credentials given just payload, read by string key with no import, so other packages stay decoupled.

Use a video

Relate to mux-video from any collection, then play it back however you like. playbackId and the virtual playbackUrl (an HLS .m3u8) work with any Mux frontend or a custom HLS player. The example uses @mux/mux-player-react (pnpm add @mux/mux-player-react if you go that route):

import config from '@payload-config'
import MuxPlayer from '@mux/mux-player-react'
import { getPayload } from 'payload'

async function Page() {
  const payload = await getPayload({ config })
  const video = await payload.findByID({ collection: 'mux-video', id: 'example' })

  return (
    <MuxPlayer
      playbackId={video.playbackOptions![0].playbackId!} // by playback id
      src={video.playbackOptions![0].playbackUrl!}        // …or the HLS URL
      poster={video.playbackOptions![0].posterUrl!}
    />
  )
}

export default Page

The URLs compute on read from the stored playback id, so already-populated videos need no extra calls, and under a signed policy each one arrives JWT-signed with the signedUrlOptions lifetime.

Under a signed policy each read signs all three URLs (playbackUrl / posterUrl / gifUrl), and JWT signing isn't free. If you only render one (e.g. just the player), select it (alongside the playbackId / playbackPolicy it's built from) so the other two aren't signed:

payload.find({
  collection: 'mux-video',
  select: { playbackOptions: { playbackId: true, playbackPolicy: true, playbackUrl: true } },
})

Public playback does no signing, so this doesn't apply there.

Endpoints

The plugin registers three endpoints under Payload's API route (the upload pair is gated by access):

  • POST /api/mux/upload: mint a Mux direct-upload (the admin uploader posts files to it).
  • GET /api/mux/upload?id=…: read a direct-upload, to pick up its asset_id.
  • POST /api/mux/webhook: verify and apply Mux events, setting metadata on ready/updated, deleting on deleted, logging on errored, and (with autoCreateOnWebhook) backfilling assets uploaded in Mux.

Webhook

On upload, the beforeChange hook polls the new asset for ~6 seconds and fills in its playback metadata if Mux finishes encoding in that window. Anything slower is saved with just its assetId and left for the webhook.

  • Metadata for videos that take longer than ~6s to encode. The common case. Without the webhook they're saved without playbackOptions, so they never become playable (the poll gave up, and re-saving won't re-fetch).
  • Mux → Payload delete sync. Deleting an asset in the Mux dashboard removes the Payload doc only via the video.asset.deleted event.
  • autoCreateOnWebhook backfill. Importing assets you uploaded directly in Mux.
  • The upload itself, and metadata for short videos (set synchronously by the 6s poll).
  • Payload → Mux delete. Handled by the collection's afterDelete hook, not the webhook.
  • Server-side ingest / seeding. The 6s limit doesn't apply; ingest waits for ready and writes full metadata into the doc, so these play straight away whatever their length.
  • Playback of already-populated videos: the URLs compute on read from the stored playback id.

The webhook needs a publicly reachable URL. Mux pushes events to your endpoint, so its servers must be able to POST to it; the SDK only verifies the signature on receipt, and it can't make Mux reach localhost. In production, set MUX_WEBHOOK_SECRET and point the dashboard at https://your-site/api/mux/webhook. For local dev, expose localhost with a tunnel (cloudflared tunnel --url http://localhost:3000 or ngrok) and set that URL as the webhook.

Server-side ingest

Besides the browser uploader, a mux-video can be created server-side from a local file or URL, handy for imports, migrations, and seeding. Pass a source; the beforeValidate hook uploads it to Mux, waits for the asset to be ready, fills in the metadata, and discards source:

import { ingestMuxVideo } from '@pro-laico/payload-mux'

await ingestMuxVideo(payload, { source: '/path/to/intro.mp4', title: 'Intro' })
// or a URL: ingestMuxVideo(payload, { source: 'https://example.com/intro.mp4', title: 'Intro' })
// playback policy comes from the plugin's uploadSettings; pass { playbackPolicy: 'signed' } to override one video

Local paths stream from disk, so ingesting a large file doesn't load it all into memory. Ingest is also what powers declarative seeding.

How it works

The upload flow, virtual URLs, and webhook fit together like this.

The asset lifecycle

When you upload, the admin field posts the bytes straight to Mux and the doc saves with just its assetId — the video never passes through your server. The beforeChange hook then polls the new asset for ~6 seconds: a fast encode comes back with full playback metadata inline, and anything slower is left for the webhook, which fills that metadata in once Mux finishes (and mirrors dashboard deletes back into Payload). The playbackUrl / posterUrl / gifUrl fields aren't stored — they compute from the playback id on every read, signed on the fly under a signed policy. Server-side ingest and seeding skip the 6s window entirely: they wait for the asset to reach ready and write full metadata into the doc, so those videos play straight away whatever their length.

Revalidation

The plugin keeps Payload and Mux in sync but doesn't touch Next.js's cache, and some writes (the video.asset.ready / deleted webhook especially) land outside the request that rendered your page. Handle revalidation yourself with a Payload afterChange / afterDelete hook on mux-video: it fires for webhook writes, admin saves, and server-side ingest alike, so it's the one place to call revalidateTag / revalidatePath for the pages that embed videos.

Seeding

mux-video isn't a plain upload collection — its bytes live at Mux — so it seeds through @pro-laico/payload-seed's custom ingestion: muxVideoPlugin() marks the collection with custom: { seedAsset: { sourceField: 'source' } }, the seed engine auto-discovers that marker from the live config, and a video seeds like any other doc. Each record carries its clip on the _file meta-key via the file() token, and other docs point at it with ref():

import { muxVideoPlugin } from '@pro-laico/payload-mux'
import { defineSeed, seedPlugin } from '@pro-laico/payload-seed'

// src/seed/videos.ts — each doc carries its clip on `_file`
const videos = defineSeed('mux-video', ({ file }) => [
  {
    _key: 'intro',
    _file: file('intro.mp4'), // assets/mux-video/intro.mp4
    title: 'Intro',
  },
])

// src/seed/pages.ts — reference a seeded video by ref
const pages = defineSeed('pages', ({ ref }) => [
  {
    _key: 'home',
    title: 'Home',
    heroVideo: ref('mux-video', 'intro'),
  },
])

plugins: [muxVideoPlugin(), seedPlugin({ definitions: [videos, pages] })]

Under the hood the engine writes each _file to the collection's source field and the server-side ingest hook does the upload — the seed package never imports this one, nor the Mux SDK, so the two stay decoupled. Ingest waits for each asset to reach ready and writes its full metadata, so seeded videos play straight away, no webhook required. Playback policy comes from the plugin's configured policy, so seeded and admin-uploaded videos share one. See the seed plugin docs for the full reference.

Without credentials, seeding skips — it doesn't fail. When MUX_TOKEN_ID / MUX_TOKEN_SECRET aren't set, the plugin also marks the collection custom: { seedDisabled: '…' }, so the seed engine skips its definition with a warning and drops any optional ref('mux-video', …) — the rest of the seed runs offline. Set the env vars and the next run ingests the clips and wires the refs, with no seed-file changes. See Disabled seeds.

Gotchas

A few things to keep in mind:

  • No webhook → longer videos never become playable. An admin upload polls the new asset for ~6 seconds; anything that encodes slower saves with just its assetId (the doc's status shows preparing) and waits for the webhook to fill in the playback metadata — and re-saving won't re-fetch. The endpoint must be publicly reachable (Mux pushes to it), so tunnel localhost in dev. Server-side ingest and seeding are exempt: they wait for ready regardless of length.
  • Deletes are real, both ways. Deleting the Payload doc deletes the Mux asset (afterDelete), and replacing a video's asset deletes the previous one. The reverse direction — a Mux-dashboard delete removing the Payload doc — only works through the webhook.
  • Signed playback signs three URLs per read. Under a signed policy every read JWT-signs playbackUrl, posterUrl, and gifUrl. If you only render one, select it (with its playbackId / playbackPolicy) so the other two aren't signed.
  • title is unique and auto-deduped. A duplicate title doesn't fail the save — it's suffixed to stay unique, so the stored title can differ from what was typed (and is mirrored onto filename when extending an upload collection).
  • Seeding without credentials skips, it doesn't fail. With MUX_TOKEN_ID / MUX_TOKEN_SECRET unset the collection is marked seedDisabled: the seed engine warns, skips the videos, and drops optional refs to them. Set the vars and the next run ingests the clips — no seed-file changes.
  • No revalidation built in. Webhook writes (ready, deleted) land outside any page request, so a cached page won't update on its own. Add afterChange / afterDelete hooks on mux-video calling revalidateTag / revalidatePath — they fire for webhook, admin, and ingest writes alike.

Exports

ExportFromWhat
muxVideoPlugin (default + named)@pro-laico/payload-muxThe plugin factory.
MuxVideoPluginOptions@pro-laico/payload-muxThe plugin options type (for typing a wrapper / shared factory).
MuxVideo@pro-laico/payload-muxThe collection factory, for advanced configs assembled by hand.
ingestMuxVideo@pro-laico/payload-muxCreate a mux-video doc from a local file / URL (seeding, imports, migrations).
MuxUploaderField@pro-laico/payload-mux/components/MuxUploaderFieldAdmin uploader + player field (wired via the import map).
MuxVideoGifCell / MuxVideoImageCell@pro-laico/payload-mux/components/*List-view thumbnail cells selected by adminThumbnail.

On this page