Media library
Named storages — what they are, why they exist, every knob, and where to find the deeper docs.
The CMS's media library is built around named storages you declare in
.norcube/cms/config.yml. Each storage pins where bytes live on disk, what's
allowed in, and what shape the value takes when it lands in your page's
frontmatter. Image and file fields opt into a storage by name; the picker
and upload UI gate on the storage's config.
This page is the entry point. The deeper concepts each live on their own page:
Frontmatter shape
The publicUrl contract — what your build pipeline sees in frontmatter and why.
Naming + dedup
Four filename strategies (slug, slug+hash, uuid, original) plus dedup.
Inline modes
inline: ref vs inline: svg — when to inline SVG markup directly into frontmatter.
Why named storages
The pre-storages flow was "the editor's picker shows the whole repo and every upload goes to one hardcoded directory." Fine for the smallest sites; breaks the moment you have more than one kind of asset:
- An icon library the designer maintains in
src/assets/icons/*.svg— edited via PR, not the CMS. The editor needs a picker scoped to that directory, SVG-only, with no upload affordance. - User uploads going to
public/uploads/, collision-safe filenames, served from the deployed site as/uploads/.... - A public/favicon directory at the repo root that needs neither validation nor a special picker — just the legacy whole-repo flow.
Named storages let you express all three side-by-side, and let each field opt into the right one.
Minimal config
# .norcube/cms/config.yml
storages:
uploads:
type: repo
path: public/uploads
publicUrl: /uploads
accept: image/*, application/pdf
max_size: 10485760 # 10 MiB
defaultStorage: uploads# In a schema block
hero:
type: image
storage: uploadsA field with no storage: falls back to defaultStorage. Sites with no
storages: block at all keep the old whole-repo picker (so nothing breaks
on migration).
Storage knobs
Every storage takes the same set of common knobs plus driver-specific ones.
| Knob | Values | Effect |
|---|---|---|
type | repo | The only shipped driver. R2 / S3 / GCS are scaffolded but rejected at resolve time. |
path | repo-relative directory | Where the bytes live on disk. Leading / trailing slashes are trimmed. |
publicUrl | URL prefix (or empty) | Drives the frontmatter shape. See Frontmatter shape. |
mode | read-write (default), read-only | read-only hides the upload button AND rejects POSTs server-side. |
accept | MIME glob list (image/*, image/svg+xml) | Picker filter + server-side upload validation. |
max_size | bytes | Server-side pre-upload check; the picker hints the limit on the dropzone. A hard 25 MiB ceiling applies on top of this regardless (see below). |
naming | slug, slug+hash, uuid, empty (= original) | Server-side filename derivation. See Naming + dedup. |
inline | ref (default), svg | svg inlines the markup into frontmatter. See Inline modes. |
dedup | hash | Short-circuit identical uploads. Requires naming: slug+hash. See Naming + dedup. |
Read-only storages
mode: read-only hides the upload affordance in the picker AND rejects
the POST server-side. Use this for asset libraries maintained outside the
CMS — designer-owned icon sets, build-time-generated thumbnails, anything
where the authoritative writer is git itself.
storages:
icons:
type: repo
path: src/assets/icons
accept: image/svg+xml
mode: read-onlyThe server-side check is the trust boundary — hiding the button alone
would let a determined caller bypass via a direct POST /sites/:id/media.
Both gates run.
Upload reliability
Uploads go through a local mirror of your repo — the CMS commits the file and pushes to your branch via git itself, not the GitHub Contents API.
Why: GitHub's Contents API caps the request body around 4.8 MB, and uploading a binary requires base64-encoding (adds ~33% to the payload), so a 3.6 MB image is already at the cap. Above that the API returns 502s that surface as an unhelpful HTML "Unicorn!" page — not a useful error.
Plain git handles binary natively, no base64 envelope. This is transparent to you as a user: same upload button, same progress bar, files just don't fail past a megabyte.
There is still one absolute ceiling. Every upload is capped at
25 MiB regardless of a storage's max_size — a larger file is
rejected with a 413 before anything is committed. Set a lower
per-storage max_size when you want a tighter limit; 25 MiB is the
hard upper bound you can't raise from config.
Default storage
defaultStorage: controls which storage a field with no storage: key
inherits. When unset, the first declared storage is the implicit
default (YAML map order is preserved through the loader).
storages:
uploads:
type: repo
path: public/uploads
publicUrl: /uploads
icons:
type: repo
path: src/assets/icons
mode: read-only
inline: svg
defaultStorage: uploads # explicit; otherwise "uploads" because declared firstPick defaultStorage: explicitly when the order of your storages might
change for unrelated reasons — config-editor reorders, alphabetisation,
etc. The explicit value is stable; the implicit "first declared" tracks
authoring order.
The Media library page
The Media item in the CMS sidebar is a full-page browser over your storages — the same files the field picker shows, with room to manage them.
- Storage switcher. Sites with more than one declared storage get a
selector; the active storage is reflected in the URL (
?storage=) so a view is shareable. Sites with nostorages:block browse the legacy media folder instead. - Folders. Navigate into subdirectories with a breadcrumb back to the
storage root. The current folder is in the URL (
?path=). - Search. Typing in the search box switches from folder browsing to a recursive, storage-wide search by filename — matching files surface with their full path, plus the folders that contain them. Clear the box to go back to browsing.
- Upload lands in the folder you're viewing (not just the storage root), with a live progress bar and an optimistic tile.
- New folder. Because git can't track an empty directory, a new folder is
materialised with a hidden
.gitkeepplaceholder — you never see it; the listing filters it out. - Rename / move and delete (files and folders; folders recurse) commit straight to your branch. Rename refuses to overwrite an existing file. Delete is idempotent.
- Preview. Clicking a file opens a detail dialog — a large preview for
images, plus Copy reference path, which gives you the exact value an
image/file field would store (honouring the storage's
publicUrl).
Previews and thumbnails are served through short-lived signed URLs, so the library can render private-repo assets without exposing them publicly. The links are minted on demand and expire on their own.
Read-only storages (mode: read-only) show the library for browsing but hide
upload, new-folder, rename, and delete — the same server-side gate that
rejects uploads also rejects these mutations.
What's not there yet
Storage features on the list but not shipped:
- R2 / S3 / GCS drivers. Only
type: repo(files in git) ships today. Cloud-object drivers are on the way but not wired. optimize.svgo/optimize.webp/optimize.resize— server-side optimisation pipeline for images on upload.cdn.rewrite_to— path substitution beyond whatpublicUrlalready covers.versioning: append-hash— cache-busting via query-param suffixes on stored URLs.