Content Collections
Develi has three content collections, defined in src/content.config.ts and validated with Zod at dev and build time. Bad frontmatter fails the build with the entry named, which is the feature rather than an inconvenience.
| Collection | Entries shipped | Powers |
|---|---|---|
blog |
8 | /blog/, /blog/<slug>/, category routes, RSS |
authors |
4 | bylines on posts and cards |
work |
6 | /work/, /work/<slug>/, the home rail |
How entries are laid out
Every entry is a folder whose name is its URL slug, holding an index.md:
src/data/
├── authors/amara-osei/index.md
├── blog/nine-years-in/index.md
└── work/meridian/index.md
The loader glob is **/[^_]*{md,mdx}, so the entry id is the flat slug — "nine-years-in", not "nine-years-in/index". The [^_] prefix means a file starting with an underscore is ignored, which gives you a way to park a draft outside the schema entirely.
.mdx works everywhere .md does; @astrojs/mdx is already wired in astro.config.mjs. Images referenced from frontmatter live under src/assets/ and are resolved through Astro’s image() helper, so they are optimized and carry real dimensions. Referencing an image that does not exist fails the build.
blog
---
title: "Nine years in: what a boutique studio optimises for"
description: "We have stayed deliberately small since 2017. …"
authors: ["amara-osei"]
pubDate: 2026-05-21
heroImage: ../../../assets/images/about-hero-team.jpg
categories: ["Studio Notes"]
---
| Field | Type | Required |
|---|---|---|
title |
string | yes |
description |
string | yes |
authors |
array of reference("authors") |
yes |
pubDate |
string or date | yes |
updatedDate |
string | no |
heroImage |
image() |
yes |
categories |
array of string, .min(1) |
yes |
draft |
boolean | no |
heroImage is required, and that is a considered decision rather than an oversight. It is what lets BaseHead emit real og:image:width and og:image:height from the bundled ImageMetadata, which it cannot do for a fallback image. The card grid also draws an image on every card, so an optional hero would be a hole in the layout rather than a graceful degradation.
categories uses .min(1) rather than .optional() for a similar reason. Every card in the design wears a category badge, and the filter row is built by folding this field across the whole collection — so a post with no category would be both unbadgeable and unreachable from the filter. The first entry is what the badge shows; the rest still file the post under their own filter route.
pubDate accepts a string or a date and transforms to a Date. updatedDate is optional and transforms the same way — set it when you meaningfully revise a post, because it drives article:modified_time and the dateModified in the BlogPosting node. A stale dateModified hurts more than none at all.
draft: true removes a post everywhere at once. Drafts are filtered inside getPosts() rather than per route, which is the only place it can be done once — a draft that reached the category route but not the index would be worse than one that reached neither.
authors
---
name: Amara Osei
avatar: ../../../assets/images/team/amara-osei.jpg
about: Founder and principal engineer. …
email: [email protected]
authorLink: https://x.com/yourhandle
---
name, about, email and authorLink are required; avatar is optional. Where an avatar is missing, components fall back to the author’s initials through initials() in @js/textUtils — a helper that exists because four call sites needed it and three had grown their own copy, one of which forgot to uppercase, so the same site rendered “AO” on a testimonial and “ao” on a post card.
authors on a post is an array of references, and the theme handles that split deliberately: cards take authors[0] because the card design draws one byline, and the post page lists them all. A reference to an author folder that does not exist fails the build, so there is nothing to guard against at render time.
The four shipped authors carry email: …@example.com and an authorLink pointing at a placeholder handle. Replace both.
work
The case-study schema is the most opinionated of the three, and reading it explains most of what the work section can and cannot do.
---
client: Meridian
title: Rebuilding Meridian's trading interface for real time
description: We rebuilt a legacy trading dashboard into a real-time interface …
order: 1
cardImage: ../../../assets/images/work/meridian.jpg
heroImage: ../../../assets/images/work/case-hero.jpg
gallery:
- image: ../../../assets/images/work/case-gallery-wide.jpg
alt: The rebuilt Meridian trading console on a desk of paired monitors
narrative:
- label: The challenge
title: Legacy debt meeting modern volatility
body:
- Meridian's existing infrastructure was struggling …
- The dashboard required a full architectural rethink …
quote:
text: We did not ask for a faster dashboard. We asked to trust our own screens again. …
role: Head of Trading Technology
---
client and title are two different strings and both are required. client is the card’s entire visible title — “Meridian”. title is the detail page’s <h1>, and the card deliberately does not show it.
order is a curated running order, not a date. A case study in this design has no publication date, and sorting on something that merely looks like an order would be inventing data. Give each entry a distinct number; duplicates are not detected and there is no secondary sort key. The shipped set runs 1 to 6.
The narrative is frontmatter, not the markdown body, because the design draws it as structure rather than prose: rows of label | title + paragraphs, where the label (“The challenge”) and the title (“Legacy debt meeting modern volatility”) are two halves of one heading sitting in two different columns. A body with ## headings could not express the left-hand rail. Each string in body is one paragraph — there is no parsing, and .min(1) makes an empty row unrepresentable.
gallery and quote are optional, and the page degrades cleanly without them. With no gallery the whole middle section drops out and the page’s band structure still resolves. That is why the section seams live on the sections rather than in the route shell.
The quote object carries text and role but no company, because the attribution the design draws under the role is the client name — repeating it would let one entry disagree with itself.
One YAML gotcha, and it will bite you
A plain YAML scalar containing a colon followed by a space is parsed as a mapping and fails the schema. The shipped Meridian entry hits this and escapes it with a folded block scalar:
body:
- >-
Performance benchmarks surpassed all initial targets. Median time-to-interact
dropped by 88%. More importantly, the qualitative feedback from the trading desk
indicated a profound shift: they no longer had to cross-reference with other feeds
to confirm Meridian's data.
>- is immune. Reach for it whenever a sentence contains : , which in practice means most sentences with a colon in them.
Reading collections
A route never calls getCollection and sorts inline. The queries live in src/js/blog.ts and src/js/work.ts — see Blog & RSS and Work & Case Studies for the full helper surface. The short version:
import { getPosts, withAuthors, postCategories } from "@js/blog";
import { getCases, relatedCases } from "@js/work";
Both getPosts() and getCases() drop drafts and apply the collection’s canonical sort, so every surface agrees.
Adding a collection
- Add a
defineCollectionblock tosrc/content.config.tswith a Zod schema, and register it in the exportedcollectionsobject. - Create
src/data/<collection>/<slug>/index.md. - Add a read helper in
src/js/if more than one route will query it — that is the threshold the existing two were written at. - Build the route as a thin shell that resolves data and passes typed props down to sections.
Use reference("…") to link collections and image() for anything optimizable. Both give you build-time failure on a bad value, which is the entire reason the content layer is worth using over a folder of markdown.