Frontmatter shape
The publicUrl contract — what every storage writes to frontmatter, why, and how to read it back from a build pipeline.
The shape your frontmatter sees after a pick or upload is deterministic
and depends on one storage knob: publicUrl. This page is the contract.
The TL;DR — the CMS writes the same URL the deployed site will serve the file from. Your build pipeline reads frontmatter and gets a working URL (or a repo-rooted path for build-imported assets) without needing to know about Norcube or storages at all.
Three canonical shapes
storages:
uploads:
type: repo
path: public/uploads
publicUrl: /uploads # → "/uploads/hero.jpg"
cdn:
type: repo
path: assets
publicUrl: https://cdn.example.com # → "https://cdn.example.com/hero.jpg"
icons:
type: repo
path: src/assets/icons # no publicUrl → "/src/assets/icons/check.svg"For the same picked file hero.jpg (or check.svg in the icons case):
| Storage config | Frontmatter value | Renders as |
|---|---|---|
publicUrl: /uploads | /uploads/hero.jpg | <img src="/uploads/hero.jpg"> |
publicUrl: https://cdn.example.com | https://cdn.example.com/hero.jpg | <img src="https://cdn.…/hero.jpg"> |
no publicUrl, path: src/assets/icons | /src/assets/icons/check.svg | Build-imported (Astro import, Hugo resources.Get, etc.) |
The composition rule on the server side is straightforward (from
storage.PublicValue):
publicUrlset →"<publicUrl>/<filename>"publicUrlempty →"/<path>/<filename>"(leading slash added, path trimmed of any wrapping slashes at parse time)
The three cases in detail
Case 1 — root-relative URL (publicUrl: /uploads)
The most common production setup. Your build directly serves files from
the storage's path at a known URL.
storages:
uploads:
type: repo
path: public/uploads
publicUrl: /uploadsPick hero.jpg, frontmatter gets "/uploads/hero.jpg". The deployed
site serves that URL natively because:
- Astro / Next.js / Hugo:
public/,static/, etc. are served at the site root. - SvelteKit:
static/is served at the site root. - Eleventy:
passthroughFileCopyshipspublic/to root.
Templates just write <img src={frontmatter.hero} /> and it works.
Case 2 — full external URL (publicUrl: https://cdn.example.com)
For sites fronted by a CDN. The storage's bytes still live in the repo
(at path:), but the frontmatter records the deployed CDN URL.
storages:
cdn:
type: repo
path: assets
publicUrl: https://cdn.example.comPick hero.jpg, frontmatter gets "https://cdn.example.com/hero.jpg".
The browser fetches directly from the CDN — your origin never serves
the bytes, even though the source of truth is in your git repo.
This is the canonical "git as source of truth, CDN as distribution"
pattern. Pair it with your CI uploading the assets/ directory to the
CDN on every commit.
Case 3 — build-imported (publicUrl empty)
For assets that DON'T have a stable public URL — they get processed by the build (transformed, fingerprinted, optimised). The frontmatter records the repo-rooted path with a leading slash so the build tool can pick them up.
storages:
icons:
type: repo
path: src/assets/icons
# no publicUrlPick check.svg, frontmatter gets "/src/assets/icons/check.svg".
The leading slash is a UI convention for "this is a repo-root path" —
your template is expected to import it.
Astro
---
import { Image } from 'astro:assets';
import iconUrl from '../..' + frontmatter.icon + '?url';
---
<img src={iconUrl} />Or with the <Image> component for optimisation:
---
import { Image } from 'astro:assets';
import iconImg from '../..' + frontmatter.icon;
---
<Image src={iconImg} alt="" />Vite (SvelteKit, Solid, Vue)
<script>
import iconUrl from /* @vite-ignore */ '../..' + frontmatter.icon + '?url';
</script>
<img src={iconUrl} alt="" />Hugo
{{ with .Params.icon }} {{ $img := resources.Get . }}
<img src="{{ $img.RelPermalink }}" alt="" />
{{ end }}Why the leading slash matters
The leading slash on a no-publicUrl value is the disambiguator:
/src/assets/icons/check.svg— repo-rooted path, build-imported.src/assets/icons/check.svg(no slash) — would be ambiguous: is it repo-root-relative? page-relative? frontmatter-relative? The CMS picks one and sticks to it.
On the read side, the CMS treats leading-slash paths as
repo-rooted when they match a declared storage's path:.
Otherwise it falls back to prepending publicDir (the
pre-storages behaviour, so /favicon.png still resolves to
public/favicon.png on sites that never migrated).
Read-side resolution
The CMS recovers the storage-relative key from a frontmatter value in a fixed order:
- External URL (
http://,https://,data:) → matches only when the storage has a CDN-stylepublicUrlAND the value starts with it. Otherwise the value is treated as external and passed through unchanged. publicUrlset → strip thepublicUrlprefix.publicUrlempty → strip the leading/path/prefix.- No match → the value is rendered as-is (legacy / external).
This is how the editor's image preview works: given a frontmatter
value like /uploads/hero.jpg, the editor strips /uploads/ to
get hero.jpg, joins with the storage's path: to get
public/uploads/hero.jpg, signs a short-lived preview URL, and
renders it.
Why the publicUrl contract (and not a bare storage key)
An earlier design draft stored just the bare filename (check.svg)
in frontmatter and resolved through the field's storage: config at
render time. We rejected it because:
- Leaky abstraction. Every consuming component would need
CMS-storage knowledge to render a value. Today a template just
writes
<img src={frontmatter.hero} />— nothing CMS-specific. - Frontmatter wouldn't work outside the CMS. A hand-written value (or an LLM-generated one) couldn't be rendered without consulting the schema. The publicUrl contract makes frontmatter values self-contained.
- Migration friction. Changing a field's
storage:would silently move the URL the value resolves to. Today changingstorage:is a deliberate authoring action — the value in frontmatter still tells you exactly where the file lives.
The trade-off: changing a storage's publicUrl after content exists
means content references go stale. Compensate with a one-shot rewrite
through the editor's bulk-edit flow (or a one-time YAML pass).
Where the storage's path matters at read time
Even when publicUrl is set (so the frontmatter doesn't need path
to read), the CMS still uses path to:
- Sign preview URLs for the editor (the signer hits the repo
file at the storage's
path, not the public URL). - Scope the picker's directory listing.
- Resolve picks to their on-disk location for inline-svg fetches.
- Treat leading-slash frontmatter values as repo-rooted so
/<path>/<file>doesn't get wrongly remapped underpublicDir.
In short: the build pipeline only sees publicUrl + filename;
the CMS needs the full path: to find the bytes.