Media
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.
| Backend | Driver | Files | Records |
|---|---|---|---|
| Git CMS | Core (pluto-github) | public/media/<yyyy>/<mm>/ in the repository | content/media/<id>.md |
| Supabase | @plutocms/supabase | Supabase Storage, bucket media | The 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:
| Key | Value |
|---|---|
id | Text. The Git CMS uses the stored file name without the extension (red-mug-k3j9a). Supabase uses the row id ("42"). |
url | The public URL. A path of the site on the Git CMS (/media/2026/10/red-mug-k3j9a.webp), a Storage URL on Supabase. |
name | The original file name. |
mimeType | The real type, from the first bytes of the file. The name and the browser do not decide it. |
size | In bytes. |
width, height | In pixels. null for a file that is not an image. |
alt | The text alternative. Empty when no one sets it. |
preview | A still image of a file that is not an image (for example a 3D model): { url, width, height }, or null. |
createdAt | ISO timestamp. |
Uploads
In the browser
Before the upload, the browser processes an image:
- It applies the rotation of the camera (EXIF).
- It draws the image on a canvas. A canvas copies no metadata, so the EXIF data, the GPS location and XMP go.
- It makes the long side at most 2400 px.
- 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
.pngname 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
| Setting | Default |
|---|---|
runtimeConfig.public.plutoMedia.allowedTypes | JPEG, PNG, WebP, AVIF, GIF, PDF |
runtimeConfig.public.plutoMedia.extraTypes | None. A layer adds its types here. The lists of all the layers join. |
runtimeConfig.public.plutoMedia.maxUploadBytes | 10 MB. Env: NUXT_PUBLIC_PLUTO_MEDIA_MAX_UPLOAD_BYTES. |
runtimeConfig.public.plutoMedia.typeLimits | None. 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:
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
}
]
})
})
| Key | What it does |
|---|---|
accept | The MIME types of the kind. |
label, icon, badge | The filter chip, the tile, and the badge on the tile. |
viewer | A 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. |
createPreview | Makes the preview image in the browser, from the file. null when it cannot. |
inspect | Checks 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
{
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
multiplestores one id. Amultiplefield 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:
textfor one id,jsonbfor 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,heightandaltof the record go on the image, so the page does not move while it loads. - Give each entry of
sizesa screen (xs:100vw). An entry without a screen gives a 1 px image in the list. - When the image service cannot read the file,
PlutoImageshows 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:
| Host | Provider |
|---|---|
| Node | IPX (the default) |
| Vercel, Netlify | The image service of the host. @nuxt/image finds it. |
| Cloudflare Workers | IPX 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
| Capability | Allows |
|---|---|
media:manage | Upload, rename, change the alt text, make a preview image, see the library |
media:delete | Delete 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:
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 */ },
})
})
authorizeis required. A media write never passes without it.getmust work with no signed-in user, because the public API uses it.uploadgets 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.listsorts bysort:newest(the default),oldest,name, orsize.