Content Collections
Grafio 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. Bad frontmatter fails the build with the entry named, which is the feature — a broken date or a missing image never reaches a browser.
There are three collections: work (case studies), blog (posts), and authors (the people posts are attributed to).
The on-disk shape
Every entry is a folder whose name is the URL slug, holding an index.mdx and, where the entry owns art, its own images:
src/data/
├── work/
│ └── lumen-systems/
│ ├── index.mdx → /work/lumen-systems/
│ ├── lumen-systems.jpg
│ ├── lumen-systems-alt-1.jpg
│ ├── lumen-systems-alt-2.jpg
│ └── lumen-systems-alt-3.jpg
├── blog/
│ └── easing-on-a-hover/
│ └── index.mdx → /blog/easing-on-a-hover/
└── authors/
└── simon-graham/
└── index.mdx (referenced by posts, no route of its own)
The folder name is the entry id, the slug and the URL segment in one. Renaming a folder changes the URL with no redirect, so decide slugs before you publish.
Images are referenced from frontmatter as relative filesystem paths and resolved by Astro’s image() helper, which imports them as ImageMetadata — the source of the intrinsic width/height that keeps layout shift at zero. Co-located art uses ./name.jpg; shared art climbs out to ../../../assets/images/…. Remote URLs are not accepted.
One glob detail is a useful escape hatch: the loader pattern is **/[^_]*{md,mdx}, so a file whose name starts with an underscore is ignored by all three collections. Renaming index.mdx to _index.mdx takes an entry out of the build without deleting it.
The work collection
Every field is required. There are no optionals and no defaults in this schema at all.
const workCollection = defineCollection({
loader: glob({ pattern: "**/[^_]*{md,mdx}", base: "./src/data/work" }),
schema: ({ image }) =>
z.object({
title: z.string(),
discipline: z.string(),
order: z.number(),
thumbnail: image(),
standfirst: z.string(),
year: z.string(),
stack: z.string(),
role: z.string(),
chapters: z.array(
z.object({
title: z.string(),
body: z.string(),
image: image(),
}),
),
}),
});
order is what sorts the case studies — an explicit curated sequence, not a date, because selected works are an order somebody chose. standfirst doubles as the page’s meta description. year is a string, so it must be quoted in YAML: year: "2026". Leave the quotes off and YAML parses 2026 as a number and the build fails with a type error.
chapters is the case-study body. Each entry becomes one alternating image-and-text row. The array has no minimum, so chapters: [] is valid and renders nothing.
The full Work & Case Studies page covers how chapters render and what they can and cannot contain.
The blog collection
const blogCollection = defineCollection({
loader: glob({ pattern: "**/[^_]*{md,mdx}", base: "./src/data/blog" }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
authors: z.array(reference("authors")).min(1),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: image(),
categories: z.array(z.string()).optional(),
draft: z.boolean().optional(),
}),
});
Three of these choices have a stated reason behind them.
heroImage is required, not optional. The index grid, the featured slot and the related list are all image-over-title layouts, and the hero doubles as the Open Graph image — so a post without one would be a hole in three places and a missing social card.
authors requires at least one entry. An empty array is schema-valid frontmatter that crashes the build several files away, when the route tries to look up authors[0]. The .min(1) makes it fail here instead, naming the entry.
pubDate uses z.coerce.date(), not a hand-rolled transform. An unparseable date string is rejected outright rather than becoming an Invalid Date that renders as a broken byline and then throws from toISOString() inside the JSON-LD builder.
Note that only authors[0] and categories[0] are ever displayed. Additional entries in either array are inert — there are no category archive pages and no multi-author bylines.
See Blog & RSS for how posts are ordered, how related posts are chosen, and how the feed is built.
The authors collection
const authorsCollection = defineCollection({
loader: glob({ pattern: "**/[^_]*{md,mdx}", base: "./src/data/authors" }),
schema: ({ image }) =>
z.object({
name: z.string(),
avatar: image().optional(),
role: z.string().optional(),
about: z.string(),
email: z.email(),
authorLink: z.url(),
}),
});
Authors have no route of their own; they exist to be referenced. A post’s authors list holds folder names, and reference("authors") resolves them at build time — a slug with no matching folder fails the build.
avatar and role are optional and degrade cleanly: without an avatar the byline drops the circle rather than rendering a broken frame, without a role it collapses to the name alone. email and authorLink are format-validated, and authorLink feeds the JSON-LD author.url.
about and email are required by the schema but not currently rendered by any route. Fill them anyway — the build will stop you otherwise.
Real frontmatter to copy
A work entry, taken verbatim from the shipped set:
---
title: Lumen Systems
discipline: Brand & site
order: 6
year: "2026"
stack: Figma, WebGL
role: Art Direction, Development
thumbnail: ./lumen-systems.jpg
standfirst: >-
Lumen designs architectural lighting controls — hardware that spends its life
invisible inside walls. The brief was an identity and site that make control
itself feel tangible.
chapters:
- title: A brand drawn with light
body: >-
The identity system is built from gradients calibrated to the company's own
fixtures, photographed rather than rendered.
image: ./lumen-systems-alt-1.jpg
- title: The site as a control panel
body: >-
The product pages behave like the hardware: sliders dim the page's own
lighting in real time.
image: ./lumen-systems-alt-2.jpg
---
A blog post:
---
title: "The easing on a hover: why micro-interactions matter"
description: Why custom cubic-bezier curves matter more than you think.
authors:
- simon-graham
pubDate: 2026-02-28
heroImage: ../../../assets/images/home/process-build.jpg
categories:
- Engineering
---
Body content starts here, as normal markdown.
The quoted title is necessary because the string contains a colon followed by a space.
An author:
---
name: Simon Graham
avatar: ../../../assets/images/shared/simon-graham-workspace.jpg
role: Creative Developer
about: Creative developer based in New York, building editorial interfaces.
email: [email protected]
authorLink: https://www.linkedin.com/in/simongraham
---
Drafts
Only the blog collection has a draft concept. Setting draft: true excludes a post in exactly one place — the getAllPosts() helper — and because every consumer goes through that helper, the post disappears from the index, from getStaticPaths (so no page is built), from related lists, from rss.xml and from llms.txt simultaneously.
That single choke point exists to prevent a specific failure: a draft that never appears on /blog/ but turns up in the related grid at the bottom of a real post.
The work collection has no draft field and no filter. Every entry ships. To hide a project, move it out of src/data/work/ or rename its file to _index.mdx.
What fails the build, and why that is good
Astro validates every entry during the content-layer sync, and a failure names the collection, the entry id and the field path. The traps this catches, in practice:
- An author slug in
authors:with no matching folder. - An empty
authors:array. - An unparseable
pubDate. - A
thumbnail,heroImageorchapters[].imagepointing at a file that does not exist. - An unquoted
yearin a work entry. - A malformed
emailorauthorLinkon an author.
One dev-server caveat that is not a schema problem but looks like one: Astro’s content layer caches entries, so moving or renaming a folder while pnpm dev is running can leave the old entry in place and the new one missing. Restart the dev server. Touching the file will not clear it.
Replacing the sample content
The shipped set is six work entries, four posts and one author, all referencing that author. Deleting src/data/authors/simon-graham/ without repointing the posts breaks the build.
Two constraints will decide what images you can use. Astro never upscales — every <Image> in this theme requests an explicit width, and a source narrower than the request emits the smaller file under a larger width attribute, which is wrong intrinsic dimensions in the HTML and visible layout shift. And the requested widths were chosen against the smallest bundled source, so raising them means replacing the art first.
Practical minimums, matching the shipped sources:
- Work
thumbnailandchapters[].image: 960 × 640. - Blog
heroImage: at least 800px wide (requested at 800×450 in the post hero, 800×500 in the cards). - Author
avatar: at least 74 × 74, square — it renders in a circle withobject-cover.
The Images & Assets page covers the wider picture: where files live, the format ladder, and why sizes is the caller’s business.