Media

Images and files live in the workspace media library. How blocks reference them, why each upload gets its own URL, and what that means when you replace one.

Media is workspace-level, not page-level. One library, organised in folders, shared by every page and every block.

What you can upload

One size cap and one allowlist, both enforced on the server, both living in a single file so the admin's pre-check and the API cannot drift apart:

  • The size your plan allows - 50 MB on free, 200 MB on starter, 500 MB on pro, 2047 MB on enterprise, and whatever a negotiated agreement sets. Over it, the refusal names your own number: File too large (max 500MB on the pro plan). The cap is per file, not per batch.
  • Any image, video or audio - the whole of image/*, video/* and audio/* - plus PDF, Word and Excel by exact type. Everything else is refused.

Storage belongs to the organisation, not the workspace. The plan's allowance is pooled across every workspace in the org, and an upload that would take the org past it is refused before any bytes move:

Storage limit exceeded. Current: 812.4MB, Limit: 1024MB

You may fill the allowance exactly - it is the upload that would cross the line that fails, not the one that lands on it.

Uploading from your own code

Bytes never stream through cmssy. media.authorizeUpload checks the type, the size and the remaining allowance first, then hands back a presigned PUT valid for an hour; you send the file straight to storage and the library records the asset afterwards. The MCP server's upload tool is a wrapper over exactly this, which is why a scripted upload obeys the same three checks as one done by hand.

Tags

Every asset carries tags, editable per file or applied to a selection at once, and the library can be filtered down to one. A file lives in exactly one folder but can carry as many tags as it needs - which is what saves you when the folder tree stops matching how people actually look for things.

The media field

A block reaches an asset through fields.media:

export const imageProps = {
  src: fields.media({ label: "Image", required: true }),
  alt: fields.text({ label: "Alt text" }),
};

You write a reference and read a resolved object. They are not the same shape, and that catches people out:

interface MediaReference {   // what is stored
  assetId: string;
}

interface ResolvedMedia {    // what your component receives
  id: string;
  url: string | null;
  visibility: "public" | "private";
  alt?: string;
  width?: number;
  height?: number;
}

Read through the helpers rather than the field. They accept the resolved object and the older plain-string value alike, so the same component works against an older cmssy too:

import { mediaUrl, mediaAlt } from "@cmssy/react";

function ImageBlock({ content }) {
  const src = mediaUrl(content.src);
  if (!src) return null;
  return <img src={src} alt={content.alt ?? mediaAlt(content.src) ?? ""} />;
}

url is string | null, and the null is not an error state: a private asset resolves to null for a reader who holds no entitlement. Render conditionally and the page degrades to no image rather than a broken one.

width and height come back when the asset has them, which is what next/image wants for its layout without a round trip.

Every upload gets its own URL

Uploaded assets are served from a CDN host, with a hash in the path:

https://assets.cmssy.io/{workspaceId}/78aa0167-cmssy-og-default.png

That hash is per upload. It is what makes assets immutable and safely cacheable forever - but it has a consequence people meet the hard way:

Uploading a replacement does not update the blocks pointing at the old file. A new upload is a new URL; existing blocks keep the old one and keep rendering the old image. If you swapped a logo and the site still shows the previous one, nothing is cached wrong - the blocks are simply still pointing where they always did.

There is no in-place replace: an upload is always a new file, and therefore a new URL. The remedy is to re-point the blocks - a search-and-replace over block content, which is exactly what the MCP server is good at.

The practical consequence is worth planning for. An asset that many pages reference - a logo, a default OG image - is cheaper to swap if you route it through site config or a single block, rather than pasting it into twenty places.

Image transformations

A media reference can carry a transform, and delivery resolves it into a resized URL rather than the original:

{
  "assetId": "6a6495e44d1ee7dedcae1f52",
  "transform": { "width": 800, "fit": "cover", "quality": 85 }
}

What your component receives is an image-resizing URL built from the canonical one:

https://assets.cmssy.io/cdn-cgi/image/format=auto,width=800,fit=cover,quality=85,gravity=auto/{workspaceId}/78aa0167-hero.jpg

format=auto is always applied, so a browser that accepts AVIF or WebP is served it without you asking.

  • width and height snap up to the nearest of 96, 200, 400, 800, 1200, 1600, 2400 - and 2400 is the ceiling. Ask for 810 and you get 1200. The ladder is deliberate: a fixed set of widths is a cache that fills, an open-ended one is a cache that never hits.
  • fit is one of scale-down, contain, cover, crop, pad. Omitted, it is cover when you gave both dimensions and scale-down when you gave one.
  • quality is 1-100, and 85 when you leave it out.

A transform outside those bounds is refused when it is written - Media transform must be within the sizes and quality this workspace can serve - rather than quietly dropped, so a typo surfaces as you save it and not months later on a page nobody opens.

Focal point

A crop uses gravity=auto until the asset carries a focal point. Set one on the file in the media library and every crop of it honours it:

.../cdn-cgi/image/format=auto,width=400,height=400,fit=cover,quality=85,gravity=0.5x0.33/...

It lives on the asset rather than on the reference because it is a fact about the picture - where the face is - not about one place the picture appears.

Private files are the exception: they resolve to url: null, so there is nothing to transform. See Private media.

Folders

Folders are a flat-ish tree with a parentId. They organise the library for humans; they are not part of the URL, so moving an asset between folders does not break anything pointing at it.

Through MCP you can list, create, rename, delete and move - which makes bulk reorganisation scriptable rather than an afternoon of dragging.

Deleting a file

Deletion is guarded. cmssy scans the whole workspace first - every page's draft and published blocks, layout blocks and custom fields, every model record, and the branding in site config - and refuses if the file is used anywhere:

CONFLICT  Cannot delete: hero.jpg is in use.

Up to five files are named; beyond that the message counts the rest. The scan matches on asset id, so moving a file between folders does not hide it from the guard.

force overrides the refusal, and it needs a permission of its own, separate from ordinary deletion. Deleting a file that pages point at leaves holes in those pages, so it is not something an editor reaches by accident.

What leaves storage is narrower than what leaves the library. The bytes go only when no other asset still points at the same stored object - otherwise the library entry disappears and the file stays, because something else is still serving it. Whatever is genuinely freed comes off the workspace's storage usage.

Next.js images

Assets come from a different origin than your app, so next/image will refuse them until the host is allowed:

// next.config.mjs
const nextConfig = {
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "assets.cmssy.io" },
    ],
  },
};

A wildcard hostname: "**" works and is what the reference app uses, but it lets any HTTPS host through your image optimizer. Naming the asset host is the tighter choice, and costs one line.

Alt text

Alt text lives in two places, and both are real. The asset carries a default per language, written once in the media library - that is what delivery returns in ResolvedMedia.alt, and what mediaAlt() reads. The block carries an override, because alt text describes what an image means in this context: the same photo needs different words in a case study and in a logo wall.

Read the specific one first and fall back to the general one:

alt={content.alt ?? mediaAlt(content.src) ?? ""}

Give editors both. The library default means an image is never shipped with no alt at all; the block field means it can say the right thing where that matters.

Next steps