Core

Media

One media library for every backend. Uploads become small WebP images with no location data, and the sizes come at delivery.

Pluto has one media library for every backend. Core gives the Media page, the media field widget, the picker, the upload checks and PlutoImage. A backend gives only a driver: the place that stores the files and their records.

BackendDriverFilesRecords
Git CMSCore (pluto-github)public/media/<yyyy>/<mm>/ in the repositorycontent/media/<id>.md
Supabase@plutocms/supabaseSupabase Storage, bucket mediaThe media table

The admin shows the Media page (/admin/media) when the app has a driver.

The Media page

  • Grid or list: the toolbar switches the view. The list shows the name, the type, the size and the date.
  • Sort: newest, oldest, name, or size. The page keeps the choice of the view and the sort.
  • Filter: search the name and the alt text, and pick a kind of file (images, PDFs, 3D models, ...).
  • Upload: drop files on the page, or use the Upload button. Each file shows its progress.
  • Select many: copy their URLs, or delete them.
  • Details: open a file to see it (an image, a PDF, or the viewer of its kind), rename it, change its alt text, copy its URL, a Markdown link or its id, download it, or make its preview image again.

The picker of a media field uses the same library, with the files you picked in a strip at the bottom.

The record

Each file has one record. Core fills it in on the server, from the bytes of the file:

KeyValue
idText. The Git CMS uses the stored file name without the extension (red-mug-k3j9a). Supabase uses the row id ("42").
urlThe public URL. A path of the site on the Git CMS (/media/2026/10/red-mug-k3j9a.webp), a Storage URL on Supabase.
nameThe original file name.
mimeTypeThe real type, from the first bytes of the file. The name and the browser do not decide it.
sizeIn bytes.
width, heightIn pixels. null for a file that is not an image.
altThe text alternative. Empty when no one sets it.
previewA still image of a file that is not an image (for example a 3D model): { url, width, height }, or null.
createdAtISO timestamp.

Uploads

In the browser

Before the upload, the browser processes an image:

  1. It applies the rotation of the camera (EXIF).
  2. It draws the image on a canvas. A canvas copies no metadata, so the EXIF data, the GPS location and XMP go.
  3. It makes the long side at most 2400 px.
  4. It saves the image as WebP. A browser that cannot write WebP saves JPEG.

A GIF, a 3D model and a PDF go as they are. When an image needs no resize and the WebP is not smaller, the browser sends the original. The server then removes its metadata.

The browser does this work, so it is the same on every backend and every host, Cloudflare Workers too.

When the kind of the file can make a preview image (see Media kinds), the browser makes it now and sends it with the file.

On the server

The server does not trust the browser:

  • It reads the real type from the bytes. An SVG file with a .png name gets 415.
  • It checks the type against the allowed list (415), and the size against the limit (413).
  • It removes the metadata of JPEG, PNG and WebP at the byte level, with no new encoding. A JPEG keeps only its rotation.
  • It reads the pixel size, and gives the file a unique name: the slug of the name, five random characters, and the extension of the real type.

The rules

SettingDefault
runtimeConfig.public.plutoMedia.allowedTypesJPEG, PNG, WebP, AVIF, GIF, PDF
runtimeConfig.public.plutoMedia.extraTypesNone. A layer adds its types here. The lists of all the layers join.
runtimeConfig.public.plutoMedia.maxUploadBytes10 MB. Env: NUXT_PUBLIC_PLUTO_MEDIA_MAX_UPLOAD_BYTES.
runtimeConfig.public.plutoMedia.typeLimitsNone. The size limit of one type, over maxUploadBytes.

SVG is not on the list, because an SVG file can hold scripts. A media field can narrow the list with accept.

For example, @plutocms/3d adds model/gltf-binary (.glb) to extraTypes, with a limit of 25 MB.

A preview image is JPEG, PNG or WebP, at most 2 MB. The server checks it and removes its metadata like a file.

Media kinds

A layer tells the admin how to show a type of file. It adds a media kind with definePlutoExtension:

app/plugins/my-layer.ts
const MyViewer = defineAsyncComponent(() => import('../components/MyViewer.vue'))

export default defineNuxtPlugin(() => {
  definePlutoExtension({
    id: 'my-layer',
    mediaKinds: [
      {
        id: 'model',
        accept: ['model/gltf-binary'],
        label: 'my-layer.kind',
        icon: 'i-lucide-box',
        badge: '3D',
        viewer: MyViewer,
        createPreview: async (file) => null,
        inspect: async (file) => null
      }
    ]
  })
})
KeyWhat it does
acceptThe MIME types of the kind.
label, icon, badgeThe filter chip, the tile, and the badge on the tile.
viewerA component that shows the file in the details. It gets the record as media. Load it with defineAsyncComponent, so its code loads only when a file shows.
createPreviewMakes the preview image in the browser, from the file. null when it cannot.
inspectChecks the file before the upload, and returns a warning (a translation key or a text). The upload continues.

Core has two kinds: images and PDFs.

A media field

shared/utils/content-product.ts
{
  name: 'media',
  type: 'media',
  label: 'Media',
  multiple: true,
  accept: ['image/*', 'model/gltf-binary'],
}

The model/gltf-binary files need @plutocms/3d. Without it, the server refuses them.

  • A field without multiple stores one id. A multiple field stores a list of ids, in order.
  • On the Git CMS, the value is a frontmatter key. On Supabase, give the field its own column: text for one id, jsonb for a list.
  • The core widget shows a tile with Change and Remove, or a strip that you can put in order. A file that someone deleted shows as "Deleted file".

The public API

The public API puts the records in place of the ids of every public media field. A field without multiple gives one record or null. A multiple field gives a list. One call reads the records of all the items of an answer. An id with no record is dropped.

{
  "cover": {
    "id": "red-mug-k3j9a",
    "url": "/media/2026/10/red-mug-k3j9a.webp",
    "mimeType": "image/webp",
    "width": 2400,
    "height": 1600,
    "alt": "A red mug"
  }
}

Images on a site

Only one size of each image is stored. PlutoImage gets the other sizes at delivery, through @nuxt/image:

<PlutoImage :media="post.cover" sizes="xs:100vw sm:50vw lg:800px" class="h-auto w-full" />
  • The width, height and alt of the record go on the image, so the page does not move while it loads.
  • Give each entry of sizes a screen (xs:100vw). An entry without a screen gives a 1 px image in the list.
  • When the image service cannot read the file, PlutoImage shows the file as it is.
  • A record that is not an image shows its preview image, or nothing when it has none. Use isMediaImage(record) to find the images.

The provider of @nuxt/image depends on the host:

HostProvider
NodeIPX (the default)
Vercel, NetlifyThe image service of the host. @nuxt/image finds it.
Cloudflare WorkersIPX cannot run. Set image.provider to cloudflare (with Cloudflare Images on the zone), or to none.
Supabase files@plutocms/supabase adds the host of SUPABASE_URL to image.domains. Supabase image transforms need a paid plan, so they are not the default.

In development, IPX reads only the public folders that exist when the server starts. Give the project a public/ folder (for example with favicon.ico) before its first upload.

The Git CMS

  • One commit: an upload commits the file, its preview image and its record together (media: add red-mug-k3j9a.webp). A delete removes them in one commit. The preview image is next to the file: <name>.preview.webp.
  • Before a deploy: a content commit can skip the deploy, so a file can be in the repository but not in the static files of the site. The route /media/** reads such a file from the repository. After the next deploy, the host sends the static file.
  • Who can upload: a user with push access can upload and delete files.
  • The repository size: the repository holds one processed size of each image, so it grows slowly.

Capabilities

CapabilityAllows
media:manageUpload, rename, change the alt text, make a preview image, see the library
media:deleteDelete a file

The routes /api/_pluto/media check them through the driver. On Supabase, Row Level Security checks them again.

Write a driver

A backend registers one driver on the server, and sets runtimeConfig.public.plutoMedia.driver:

server/plugins/media-driver.ts
export default defineNitroPlugin(() => {
  registerMediaDriver({
    name: 's3',
    authorize: async (event, capability) => { /* throw 401 or 403 */ },
    list: async (event, { limit, offset, search, type, sort }) => ({ data: [], total: 0 }),
    get: async (event, ids) => [],
    upload: async (event, input) => { /* store input.data and input.preview, save the record */ },
    update: async (event, id, { alt, name }) => { /* ... */ },
    setPreview: async (event, id, preview) => { /* store the image, replace the old one */ },
    remove: async (event, id) => { /* delete the file, its preview and the record */ },
  })
})
  • authorize is required. A media write never passes without it.
  • get must work with no signed-in user, because the public API uses it.
  • upload gets a complete record (input.mimeType, input.width, input.fileName, ...) and, when there is one, input.preview (fileName, data, mimeType, width, height). Store it as it is. When the record fails, remove the files.
  • list sorts by sort: newest (the default), oldest, name, or size.