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: falseauthors: - kevin-denchtags: - css - custom-properties - design-systems - design - webdevAnd 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.