Skip to content
AstroCraft Docs
On this theme

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 setz.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: true when 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.

NEXT STEPBlog & RSS