# payload-mux

URL: /docs/plugins/payload-mux

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

> **Based on [`@oversightstudio/mux-video`](https://github.com/oversightstudio/payload-plugins) (MIT)**,
> originally created by [Idan Yekutiel](https://github.com/idanyekutiel). 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](https://www.mux.com/), 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.

```bash
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`](/docs/plugins/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.

```bash
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.

**Public playback**

```ts
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
})
```

**Signed playback**

```ts
// 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](#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:

```bash
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.

**Reference**

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | When false, the plugin registers nothing: no collection, endpoints, or hooks. |
| `initSettings` | `MuxVideoInitSettings` | `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). |
| `initSettings.tokenId` | `string` | `process.env.MUX_TOKEN_ID` | Mux API token ID. |
| `initSettings.tokenSecret` | `string` | `process.env.MUX_TOKEN_SECRET` | Mux API token secret. |
| `initSettings.webhookSecret` | `string` | `process.env.MUX_WEBHOOK_SECRET` | Secret used to verify incoming Mux webhooks. |
| `initSettings.jwtSigningKey` | `string` | `process.env.MUX_SIGNING_KEY` | JWT signing key ID, for signed playback. |
| `initSettings.jwtPrivateKey` | `string` | `process.env.MUX_PRIVATE_KEY` | JWT private key, for signed playback. |
| `uploadSettings` | `MuxVideoUploadSettings` |  | Settings applied to every upload. |
| `uploadSettings.cors_origin` | `string` | `NEXT_PUBLIC_SERVER_URL, then *` | CORS origin for the direct-upload URL, usually your site URL. |
| `uploadSettings.new_asset_settings` | `MuxVideoNewAssetSettings` |  | 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'` | `'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. |
| `extendCollection` | `keyof 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>` | `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 }` | `{ expiration: '1d' }` | Lifetime of the JWT-signed playback / poster / gif URLs under a signed policy. |
| `posterExtension` | `'webp' \| 'jpg' \| 'png'` | `'png'` | Image format for the poster (posterUrl). |
| `animatedGifExtension` | `'gif' \| 'webp'` | `'gif'` | Format for the animated preview (gifUrl). |
| `adminThumbnail` | `'gif' \| 'image' \| 'none'` | `'gif'` | List-view thumbnail style: animated gif, static image, or none. |
| `autoCreateOnWebhook` | `boolean` | `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). |

**TypeScript**

```ts
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](#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 sidebar](/screenshots/admin-mux.webp)

**Fields**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `muxUploader` | `ui` |  | The admin uploader + player (label 'Video Preview'). Its list-view Cell is the gif / image / none thumbnail set by adminThumbnail. |
| `source` | `json` |  | Transient server-side ingest input: a local path / URL, or { file\|url, playbackPolicy, posterTimestamp }. Uploaded by beforeValidate then stripped; never persisted. |
| `title` | `text` |  | Unique, required. The admin title; auto-deduped to stay unique (and mirrored onto the filename when extending an upload collection). |
| `assetId` | `text` |  | Mux asset id. Read-only, filled by the hooks. |
| `status` | `select` |  | Encoding state: preparing / ready / errored. Read-only (sidebar), written by the hooks and the webhook. |
| `error` | `text` |  | Mux's error messages when an asset fails to process. Hidden; surfaced by the uploader field. |
| `duration` | `number` |  | Length in seconds. Read-only, from Mux. |
| `posterTimestamp` | `number` |  | Seconds into the video for the poster image. Defaults to the middle; validated against duration. |
| `aspectRatio` | `text` |  | Read-only, from Mux. |
| `maxWidth` | `number` |  | Read-only, from Mux. |
| `maxHeight` | `number` |  | Read-only, from Mux. |
| `playbackOptions` | `array` |  | Read-only. One row per playback id, each with: playbackId (text), playbackPolicy (select: signed \| public), and virtual playbackUrl / posterUrl / gifUrl computed on read (signed under a signed policy). |

**Hooks**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `beforeValidate` | `beforeValidate` |  | Server-side ingest: if the doc carries a transient `source` and no assetId, upload it to Mux, wait until ready, fold in the metadata, and strip source. Skipped when an assetId is already present. |
| `beforeChange` | `beforeChange` |  | On a new/changed assetId: delete the previous asset (on update), poll the new one for ~6s while it's preparing, fold in ready metadata, and de-dupe the title (mirrored onto filename on an upload collection). A still-preparing asset is left to the webhook; an errored asset is deleted and the save rejected. |
| `afterDelete` | `afterDelete` |  | Payload → Mux delete: when a doc is deleted, delete its Mux asset too (a not_found is ignored). |

> 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`](https://www.npmjs.com/package/@mux/mux-player-react) (`pnpm add
@mux/mux-player-react` if you go that route):

```tsx
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:
>
> ```ts
> 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.

**Needs 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.

**Works without it**

- 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`:

```ts
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](#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`](/docs/plugins/payload-seed#custom-ingestion)'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()`:

```ts
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](#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](/docs/plugins/payload-seed#custom-ingestion) 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](/docs/plugins/payload-seed#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

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