Content Collections
Urengi has four 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 | Location | Ships | Holds |
|---|---|---|---|
blog |
src/data/blog/ |
8 | posts, with prose bodies |
authors |
src/data/authors/ |
4 | bylines, referenced by posts |
caseStudies |
src/data/case-studies/ |
7 | customer stories, with prose bodies |
integrations |
src/data/integrations/ |
24 | directory entries — the one with no body |
How an entry is laid out
One folder per entry, directly under the collection directory, holding an index.md (or .mdx, which @astrojs/mdx renders). The folder name is the slug, and it is the entry id:
src/data/blog/soc-2-type-ii/index.md → id "soc-2-type-ii" → /blog/soc-2-type-ii/
src/data/case-studies/fold-systems/ → id "fold-systems" → /customers/fold-systems/
The glob pattern is **/[^_]*{md,mdx}, so a file whose name starts with an underscore is skipped — that is where a draft-in-progress can sit without being an entry at all.
Adding an entry is adding a folder. Deleting one deletes the route, its listing card, its feed item and its llms.txt line together, and the build fails loudly if you leave a dangling reference("authors") behind.
Images referenced from frontmatter go through Astro’s image() helper, which means the path is resolved and the file must exist — a typo is a build error, not a broken <img>. Paths are relative to the entry file, which is why they read "../../../assets/images/blog/…".
blog
---
title: "Urengi achieves SOC 2 Type II certification and upgrades its trust center"
description: "True risk management begins at home. We're proud to announce…"
authors: ["zoe-harrington"]
pubDate: "2026-01-29"
heroImage: "../../../assets/images/blog/soc-2-type-ii.jpg"
heroImageAlt: "A framed certificate standing on a desk in a warm-lit office"
category: "Company News"
---
Optional beyond that: updatedDate, featured, draft.
Four decisions in this schema are tighter than a starter’s, and each has a reason:
authors is .min(1), and that bound is load-bearing rather than decorative. postAuthor reads authors[0]!, and reference() only rejects an id that does not resolve — an empty array resolves nothing and passes. Without the bound, the non-null assertion is a lie that authors: [] makes true.
heroImage and heroImageAlt are both required. Every listing card, the post’s own banner and its Open Graph image are the same picture, so a post without one is a hole in three places at once. The alt text is required beside it because an optional alt is an alt nobody writes.
category is exactly one label from a closed set — z.enum(CATEGORY_LABELS), imported from blogData. It was an array of free strings in the starter; the design has one chip per card and a fixed tab row, so an array would only ever be read at index 0 and a free string would let a typo quietly fall out of every filter.
featured is a flag, not “the newest post”. The design features a January post above six newer ones, so recency is demonstrably not the rule. Zod cannot see across entries, so pickFeatured throws at build time when two posts claim it rather than picking one silently.
authors
---
name: "Sarah Jenkins"
avatar: "../../../assets/images/authors/sarah-jenkins.jpg"
role: "Head of Compliance"
about: "Sarah leads compliance at Urengi. She spent eight years…"
email: "[email protected]"
authorLink: "#"
---
All six fields are required. avatar for the same reason heroImage is: the byline draws a portrait in five places.
Posts reference authors by id through reference("authors"), so a post naming an author folder that does not exist fails the build with both names in the message.
caseStudies
A second collection rather than a type: "case-study" flag on blog, because almost nothing is shared. A story has an industry where a post has a category, a customer contact where a post has a site author, and three required structured blocks that would have to be optional-and-unused on every post. One schema serving both would validate neither.
---
title: "Six weeks of email, or two days: how Fold Systems rebuilt vendor review…"
description: "Fold Systems ran 240 vendor reviews a year out of a shared inbox…"
industry: "B2B SaaS"
company: "Fold Systems"
resultMetric: "41 → 2 days"
contact:
name: "Marc Delhaye"
role: "IT Operations, Fold Systems"
avatar: "../../../assets/images/testimonial-marc-delhaye.jpg" # optional
pubDate: "2026-01-22"
heroImage: "../../../assets/images/case-studies/fold-systems.jpg"
heroImageAlt: "Three members of the Fold Systems IT Operations team…"
results:
eyebrow: "Results after six months"
metrics: # exactly three
- value: 2
from: 41
prefix: "41 → "
suffix: " days"
label: "Median time from request to decision, down from 41 working days"
caption: "Measured across 118 reviews between July 2025 and January 2026…"
atAGlance: # at least one row
- label: "Industry"
value: "B2B SaaS · payroll infrastructure"
about: "Fold Systems builds payroll infrastructure for European employers…"
---
Three of these deserve a note.
resultMetric is a string, deliberately. The six the design draws are four different shapes — a percentage delta, a count, a ratio, a duration — and the only thing they have in common is that a human wrote them to be read at a glance. Modelling that as value + unit + direction would be a schema for a spreadsheet, not for a card.
results.metrics is exactly three, because the grid is built for three, and its shape is asserted with satisfies z.ZodType<StoryResults>. That assertion is the load-bearing part: the same figure shape is also declared as typed config for the index’s aggregate band, and one component renders each. The interface is the single declaration; the schema asserts it still describes the frontmatter, and fails the build when it does not.
contact.avatar is optional, unlike a post’s author portrait. Only the flagship story ships with a real photograph; the rest fall back to the monogram circle the cards draw — which is honest, where inventing six portraits of people who do not exist is not.
integrations
A third collection rather than typed config, even though an integration has no prose body at all. The deciding property is not “does it have markdown”, it is “is it a row a buyer edits and can get wrong”: twenty-four entries, each naming a category from a closed set, a logo file that must exist, and three scope lists. As one config array it would be roughly 1,300 lines that nothing validates.
The body is empty on every entry because the design draws no prose on either frame — every block is structured. The entries keep the same <slug>/index.md layout so all four collections are read the same way.
| Field | Shape |
|---|---|
name |
the vendor’s own spelling — “Microsoft Entra ID” |
category |
one of nine labels from integrationData |
logo |
a file stem under src/assets/logos/ |
summary |
the one line the directory card draws |
description |
the detail page’s lead, reused as its meta description |
tags |
up to three pills beside the H1 |
spec |
label/value rows for the spec panel, at least one |
order |
reading order across the whole directory |
featured |
one of the three “most connected” cards |
stats |
optional — exactly four figures for the amber strip |
capabilities |
exactly three cards, each with a validated icon name |
permissions |
reads, writes and never, each at least one |
setup |
optional per-entry override of the shared four steps |
showcase |
optional, from a closed union of built mocks |
logo is not derived from the entry id. microsoft-entra-id happens to ship its mark under the same stem, but a vendor whose slug and asset disagree would otherwise be unrepresentable; integrationData.test.ts asserts every stem has a file on disk.
capabilities.cards[].icon is validated here, against the live icon registry, rather than where it is rendered — and that placement is the whole point. A bare z.string() widens the name back to any string, and an unknown name resolves to undefined, so the card ships with a silent empty 24×24 hole that the build, the types and astro check are all perfectly happy with. As an enum it is a build failure naming the offending entry file.
stats is optional as a content decision rather than a technical one: four measured figures per integration is four claims, and the design supplies them for one page. The three featured entries ship them; the rest render without the strip rather than with invented telemetry.
Four invariants Zod cannot express
Each of the following is valid frontmatter on every individual entry, and wrong only as a relationship between entries. assertDirectory (in @js/integrations) runs once, on the directory route, and throws — so each is a failed pnpm build rather than something that ships:
- two entries claiming the same
order; - a fourth entry setting
featured: truewhen the row draws three; - a category label with no entries in it, which would build an empty filter page;
- an entry naming a category that is not in the label set.
The blog has one of its own: pickFeatured throws when two posts claim featured.
Reading a collection
Never call getCollection directly in a route. @js/collections holds the three published-entry readers, and they are the one definition of what a draft is:
const posts = await publishedPosts(); // blog, non-draft
const stories = await publishedStories(); // caseStudies, non-draft
const integrations = await publishedIntegrations(); // integrations, non-draft
The predicate was inlined at six call sites once. The failure mode is not a broken page — it is a draft that stays hidden on the listings and quietly ships in the RSS feed, where nobody is looking.
Sorting is separate and lives in @js/listing: sortByDate for the two dated collections, sortByOrder for the authored directory.