Content Collections
Urbic has three content collections, defined in src/content.config.ts with Zod. Entries live one folder deep under src/data/, and the folder name is the slug:
src/data/
├── projects/<slug>/index.md 9 case studies
├── blog/<slug>/index.md 7 journal notes
└── authors/<slug>/index.md 1 byline
Because the schemas are Zod, a mistake in frontmatter fails the build with the entry named, rather than rendering a page with a hole in it. That is also what makes the CMS safe: src/admin.config.ts can only ever name fields these schemas already declare, so there is no second schema to drift from this one. How the schema becomes the admin covers which Zod type turns into which control.
Both .md and .mdx render — @astrojs/mdx is already wired in astro.config.mjs.
projects
The largest schema in the repo, and the one that carries the whole of a case study. A project entry’s frontmatter holds title, year, a short location for the card caption and a locationFull for the hero, a category of homes, apartments or hospitality, an order, a cover image with coverAlt, a statement, a completed string, an areaValue and areaText pair, a scope, a materials array of label/value rows, a plan image with alt and caption, and a gallery of image/alt/caption objects.
The body is the project story — plain paragraphs rendered as prose. Everything a section needs in order to lay out (the facts, the material schedule, the gallery) is frontmatter, because it is data, not prose.
order is load-bearing and unique. It fixes which slot a project takes in the /work/ grid and who its neighbours are in the detail page’s prev/next pager — one field, so the two can never disagree. src/js/projects.ts throws on a duplicate rather than letting two projects claiming slot 4 silently swap on every build, since the loader’s own order is filesystem order.
areaValue is unitless on purpose: it is the oversized numeral in the hero, and the label beside it supplies the “M²”. areaText is the fact row’s own phrasing (“78 m² on one floor”), which is why both exist.
blog
Seven notes. title, description, an authors array of references into the authors collection, pubDate, an optional updatedDate, a heroImage, a non-empty categories array, and an optional draft flag.
Two of those are required where a generic starter would leave them optional, and both tightenings are deliberate. heroImage is required because it is the card photo, the post hero and the OG image — a note without one is a hole in the index grid, not just a missing social preview. categories is required and non-empty because the filter strip and every meta row are projections of it, so an untagged note would be a card with a blank chip that leads nowhere.
authors is a required reference("authors"), which is why the authors collection is mapped in admin.config.ts even though its entries appear on no page of their own — a collection the CMS could not add to would be a required field no author could satisfy without leaving the CMS and writing a file by hand.
The rules every journal route applies live in src/js/blog.ts rather than inline in three .astro files: the published notes newest-first (drafts dropped there, so one cannot leak into a related-notes grid), the topics the notes actually use, the notes related to one note, and a reading time. The helpers are dependency-free and typed structurally, so pnpm test can run them under plain Node.
authors
One entry, and a small schema: name, an optional avatar, an optional role (the line under the name in a note’s author block), about, email and authorLink. It is a reference target rather than a page type — there is no /authors/ route — which is why its admin.config.ts mapping has no publicPath and no dateField, and why the editor draws no Preview button for it.
Adding an entry
Create a folder under the collection with an index.md, fill in the frontmatter, and it exists. Images referenced from frontmatter are relative paths into src/assets/images/<collection>/, which is what routes them through Astro’s asset pipeline and gets them optimized. See Images for the two asset directories and which one a photo belongs in.
From the CMS, the same thing happens through /admin/work/ or /admin/blog/ — New Entry writes the folder and the frontmatter, and the commit lands in your repo. Working with entries is the operator’s side of that.
Adding a collection
Three edits. Define the collection in src/content.config.ts, add its folder under src/data/, and — if the CMS should manage it — add a block to collections in src/admin.config.ts naming which of its fields play the title, date, description and social-image roles. The admin sidebar generates its Sections group from that config, so the screen and the route arrive together rather than as two lists that agree by hand.
One trap worth repeating from admin.config.ts’s own notes: dateField is parsed with new Date(value). A numeric year sorts every entry into 1970. Point it at a string date, the way projects points at completed rather than year.
The services data in src/config/servicesData.json.ts is shaped as a collection-in-waiting — each service is exactly what a src/data/services/<slug>/ frontmatter would carry, so the day you promote it the section swaps an array for getCollection("services") and the card does not change. See Services.