Show navigationHide navigation

Media library

What “media” means here#

Each uploaded image has:

  1. A binary file under public/media/<uuid>.<ext> (local dev) or the same path committed to Git in production workflows.
  2. A media entry JSON file under cms/media/media-<uuid>.json (configurable via mediaContentFolder in cms/schema.json) with:
    • title (required on upload) — human-readable label, used as the default alt text when this asset is referenced from an image field (similar to Contentful).
    • originalName — the on-disk filename in <uuid>.<ext> form (not the user's upload name). Long, messy source filenames are discarded so the UI always shows clean identifiers.
    • extension, folder (virtual folder path).
    • width, height — populated on upload by server-side metadata extraction (sharp).
    • blurDataURL (optional) — tiny LQIP data URL for next/image placeholders. Generated by default; opt out by unchecking "Generate blur placeholder" in the upload modal when you don't need LQIP.

Image fields in content entries store the media UUID. The query() API resolves that to a ResolvedImageField object (src, alt, dimensions, blur data), not a bare URL string. The src is typically /media/<uuid>.<ext> so production can serve files that only exist on GitHub after deploy.

Saving a content entry that references a UUID checks that the media entry has a non-empty title; fix legacy assets under /cms/media/<id> (asset editor Save title).

Media manager UI#

The library is split into two screens that share the same chrome as /cms/content.

List — /cms/media#

  • Left panelMediaLeftPanel mirrors the content-list LeftPanel: an All files entry plus a Folders section. Folders are virtual labels (think tags) for visual sorting — not physical directories. Add a folder via the + in the section header (opens the Create folder dialog), delete via the X-on-hover (replaces the count visually so they never overlap; opens the Delete folder confirm dialog; blocked if any asset still uses that label). Folders you create are persisted in localStorage (octocms:media-custom-folders) so navigating into the asset editor and back doesn't wipe them.
  • Persistent dropzone — drag images onto the strip above the grid, or click to open the file picker. After choosing files you set a required Title per file (defaults to the suggested title from the filename) and an optional Generate blur placeholder checkbox (enabled by default — uncheck to skip the LQIP encode and shave a few KB off the entry JSON). After upload completes the page navigates to the asset editor for the first uploaded file so you can review it immediately.
  • Default sort — newest first. The list orders by the entry JSON's last-modified time (dev mode); production reads from GitHub and falls back to insertion order from the in-memory store.
  • Search bar — same shape and tokens as /cms/content (Filter assets… + / keyboard hint). Press / anywhere on the page to focus.
  • View-mode switcher — grid / list pill in the topbar (next to Upload). Choice persists to localStorage (octocms:media-view-mode).
    • Grid mode — thumbnail cards in a responsive grid. Cards have no overflow menu — clicking anywhere on a card navigates to the asset editor where you delete or edit.
    • List mode — table with Title (with thumbnail), Folder, Format, Dimensions, File name. Same table chrome as /cms/content.

Asset editor — /cms/media/<id>#

A dedicated page (no overlays, no in-place panel). Server-rendered via MediaAssetPage; missing ids 404. Layout:

  • Page header — same min-h-[52px] chrome as /cms/content. Breadcrumb Media › <folder> › <title>, back arrow, Open in new tab, Delete.
  • Preview pane (left) — large aspect-fit preview on a neutral surface.
  • Sidebar form (right) — Title (saves via the updateMediaMetadata server action), Folder (shadcn Select + explicit Save folder button — moveMedia runs only when the user confirms; the button is disabled until the dropdown actually changes), and read-only details (file name, format, dimensions, path, id).

After delete the editor redirects back to /cms/media. Deletion is blocked if an image is still referenced from a content entry — the toast surfaces the failure.

Picking an image inside an entry editor#

An image field on a content entry (e.g. a post's featuredImage) is rendered by FormImageField. It shows the current thumbnail (or an empty placeholder) plus two side-by-side buttons:

  • Upload new image — opens the same upload dialog as /cms/media's Upload button. Pick one or more files, set per-file Titles, and (optionally) toggle Generate blur placeholder. The first uploaded asset becomes the field value.
  • Select existing image — opens a modal media browser with the same chrome as /cms/media: search input, grid / list view toggle, folder left panel. Click any image to set it as the field value and close the dialog.

The X button on the thumbnail clears the field. The hidden form input stores the media entry UUID (not the image URL); query() resolves the UUID to a ResolvedImageField at read time.

Limits and formats#

Allowed extensions come from mediaAllowedFormats in cms/octocms.config.ts (for example jpg, png, webp).

Maximum upload size is 10 MB per file (enforced server-side in uploadMedia).

URLs for templates and front ends#

From query(), use fields.<imageField>.src for the URL and fields.<imageField>.alt for accessibility. Optional width, height, and blurDataURL support next/image (placeholder="blur"). Example:

tsx
const img = post.fields.featuredImage;
{img ? (
  <Image
    src={img.src}
    alt={img.alt}
    width={img.width ?? 1200}
    height={img.height ?? 800}
    {...(img.blurDataURL ? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL } : {})}
  />
) : null}

That path works with next/image and plain <img> without extra remote image domain configuration, because it targets your own app’s API route.

Why a proxy route?#

The image URL /media/<uuid>.<ext> is served by a Route Handler at app/media/[...slug]/route.ts (a thin re-export of mediaRoute from octocms/admin/mediaRoute). It is public and unauthenticated — anyone can fetch an image — and it lives outside /cms/* so public pages aren't blocked by the admin auth gate.

The handler exists because images uploaded through the CMS are committed to GitHub at public/media/<uuid>.<ext>, but on Vercel and similar serverless hosts the deployed filesystem is immutable after build. Anything committed after the deploy (every CMS upload) never lands in public/ on the running instance. The route bridges that gap:

  • Dev — reads from local public/media/.
  • Production — streams the binary from the GitHub Contents API (readGitHubBinaryFilePublic). When the editor cookie cms-active-branch is set, it tries the feature branch first (so editors see their own uploads before publish) and falls back to the published branch on miss.

Cache headers reflect that split: private, max-age=60 for editor previews so a CDN never serves a feature-branch asset to a public visitor, public, max-age=31536000, immutable once published. Same-origin URL means next/image and bare <img> work without remotePatterns. The route is scaffolded by npx octocms init and kept in sync by npx octocms update; if you set the project up by hand, see Getting started — Create the media proxy route.