Content Collections
Olsa ships no CMS. Content is markdown on disk, validated by Zod schemas in src/content.config.ts, and read through a handful of typed helpers in src/js/. Bad frontmatter fails the build with the entry named, which is the feature — a missing image or a broken author reference never reaches a browser.
Three collections ship: blog, authors and integrations.
Where entries live
Entries are flat files directly under their collection directory. The entry id is the filename without its extension:
src/data/blog/why-teams-switch-from-zapier-to-olsa.md → id "why-teams-switch-from-zapier-to-olsa"
src/data/authors/sarah-jenkins.md → id "sarah-jenkins"
src/data/integrations/slack.md → id "slack"
The glob pattern is **/[^_]*{md,mdx}, which has two consequences. A file prefixed with _ is ignored, which is a convenient way to park a draft outside the schema entirely. And while the pattern would happily match a nested <slug>/index.md, doing so yields the id <slug>/index — so do not nest. Both .md and .mdx render, because @astrojs/mdx is installed.
The id is the URL slug. src/data/blog/olsa-raises-12m-series-a.md becomes /blog/olsa-raises-12m-series-a/.
The blog schema
{
title: z.string(),
description: z.string(),
authors: z.array(reference("authors")),
pubDate: z.string().or(z.date()).transform((val) => new Date(val)),
updatedDate: z.string().optional().transform((str) => (str ? new Date(str) : undefined)),
heroImage: image(),
categories: z.array(z.string()).optional(),
draft: z.boolean().optional(),
}
Four things about it are worth knowing before you write your first post.
heroImage is required, not optional. Every post needs it twice: as the card thumbnail on /blog/ and as the og:image on the post page. Because it goes through Astro’s image() helper, the value is a path relative to the markdown file and the result is ImageMetadata with real intrinsic dimensions — which is how BaseHead emits accurate og:image:width and og:image:height instead of falling back to the 1200×630 convention.
authors is an array of references, not strings. reference("authors") means the referenced slug must exist in the authors collection or the build fails naming the offending entry. Only the first author is rendered — it drives the byline, the bio card and the JSON-LD author — but the field is an array so a co-authored post keeps its full attribution in the data.
Dates accept a string or a Date and are transformed to a Date. pubDate: "2024-10-08" in YAML is fine. Everything downstream (sorting, RSS pubDate, article:published_time, the visible byline) works on a real Date object.
draft: true removes a post from everything. It is filtered at the source in getPublishedPosts(), so a draft is absent from the index, from getStaticPaths (no page is built), from the related-posts grid and from the RSS feed at once.
A complete entry:
---
title: "Olsa raises $12M Series A led by Vertex"
description: "A milestone in our journey to automate the busywork."
authors: ["sarah-jenkins"]
pubDate: "2024-10-08"
heroImage: ../../assets/images/blog/series-a.jpg
categories: ["Company"]
---
Today we're announcing our $12M Series A…
The authors schema
{
name: z.string(),
role: z.string(),
avatar: image().optional(),
about: z.string(),
email: z.string(),
authorLink: z.string(),
}
role is the job title shown beside the name in the post hero and the bio card. authorLink is where the byline points — the shipped authors all point at /about/, which is where the team section lives; a per-author page would be a route you add. avatar is optional, so an author with no photo is valid and the byline degrades to name and role.
Six sample authors ship, matching the team photos in src/assets/images/.
The integrations schema
The richest of the three, because the detail page is entirely frontmatter-driven:
{
name: z.string(),
tagline: z.string(),
description: z.string(),
category: z.string(),
developer: z.string().default("Olsa Labs"),
rating: z.number().min(0).max(5),
featured: z.boolean().default(false),
image: image(),
highlight: z.object({ title: z.string(), lead: z.string() }),
requirements: z.array(z.string()).min(1),
}
tagline is the one-line card blurb; description is the hero paragraph on the detail page and the meta description. rating is bounded 0–5 and is what the directory sorts by — the detail hero renders it as “4.9/5.0”. featured: true promotes an entry to the wide cards at the top of /integrations/. requirements needs at least one item; an empty array fails the build.
highlight supplies the heading and lead of the detail page’s highlight section, and the markdown body becomes that section’s paragraphs. This is the only collection where the body is not the whole page.
One convention lives outside the schema and will bite you if you miss it: the entry id must also match a logo file at src/assets/logos/<id>.svg. integrationUtils.ts globs that directory eagerly and throws a named error if the glyph is missing:
No logo for integration "linear" — add src/assets/logos/linear.svg
That is a build failure with the fix in the message, which is the intent.
Reading collections
Do not call getCollection directly from a route. Two helper modules own the invariants so the routes and the feed cannot disagree about what “published” or “directory order” means.
@js/blogUtils exports getPublishedPosts() — drafts filtered, newest first — and withAuthors(posts), which pairs each post with its resolved first author in parallel while preserving input order. /blog/, /blog/<id>/ and /rss.xml all read through them.
@js/integrationUtils exports getIntegrations() — sorted by rating descending, then by name — and logoSvg(id).
If you need a new invariant (posts by category, integrations by category), add it to the helper module rather than to a route. That is the pattern both files record in their own comments, and it is what stops the index and the generated paths drifting apart.
What fails, and where
At build time, with the entry named: a missing required field, a wrong type, a heroImage path that does not resolve, an author reference to a slug that does not exist, a rating outside 0–5, an empty requirements array, or an integration with no matching logo SVG.
Silently, in ways worth watching for: a draft: true you forgot about (the post simply is not there), a post with a categories array that shares no category with any other post (related posts fall back to newest-first, which is the designed behaviour, not a bug), and an author entry nothing references (valid, just unused).
Not at all: renaming a file while pnpm dev is running. Astro’s content layer caches entries and the old id can survive the rename. Restart the dev server.
Adding a fourth collection
Define it in src/content.config.ts with the glob loader and a Zod schema, create src/data/<name>/, and add it to the exported collections object. If it needs a route, follow the two existing pairs — an index route and a [id].astro detail route, both reading through a helper module in src/js/ rather than calling getCollection inline. Pages & Routing walks through what a route owns.