Skip to content
All posts

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.

2 min read

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 deploy

The 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**: Required

That 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

KeyRequiredNotes
titleyesMax 120 characters.
descriptionnoFalls back to the site description. Used for meta, cards and RSS.
pubDateyesISO date. Drives ordering, sitemap lastmod and the RSS feed.
updatedDatenoAdds an Updated line and a second JSON-LD date.
tagsnoPowers /tags/ and the topic pages.
draftnoDefaults to false.
featurednoOne post gets the big card on the home page.
heronoPath 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 collection
  • robots.txt, with the sitemap URL derived from site
  • rss.xml in the format every reader understands
  • One 1200×630 OpenGraph image per post, rendered at build time
  • llms.txt and per-page .md copies, 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().