Content Collections
Finly 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.
Everything else on the site — the sixty integration connectors, the nine careers adverts, the pricing tiers, the team — is typed config, not a collection. The line between the two is drawn on one question: does the entry have a prose body? A blog post and a customer story do — three h2 sections, a pull quote, a reading rail. An integration page has no body at all: it is a one-sentence summary, two lists of short rows, two paragraphs and a link, every one of them a named field the layout positions. A collection there would buy Zod validation over content TypeScript already types, and cost sixty files with nothing in them.
The layout on disk
src/data/
├── blog/en/<slug>/index.md # entry id => "en/<slug>"
├── customers/en/<slug>/index.md # entry id => "en/<slug>"
└── authors/<slug>/index.md # entry id => "<slug>" — no locale folder
Each entry is a folder whose name is the URL slug, holding an index.md (or .mdx — @astrojs/mdx is wired, so a post can use components). The folder shape is what lets an entry keep its images beside it.
blog and customers are per-locale: the locale segment in the id is what filterCollectionByLanguage(entries, "en") splits on. It keeps the matching entries and trims the prefix, so the rendered slug is just <slug>. The loader glob is **/[^_]*{md,mdx}, so a filename starting with _ is ignored — a working draft can sit in the tree without building.
authors is deliberately not per-locale. An author is a person, not a translation: the same byline in every language. A locale folder would mean maintaining one copy of each author per language behind a reference("authors") that resolves by slug anyway.
Thirteen posts, five authors and ten customer stories ship.
blog
{
title: string;
description: string; // the card deck AND the meta description
standfirst: string; // the post page's larger opening paragraph
authors: reference("authors")[];
pubDate: Date; // string or date, coerced
updatedDate?: Date;
heroImage: image(); // REQUIRED
imageAlt: string;
category: string; // exactly one
draft?: boolean;
}
Four of those decisions are worth the paragraph they cost.
description and standfirst are both required and are not the same string. The design draws them as different things — the deck sells the card in one clause, the standfirst opens the piece — and collapsing them makes one of the two wrong. description does double duty as the meta description, which is the one-string-two-jobs rule the theme applies deliberately: a post whose summary differs from its search snippet is two things to keep true instead of one.
heroImage is required, against the usual advice to make images optional. Every blog node in the design draws a cover, and the SEO rule wants one OG image per post. Because it goes through image(), the entry is bundled ImageMetadata — which is how BaseHead can emit real og:image:width and og:image:height rather than guessing at 1200×630.
category is one string, not an array. The design draws exactly one everywhere it appears: the cover pill, the filter chip, the breadcrumb, the “More from …” heading. An array would make a post with zero or three categories representable when neither can render.
authors is an array of references, so a co-authored post is expressible — but the post route takes authors[0] as the byline, because getArticleSchema takes one author. Making a real co-authored post work means growing the byline and the schema builder together.
authors
{
name: string;
avatar?: image(); // optional, and the design depends on it being so
role: string;
about: string;
email: string;
authorLink: string;
}
avatar is optional on purpose. The featured card and the post header draw a monogram — two letters from the name, via initials() in @js/postFacts — and only the author-bio card draws a photograph. An author with no portrait falls back to initials everywhere, which is the design’s own treatment of one of the five sample authors.
customers
The largest schema in the theme, and the one where “nothing derivable is stored” is most visible.
{
title: string; // the story page's H1; \n is a designed line break
description: string; // the card's story line AND the meta description
standfirst: string;
client: string;
logoMark: "square" | "circle" | "none"; // default "square"
wordmark?: string; // defaults to `client`
industry: string; // exactly one
headcount: number;
financeHeadcount: number;
location: string;
systemsReplaced: string;
timeToValue: string;
resultFigure: string; // the card's big figure — a string, not a number
metrics: { figure: string; label: string }[]; // exactly three
productShot: { title; body; linkLabel; linkHref };
heroImage: image();
imageAlt: string;
pubDate: Date;
updatedDate?: Date;
draft?: boolean;
}
.length(3) on metrics is the schema doing design work. The featured card’s metric row and the facts band both draw three tiles. A story with two or four renders as a broken row, and .length(3) is what makes that unrepresentable rather than merely unlikely.
resultFigure is a string rather than a number because half of the sample figures are transitions — “Day 19 → day 4” — which no number type can hold.
headcount and financeHeadcount are stored; everything built from them is not. The card’s “Marketplace · 610 people” meta line, the facts rail’s “COMPANY SIZE” sentence and the band the size filter groups by are all computed in @js/customerUtils. A sizeBand: "medium" field would be a third copy of the same number that can disagree with the first two.
logoMark and wordmark are a shape and a casing, not an asset. The logo wall draws ten client marks, and ten identical grey squares would be a wall of one logo — so the shape is content. But these are invented companies, and an invented SVG trademark is a bigger claim than a placeholder, so the schema stores which silhouette to draw and how to set the name. wordmark exists only for the four marks the design draws in capitals.
Reading a collection
Never call getCollection directly from a component. Each collection has a helper module that owns the filtering, the sort and the slug arithmetic:
import { getPosts, postHref, postSlug } from "@js/blogUtils";
import { getStories, storyHref, storySlug } from "@js/customerUtils";
const posts = await getPosts(locale); // published only, newest first
const stories = await getStories(locale); // published only, newest first
Both drop draft: true entries, filter to the locale, trim the id prefix and sort by pubDate descending. Both also expose a *BySlug lookup that throws naming the slug rather than returning undefined, so a broken reference fails the build instead of rendering an empty page.
Dates are formatted with formatDate(date, locale) from @js/textUtils, which goes through Intl using the BCP-47 tag in localeMap — never a hand-rolled format string.
Adding an entry
- Create
src/data/blog/en/my-post/index.md. The folder name is the URL slug. - Fill the frontmatter. Everything not marked optional above is required, and the build will tell you which field is missing.
- Put the cover beside it and reference it relatively —
heroImage: ./cover.jpg— or point at a shared file undersrc/assets/images/. Either way it goes throughastro:assets. - Reference authors by their folder slug:
authors: [iris-bakker]. pnpm build. A schema error names the entry and the field.
There is nothing to register. The routes derive their paths from the collection, the archives derive their taxonomies from the entries, the RSS feed and the sitemap pick it up, and the header’s “By topic” and “By industry” mega-menu columns regenerate from the live set — because they are declared as source columns rather than as hand-written rows.
Growing the schemas
Two extensions are anticipated in the file itself.
To pair a post across languages for a content-aware language switcher, add mappingKey: z.string().optional() to the blog schema and give both translations the same value.
To add a collection, define it in src/content.config.ts, add the folder under src/data/, and write its helper module in src/js/ — keeping the pure arithmetic in a *Facts file so it can carry a runnable check, and the route-aware half in a *Utils file beside it. Then give the new page type its own JSON-LD builder and its own llms.txt line; the SEO rule requires both, and SEO & Structured Data explains why.