Skip to content

Frontend

Astro Content Collections — How Aidxn Manages 500+ Blog Posts

Forget databases for blog content. Astro Content Collections turns .md files into type-safe, fully queryable posts with Zod validation, automatic tag indexing, and dynamic routing. How to skip the CMS and scale to 500+ posts.

📚 🔗

Astro Content Collections is a file-based content system built into Astro 4+. You write blog posts as .md, .mdx, or .astro files. Astro validates the frontmatter with Zod schemas, generates type-safe APIs, and creates dynamic routes automatically. No database, no CMS API, no runtime parsing — everything is static at build time. Aidxn ships this for portfolio blogs, documentation sites, and product blogs. At 500+ posts, Content Collections is faster than any headless CMS, easier to version-control than database exports, and requires zero DevOps. Here's the setup, schema patterns, tag indexing tricks, common FAQs, and why CMS overkill fails for developer-led content.

What Are Content Collections?

Content Collections is Astro's answer to: "Why do I need a CMS for a blog?" Instead of posting to a backend and fetching at runtime, you commit .md files to git. Astro reads them at build time, validates frontmatter with Zod, and generates fully typed APIs. Every post gets a type-safe data object: title, author, date, tags, content, excerpt. Dynamic routes automatically create `/blog/[post-slug]` pages. Static generation means deploy time is seconds, no database downtime, git is your version control. The trade-off: non-technical editors can't use a UI (they need a git client or Netlify CMS overlay). For dev-led content, this is fine. For content agencies, Sanity or Strapi are better.

Why Skip the CMS for Developer Blogs?

A headless CMS (Sanity, Strapi, Payload) adds overhead. You pay monthly fees (or manage self-hosting). Content lives in a database, fetched at runtime via REST or GraphQL. Editors use a UI. For marketing teams, this is worth it. For developer blogs, CMS is overkill. Developers write posts in Markdown (their native format). Posts are code, version-controlled like code. Revert a post? `git revert`. See who edited? `git log`. Merge conflicts? Handled by git, not a lock system. Astro Content Collections cuts the middle layer: no CMS, no runtime fetches, no database. Build time is the only time content is read. This is why it scales: Aidxn's 500-post blog builds in under 2 seconds. A Sanity-backed blog fetches all 500 posts at build time too, but you're also paying $99/month for the privilege of Sanity's hosting. Skip it.

Setting Up Content Collections

Step 1: Define Your Schema

Create a schema file at `src/content/config.ts`. This is where you define your collections (blog posts, documentation, case studies) and validate frontmatter with Zod.

import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    author: z.string().default('Aiden Wood'),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    tags: z.array(z.string()),
    category: z.enum(['Frontend', 'Backend', 'DevOps', 'Design']),
    cover: z.string().optional(),
    draft: z.boolean().default(false),
  }),
});

export const collections = { blog };

Zod validates every post's frontmatter. Missing a required field? Build fails with a clear error. Types are inferred: TypeScript knows `post.data.pubDate` is a `Date`, `post.data.tags` is `string[]`. No type casting needed.

Step 2: Organize Posts

Posts live in `src/content/blog/`. Each post is a .md file with frontmatter and Markdown body.

---
title: "Astro Content Collections — How Aidxn Manages 500+ Blog Posts"
description: "Type-safe .md blogs with Zod validation and dynamic routing."
author: "Aiden Wood"
pubDate: 2026-06-13
tags: ["astro", "markdown", "static-site"]
category: "Frontend"
draft: false
---

# Your blog post content here

This is the body. Markdown renders to HTML automatically.

Step 3: Query Posts in Astro Templates

Use the `getCollection()` API to fetch and query posts. This returns fully typed post objects.

---
import { getCollection } from 'astro:content';

// Get all published posts, sorted by date
const posts = (await getCollection('blog'))
  .filter(post => !post.data.draft)
  .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());

// Get posts by tag
const frontendPosts = posts.filter(post =>
  post.data.tags.includes('frontend')
);
---

{posts.map(post => ( {post.data.title} ))}

Step 4: Dynamic Routes

Create `src/pages/blog/[...slug].astro` for dynamic post pages. Astro auto-generates routes for every post in the collection.

---
import { getCollection } from 'astro:content';
import PostLayout from '../../layouts/PostLayout.astro';

export async function getStaticPaths() {
  const blog = await getCollection('blog');
  return blog
    .filter(post => !post.data.draft)
    .map(post => ({
      params: { slug: post.slug },
      props: { post },
    }));
}

const { post } = Astro.props;
const { Content } = await post.render();
---


  

Every post gets a unique URL. Posts marked `draft: true` are skipped at build time (not deployed). Slug is auto-generated from the filename: `my-post-title.md` → `/blog/my-post-title`.

Advanced: Tag Indexing and Search

Auto-Generated Tag Pages

Query all unique tags, then create a page for each. `/blog/tags/astro` shows every post tagged "astro".

---
// src/pages/blog/tags/[tag].astro
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const blog = await getCollection('blog');

  // Collect all unique tags
  const tags = new Set();
  blog.forEach(post => {
    post.data.tags.forEach(tag => tags.add(tag));
  });

  // Create a page for each tag
  return Array.from(tags).map(tag => ({
    params: { tag },
    props: { tag, posts: blog.filter(p =>
      p.data.tags.includes(tag) && !p.data.draft
    )},
  }));
}

const { tag, posts } = Astro.props;
---

Posts tagged "{tag}"

{posts.map(post => ( {post.data.title} ))}

Related Posts

Find posts sharing tags with the current post. Shows "You might also like" cards based on tag overlap.

// src/lib/related-posts.ts
import { getCollection } from 'astro:content';

export async function getRelatedPosts(currentPost: any, limit = 3) {
  const allPosts = await getCollection('blog');

  const related = allPosts
    .filter(post =>
      post.slug !== currentPost.slug &&
      !post.data.draft
    )
    .map(post => {
      // Count matching tags
      const matchCount = post.data.tags.filter(tag =>
        currentPost.data.tags.includes(tag)
      ).length;
      return { post, matchCount };
    })
    .sort((a, b) => b.matchCount - a.matchCount)
    .slice(0, limit)
    .map(({ post }) => post);

  return related;
}

Schema Patterns That Scale

Pattern 1: Optional Fields for Flexibility

Not every post needs a featured image or updated date. Use `.optional()` to make fields conditional.

schema: z.object({
  title: z.string(),
  pubDate: z.coerce.date(),
  updatedDate: z.coerce.date().optional(),
  cover: z.string().optional(),
  coverAlt: z.string().optional(),
});

Pattern 2: Enums for Categories

Enforce valid categories with `z.enum()`. No typos like "frontend" vs "Frontend".

category: z.enum(['Frontend', 'Backend', 'DevOps', 'Design', 'Business']),

Pattern 3: Excerpts for List Pages

Extract the first paragraph from every post automatically. No duplicate content in frontmatter.

export async function getExcerpt(post: any) {
  const { Content } = await post.render();
  // Render to HTML, extract first paragraph
  // (Astro plugins like astro-reading-time do this)
  return "First 150 characters...";
}

Six FAQs

Can non-technical editors use Content Collections?

Not easily. They need git or a CMS overlay (like Netlify CMS). If your team isn't dev-led, use Sanity or Strapi. Content Collections is for developers who write Markdown natively.

How do I preview drafts locally?

Temporarily set `draft: false` on a post, or modify your schema to accept a `--draft` env var. At build time, filter out `draft: true` posts. For staging deploys, set an env var that skips the draft filter — useful for client review.

Can I use .mdx instead of .md?

Yes. Both work identically. .mdx lets you embed JSX in posts: ``. Use .mdx for interactive blog posts; stick with .md for pure content.

What if I need to migrate 500 posts from WordPress?

Export as CSV, write a Node script to transform rows into .md files with frontmatter. Takes a day to script and test, but it's a one-time cost. Content Collections has no lock-in: posts are just .md files, easily portable to any Markdown-based system.

How does performance scale at 5,000 posts?

Astro generates all routes at build time, so 5,000 posts = 5,000 HTML files. Build time might hit 30–60 seconds (vs 2 seconds for 500). Lighthouse scores stay perfect (static HTML is always 100 FCP/LCP). If you need pagination or search, add that client-side (fetch a JSON index of posts, search in the browser).

Can I use Content Collections with a database for metadata?

Absolutely. Store posts as .md in git. Store author profiles, view counts, or reader comments in Supabase. Query both at build time, merge the data in your templates. Best of both worlds: version-controlled content + dynamic metadata.

The Bottom Line

Content Collections is the fastest, simplest way to manage blogs when your team is dev-led. Write posts as .md, validate with Zod, deploy static HTML. No CMS fees, no database downtime, no runtime fetch latency. At 500 posts, Aidxn's blog builds in 2 seconds and serves sub-100ms pages. Tag indexing, dynamic routes, and related-post search are built-in. The catch: if you need a visual editor for non-technical writers, use Sanity (see CMS comparison for the full breakdown). For developer blogs, technical documentation, and product release notes, Content Collections is unbeatable. For custom blog architecture, see Aidxn Design consulting or explore Astro's full docs at astro.build.

Let us make some quick suggestions?
Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.