A cinematic neon illustration contrasts a cluttered traditional CMS setup with a clean, Git-versioned file system containing posts, tags, categories, and authors, representing Astro Content Collections as a type-checked CMS without a traditional CMS.

Astro Content Collections as a Typed CMS (No CMS Required)

Introduction

This blog has no CMS. No database, no admin panel, no monthly bill, no login page for someone to brute-force at 3 a.m. Every post, tag, category, and author is a plain file sitting in a folder in the repo—and when I get one of them wrong, TypeScript yells at me before the site ever builds.

TL;DR: a CMS (content management system—the WordPress-shaped thing that stores your posts in a database and gives you an editing UI) is really a bundle of features, and the feature that matters most is a content model: a definition of what a valid post is and how it relates to tags, categories, and authors. Astro’s Content Collections give you that model with three pieces—defineCollection, a Zod schema, and reference()—applied to ordinary files. By the end of this post you’ll see how a folder of Markdown and JSON becomes a typed, relational content layer, because that’s literally how the thing you’re reading right now works.

The Shape of the Problem

If you peel a blog down to the bones, you really only need four things. First, you need posts with some structure—think title, date, body, and a short description. Second, you need a way to tag and categorize those posts, so you can reuse the same tags across lots of entries. Third, you need a record of who wrote what. And finally, you need the links tying all those pieces together. Imagine a post as a little note that points to its tags, and a tag as a label that can stick to any number of posts. Change the name of a tag once, and every post using it should update automatically. That’s the dream.

The traditional answer is a CMS, and the CMS answer comes with a bill. Not always a literal one—though often a literal one—but always an operational one: another service to run, another login to secure, another database to back up, and a slow drift between the content living in that database and the code living in your repo. You deploy version 2 of the site, the CMS still holds fields shaped for version 1, and now you’re writing migration scripts for blog posts. I just wanted to write blog posts.

Think of it as the difference between a restaurant point-of-sale system and a recipe box. The POS system is genuinely powerful—multiple users, permissions, reporting—and it costs money every month and breaks in ways that require a support call. The recipe box is a set of index cards in a drawer. Same dishes come out of the kitchen. And because my recipe box lives in Git, every card is versioned: I can see exactly when I changed a recipe and roll it back when the new version is worse.

Astro takes the recipe box idea and adds one important twist: before any card goes in the drawer, there’s an inspector who checks it over for mistakes. So you get the simplicity of index cards, but with someone making sure you don’t accidentally file a recipe for disaster.

Defining a Collection (and a Confession About Loaders)

Everything about your content model now lives in a single file: src/content.config.ts. I’ll be honest with you before we dive in. My first draft of this post used the old API, where you had to set type: 'content' for Markdown and type: 'data' for JSON, all in src/content/config.ts. That approach is history. Astro 5 brought in the Content Layer API, and Astro 6 (which is what this site uses) tossed out the old version for good. Most tutorials still walk you through the outdated setup, and so did my own notes. Turns out, even your own documentation can go stale when you’re not looking.

The new setup is actually simpler. You don’t have to pick a collection “type” anymore. Now, each collection just declares a loader that tells Astro where to find its entries. The old split between content and data didn’t vanish; it just packed its bags and moved into the file patterns you give the glob() loader. For example, posts use .md or .mdx files, while taxonomy uses .json. Here’s what the real post collection looks like on this site:

const post = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/post' }),
schema: ({ image }) =>
z.object({
title: z.string(),
isDraft: z.boolean(),
featured: z.discriminatedUnion('isFeatured', [
z.object({
isFeatured: z.literal(true),
featuredImage: image(),
featuredImageAlt: z.string(),
}),
z.object({
isFeatured: z.literal(false),
}),
]),
// Frontmatter dates arrive as strings under the Content Layer loader, so
// coerce rather than requiring an already-parsed Date.
publishDate: z.coerce.date(),
authors: z.array(reference('author')),
categories: z.array(reference('post-category')),
tags: z.array(reference('post-tag')),
shortDescription: z.string(),
image: image().optional(),
imageAlt: z.string().optional(),
technologyUsed: z.array(reference('technology-used')).optional(),
}),
});

If you read it from top to bottom, you get a recipe for what makes up a post: there’s a title, a draft flag, a featured variant (I’ll get to that in a second), a publish date, some arrays that point to other collections, a short description, and maybe a picture or two if you’re feeling fancy. The image() helper is Astro’s way of double-checking your work. It makes sure the path you give actually leads to a real image file and hooks it up to Astro’s image optimization. If you mess up the path, the build fails right away instead of quietly shipping a broken image tag to your live site.

Think of the schema as your building code, and Astro’s build process as the city inspector. Every time you build the site, the inspector goes room by room—file by file—making sure everything matches the blueprint. If you misspell a field, forget a description, or put in a date that isn’t actually a date, the build stops right there on your computer. Nobody moves in until it’s fixed. Now, if you skip this and go untyped, it’s like building without permits: the site deploys fine, but three weeks later, the porch collapses when a page tries to render undefined. The inspector can be a pain, but it’s the kind of pain that keeps you out of real trouble.

There are two little Zod quirks worth calling out. First, when you’re using the Content Layer loader, the dates in your frontmatter show up as strings. To handle that, the schema uses z.coerce.date(). I picture this like a mail slot that takes in crumpled envelopes (the strings) and hands you back a neatly flattened letter (a real Date object). Second, Astro 6 comes with Zod 4, and Zod 4 took away z.string().date(). So, in another part of the config, a firstUsed field that used to depend on that now checks the ISO date format with a regex instead.

// Zod 4 (shipped with Astro 6) removed `z.string().date()`; validate the
// ISO date shape with a regex instead.
firstUsed: z.string().regex(/^\d{4}-\d{2}-\d{2}/, 'Expected an ISO date (YYYY-MM-DD)'),

For comparison, here’s an entire JSON-backed collection. Tags don’t need much:

const categorySchema = z.object({
name: z.string(),
});
const postTag = defineCollection({
loader: glob({ pattern: '**/*.json', base: './src/content/post-tag' }),
schema: categorySchema,
});

Same defineCollection, same Zod, same inspector—just pointed at JSON files instead of Markdown.

Relationships with reference()

This is the part that makes files feel like a real CMS instead of a pile of Markdown. reference('post-tag') says “this field doesn’t hold a value—it holds a pointer to an entry in another collection.”

I think of this system like a library card catalog. A post does not lug its tags around like a tourist with a suitcase full of souvenirs. Instead, it just carries a call number. The card might say 941.5 DEN, but the actual book sits on a shelf in one spot. There is only one book, and it lives in one place, but any number of cards can point to it. The important part is what happens when a call number points to an empty shelf. The librarian spots the mistake when you check in the book. If I write tags: [typos] and there is no post-tag/typos.json on the shelf, the build fails and tells me exactly which entry is missing. In a string-based system, that typo would quietly create a tag page with just one lonely post, and it would stay there forever. I have seen this happen on my old WordPress blogs. They collected orphaned tags like dust bunnies under a couch.

Here’s what the relationship looks like from both ends. The frontmatter of my two-tier CSS custom properties post carries the call numbers:

featured:
isFeatured: false
authors:
- kevin-dench
tags:
- css
- custom-properties
- design-systems
- design
- webdev

And a tag entry—the book on the shelf—is a one-line JSON file. Here’s src/content/post-tag/astro.json in its entirety:

{ "$schema": "../../../.astro/collections/post-tag.schema.json", "name": "Astro" }

That $schema pointer is a nice bit of free lunch: Astro auto-generates JSON Schema files for each collection into .astro/collections/, and pointing the JSON file at one gives your editor autocomplete and red squiggles inside the data files themselves. The inspector’s checklist, taped inside the drawer.

One naming note that bites people migrating from older Astro: entry.slug is gone. The glob loader derives each entry’s id from its file name, and that id is both what reference() resolves against and what my dynamic routes use as the URL slug. File name, reference target, and URL are the same string by construction—one less thing to keep synchronized.

The Clever Bit: Discriminated Unions for Variants

Look back at the featured field in the post schema. The obvious design has three optional fields—isFeatured, featuredImage, featuredImageAlt—and a stern comment reminding future me that the last two are required when the first is true. Stern comments are not a validation strategy.

z.discriminatedUnion('isFeatured', [...]) defines two distinct shapes keyed on one field. It works like a boarding pass. A pass with a checked bag must have a bag-claim stub attached; a carry-on pass can’t have one. There’s no way to print a checked-bag pass without a stub because the form won’t produce it. Here, a post with isFeatured: true must supply featuredImage and featuredImageAlt, and a post with isFeatured: false isn’t allowed to. The invalid combination—featured post, no image—is unrepresentable. I can’t forget the image because forgetting it causes a build failure with a line number.

Worth calling out: this is a TypeScript-and-Zod pattern, not an Astro trick. Anywhere you’re tempted to write “field B is required when field A is true” in a comment, a discriminated union enforces it at compile time. It’s also my standing argument for Zod over alternatives—as Type casting is a silent lie at runtime, and discriminated unions are where the lie gets expensive.

While we’re admiring constraints: schemas can encode editorial rules, not just shapes. The case-study collection on this site includes:

description: z.string().max(255),
keyOutcomes: z.array(z.string().max(120)).length(3),

Exactly three key outcomes, each under 120 characters, enforced by the build. The design has three slots, so the content model has three slots. Two outcomes won’t build. Neither will four. The layout and the data can’t drift apart, because the inspector reads the card count too.

Consuming It with Full Type Safety

All of this would be pretty useless if the types just disappeared when you actually ran a query. But they stick around. When you call getCollection('post'), you get back entries that are typed straight from your schema. For example, entry.data.title is a string, and entry.data.publishDate is a real Date object. Your editor even autocompletes everything for you, which feels like magic every time.

What really makes me smile is how the discriminated union just works its way through the whole thing. Here’s the actual query from this site’s posts listing page. It’s the same file where I once got lost in the weeds and wrote an entire saga about pagination, if you remember that adventure.

const featuredPost = await getCollection('post', (entry) => entry.data.featured.isFeatured && publishedPosts(entry));

Inside that filter callback, once TypeScript sees entry.data.featured.isFeatured is true, it narrows the type: it now knows featuredImage and featuredImageAlt exist on this entry. Not “probably exist.” Exist. The boarding-pass logic from the schema travels all the way into the template code, no casts, no optional chaining, no defensive if (image) checks.

Draft filtering works the same way—a schema field becomes a typed predicate:

getCollection('recommendation', ({ data }) => !data.isDraft);

And when a page holds a reference and needs the actual entry—the call number needs to become the book—getEntry resolves it, while render() (imported from astro:content in Astro 6) turns a Markdown entry’s body into a component. Query, resolve, render: the whole read path is typed end to end.

What You Give Up (Honestly)

Here’s the bit the cheerful tutorials tend to gloss over.

You won’t find an editing UI here. Every change is a commit. There’s no browser dashboard, no ‘Save Draft’ button, and no editorial workflow with roles or review queues. If you’re a solo developer who already lives in your code editor, this is actually a feature—version control is your editorial workflow. But if you need non-technical folks to publish on your site, this is a hard stop. In that case, reach for a real or headless CMS and don’t feel bad about it.

I should admit one wrinkle in my own ‘no CMS’ story: I do use a local-only editing UI called TinaCMS on top of these collections. It reads and writes the same Markdown and JSON files, but only runs on my machine when I ask for it, and it gets left out of every production build. There’s no server-side CMS lurking anywhere. The key point is this: since the schema-first files are the real source of truth, an editing UI is just a handy add-on, not a critical piece you have to keep running. It’s like a recipe box that doesn’t care if you scribble the card by hand or use your fanciest pen.

This setup is build-time only. If you want to update content, you have to rebuild and deploy the site. If your content changes every hour, or if readers are submitting things, this approach will fight you every step of the way. For a blog, though, ‘publishing’ just means merging to main and letting continuous integration (CI) handle the deploy. The bonus: every time I publish, my tests run automatically. My CMS comes with a test suite.

Validation only goes as far as the schema. Zod, the validation library, checks the structure of your data, not the quality of your writing. It’ll spot a missing description, but it won’t flag a dull one. Think of it like a building inspector who checks the framing but doesn’t comment on your choice of wallpaper.

Conclusion

Here’s how it stacks up: defineCollection with a glob loader is like turning a messy drawer into a neatly labeled filing cabinet. Add a Zod schema, and now every file has to follow the rules—no sneaking in a crumpled receipt where a contract should be. reference() lets you link collections together, so your authors and posts can actually talk to each other, not just sit in separate silos. Discriminated unions make it impossible to create a file that doesn’t fit any category, like a bouncer at the door turning away anyone without the right wristband. Put it all together and you’ve got structured content, taxonomy, authorship, relationships, and validation—the heavy-duty features of a content management system—built from plain files in a Git repo.

The meta-point I keep coming back to: “CMS” is a feature set, not a product you’re obligated to install. For a developer-owned site, files plus a schema cover most of the feature set with none of the operational overhead, and the features you lose (editing UI, instant publishing) are precisely the ones a solo developer uses least. When those features matter, buy them. When they don’t, a card catalog and a strict inspector go surprisingly far.

Every claim in this post has been fact-checked by the strictest reviewer I know: the build that published it.