A blog is a folder of Markdown files
How this site is put together: content collections, a schema that fails the build, and one command to publish.
The entire site is src/content/blog/*.md. No CMS, no database, no admin panel. A post is a file with some front matter, and publishing is:
npm run deployThe schema is the contract
Every post is validated against a schema at build time. This one lives in src/content.config.ts:
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.md' }),
schema: z.object({
title: z.string().min(1).max(120),
description: z.string().min(1).max(200).optional(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
featured: z.boolean().default(false),
hero: z.string().optional(),
}),
});Misspell pubDate as pubdate and the build stops with an error instead of publishing a post that silently has no date:
InvalidContentEntryDataError: **frontmatter** does not match collection schema.
**pubDate**: RequiredThat is the whole reason to bother with a schema. A blog is not a place to be flexible about metadata.
Drafts are free
draft: true is visible in astro dev and dropped from production builds:
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.PROD ? data.draft !== true : true,
);Write the post, look at it locally, flip the flag when it is finished.
Front matter reference
| Key | Required | Notes |
|---|---|---|
title | yes | Max 120 characters. |
description | no | Falls back to the site description. Used for meta, cards and RSS. |
pubDate | yes | ISO date. Drives ordering, sitemap lastmod and the RSS feed. |
updatedDate | no | Adds an Updated line and a second JSON-LD date. |
tags | no | Powers /tags/ and the topic pages. |
draft | no | Defaults to false. |
featured | no | One post gets the big card on the home page. |
hero | no | Path under public/. Always set heroWidth/heroHeight too. |
What the build produces
- A static page per post, no server at runtime
sitemap-index.xml, generated from the collectionrobots.txt, with the sitemap URL derived fromsiterss.xmlin the format every reader understands- One 1200×630 OpenGraph image per post, rendered at build time
llms.txtand per-page.mdcopies, for AI crawlers
All of it lands in dist/ and gets uploaded in one shot. There is no database to back up, because there is no database.
A note on astro build
Astro 7 renders Markdown with Sätteri, its own native pipeline, instead of the old remark/rehype stack. GitHub-flavoured Markdown and smart punctuation work out of the box. If you reach for a remark plugin, you have to move it to a Sätteri plugin or opt back into unified().