Skip to content
Go back

Astro Content Collections: Modern Static Site Architecture

Astro Content Collections: Modern Static Site Architecture

Astro’s Content Collections provide a powerful, type-safe way to manage content in static sites. This guide covers everything from basic setup to advanced content management patterns for building scalable, performant websites.

Why Use Astro Content Collections?

Step 1: Project Setup and Installation

Create a new Astro project with content collections:

# Create new Astro project (v5.13.7)
npm create astro@latest astro-collections-site
cd astro-collections-site

# Install additional dependencies
npm install @astrojs/mdx @astrojs/sitemap @astrojs/rss
npm install @astrojs/tailwind tailwindcss
npm install sharp
npm install reading-time gray-matter

Configure Astro with content collections support:

import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import tailwind from "@astrojs/tailwind";

export default defineConfig({
  site: "https://yoursite.com",
  integrations: [mdx(), sitemap(), tailwind()],
  markdown: {
    shikiConfig: {
      theme: "github-dark-dimmed",
      wrap: true,
    },
    remarkPlugins: [],
    rehypePlugins: [],
  },
  image: {
    service: {
      entrypoint: "astro/assets/services/sharp",
    },
  },
});astro.config.mjs

Step 2: Content Collection Schema Definition

Define your content collections with TypeScript schemas:

import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";

// Blog collection schema
const blogCollection = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    author: z.string(),
    pubDate: z.date(),
    updatedDate: z.date().optional(),
    heroImage: z.string().optional(),
    imageAlt: z.string().optional(),
    tags: z.array(z.string()).default([]),
    category: z.string(),
    featured: z.boolean().default(false),
    draft: z.boolean().default(false),
    seo: z
      .object({
        title: z.string().optional(),
        description: z.string().optional(),
        keywords: z.array(z.string()).optional(),
        canonicalUrl: z.string().optional(),
      })
      .optional(),
    readingTime: z.number().optional(),
    tableOfContents: z.boolean().default(true),
  }),
});

// Project collection schema
const projectCollection = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/projects" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    startDate: z.date(),
    endDate: z.date().optional(),
    status: z.enum(["planning", "in-progress", "completed", "on-hold"]),
    technologies: z.array(z.string()),
    repository: z.string().url().optional(),
    liveUrl: z.string().url().optional(),
    client: z.string().optional(),
    teamSize: z.number(),
    images: z
      .array(
        z.object({
          src: z.string(),
          alt: z.string(),
          caption: z.string().optional(),
        })
      )
      .default([]),
    testimonial: z
      .object({
        content: z.string(),
        author: z.string(),
        title: z.string(),
        company: z.string(),
      })
      .optional(),
    featured: z.boolean().default(false),
  }),
});

// Team member collection schema
const teamCollection = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/team" }),
  schema: z.object({
    name: z.string(),
    role: z.string(),
    bio: z.string(),
    avatar: z.string(),
    email: z.string().email().optional(),
    social: z
      .object({
        linkedin: z.string().url().optional(),
        twitter: z.string().url().optional(),
        github: z.string().url().optional(),
        website: z.string().url().optional(),
      })
      .default({}),
    skills: z.array(z.string()).default([]),
    joinDate: z.date(),
    isActive: z.boolean().default(true),
    order: z.number().default(0),
  }),
});

// Services collection schema
const servicesCollection = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/services" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    icon: z.string(),
    category: z.enum([
      "web-development",
      "mobile-development",
      "consulting",
      "design",
    ]),
    features: z.array(z.string()),
    pricing: z
      .object({
        startingPrice: z.number(),
        currency: z.string().default("USD"),
        billingPeriod: z.enum(["one-time", "monthly", "yearly"]),
        priceNote: z.string().optional(),
      })
      .optional(),
    process: z
      .array(
        z.object({
          step: z.number(),
          title: z.string(),
          description: z.string(),
        })
      )
      .default([]),
    technologies: z.array(z.string()).default([]),
    deliverables: z.array(z.string()).default([]),
    timeline: z.string().optional(),
    featured: z.boolean().default(false),
    order: z.number().default(0),
  }),
});

// Export collections
export const collections = {
  blog: blogCollection,
  projects: projectCollection,
  team: teamCollection,
  services: servicesCollection,
};src/content/config.ts

Step 3: Content Structure and Organization

Create your content directory structure:

src/content/
├── blog/
│   ├── 2024-01-15-getting-started-astro.md
│   ├── 2024-02-20-content-collections-guide.md
│   └── 2024-03-10-performance-optimization.mdx
├── projects/
│   ├── ecommerce-platform.md
│   ├── mobile-app-development.md
│   └── corporate-website.md
├── team/
│   ├── john-doe.md
│   ├── jane-smith.md
│   └── mike-johnson.md
└── services/
    ├── web-development.md
    ├── mobile-development.md
    └── consulting.md

Example blog post with complete frontmatter:

---
title: "Astro Performance Optimization: Complete Guide"
description: "Learn advanced techniques for optimizing Astro sites for maximum performance, including image optimization, lazy loading, and build optimization."
author: "Webtrophy Team"
pubDate: 2024-03-15
updatedDate: 2024-03-20
heroImage: "/images/blog/astro-performance.jpg"
imageAlt: "Astro performance optimization dashboard"
tags: ["astro", "performance", "optimization", "web-development"]
category: "Web Development"
featured: false
draft: false
seo:
  title: "Astro Performance Optimization Guide 2024"
  description: "Master Astro performance optimization with advanced techniques for faster loading times and better user experience."
  keywords: ["astro", "performance", "optimization", "static site"]
readingTime: 8
tableOfContents: true
---

# Astro Performance Optimization: Complete Guide

Astro is already fast by default, but there are many techniques to make it even faster. This comprehensive guide covers advanced optimization strategies for maximum performance.

## Core Performance Principles

Astro's performance advantages come from:

- **Static Generation**: Pre-rendered HTML for instant loading
- **Partial Hydration**: JavaScript only where needed
- **Asset Optimization**: Automatic image and CSS optimization
- **Bundle Splitting**: Efficient code loading

## Image Optimization Strategies

### Responsive Images with Astro Assets

Use Astro's built-in image optimization:

```astro
---
import { Image } from "astro:assets";
import heroImage from "../assets/hero.jpg";
---

<Image
  src={heroImage}
  alt="Hero image"
  width={800}
  height={400}
  format="webp"
  quality={80}
  loading="lazy"
  sizes="(max-width: 768px) 100vw, 800px"
/>
```src/content/blog/astro-performance-guide.md

This automatically generates multiple formats and sizes for optimal loading.

Example project entry:

---
title: "E-commerce Platform Development"
description: "Full-stack e-commerce platform built with modern technologies for scalable online retail."
startDate: 2024-01-15
endDate: 2024-06-30
status: "completed"
technologies:
  ["Next.js", "TypeScript", "Prisma", "PostgreSQL", "Stripe", "Tailwind CSS"]
repository: "https://github.com/webtrophy/ecommerce-platform"
liveUrl: "https://shop.example.com"
client: "Retail Company Inc."
teamSize: 4
images:
  - src: "/images/projects/ecommerce-home.jpg"
    alt: "E-commerce homepage"
    caption: "Modern, responsive homepage design"
  - src: "/images/projects/ecommerce-product.jpg"
    alt: "Product detail page"
    caption: "Detailed product pages with reviews"
testimonial:
  content: "Outstanding work! The platform exceeded our expectations and drove a 40% increase in online sales."
  author: "Sarah Johnson"
  title: "Marketing Director"
  company: "Retail Company Inc."
featured: false
---

# E-commerce Platform Development

A comprehensive e-commerce solution built from the ground up with modern technologies and best practices.

## Project Overview

This project involved creating a full-featured e-commerce platform with advanced inventory management, payment processing, and analytics capabilities.

### Key Features

- **Product Management**: Advanced catalog with variants, inventory tracking
- **Payment Processing**: Integrated Stripe with multiple payment methods
- **Order Management**: Complete order lifecycle with tracking
- **Analytics Dashboard**: Real-time sales and performance metrics
- **Mobile Responsive**: Optimized for all devicessrc/content/projects/ecommerce-platform.md

Step 4: Dynamic Pages and Routing

Create dynamic pages using content collections:

---
import { type CollectionEntry, getCollection, render } from "astro:content";
import Layout from "../../layouts/BlogLayout.astro";
import { Image } from "astro:assets";

export async function getStaticPaths() {
  const blogEntries = await getCollection("blog", ({ data }) => {
    return !data.draft;
  });

  return blogEntries.map(entry => ({
    params: { slug: entry.id },
    props: { entry },
  }));
}

type Props = {
  entry: CollectionEntry<"blog">;
};

const { entry } = Astro.props;
const { Content, headings } = await render(entry);

// Calculate reading time
const readingTime = entry.data.readingTime ?? 5;

// Format date
const formatDate = (date: Date) => {
  return date.toLocaleDateString("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric",
  });
};
---

<Layout
  title={entry.data.seo?.title ?? entry.data.title}
  description={entry.data.seo?.description ?? entry.data.description}
  keywords={entry.data.seo?.keywords}
  canonicalUrl={entry.data.seo?.canonicalUrl}
  image={entry.data.heroImage}
>
  <article class="mx-auto max-w-4xl px-4 py-12">
    <!-- Article Header -->
    <header class="mb-12">
      <div class="mb-4 flex flex-wrap items-center gap-2">
        {
          entry.data.tags.map(tag => (
            <span class="rounded-full bg-blue-100 px-2 py-1 text-xs text-blue-800">
              {tag}
            </span>
          ))
        }
      </div>

      <h1 class="mb-6 text-4xl font-bold text-gray-900 md:text-5xl">
        {entry.data.title}
      </h1>

      <div class="mb-8 flex items-center gap-6 text-gray-600">
        <div class="flex items-center gap-2">
          <span>By {entry.data.author}</span>
        </div>
        <time datetime={entry.data.pubDate.toISOString()}>
          {formatDate(entry.data.pubDate)}
        </time>
        <span>{readingTime} min read</span>
      </div>

      {
        entry.data.heroImage && (
          <div class="mb-8 aspect-video w-full overflow-hidden rounded-lg">
            <Image
              src={entry.data.heroImage}
              alt={entry.data.imageAlt ?? entry.data.title}
              width={1200}
              height={675}
              format="webp"
              quality={90}
              class="h-full w-full object-cover"
            />
          </div>
        )
      }
    </header>

    <!-- Table of Contents -->
    {
      entry.data.tableOfContents && headings.length > 0 && (
        <nav class="mb-12 rounded-lg bg-gray-50 p-6">
          <h2 class="mb-4 text-lg font-semibold">Table of Contents</h2>
          <ul class="space-y-2">
            {headings
              .filter(h => h.depth <= 3)
              .map(heading => (
                <li style={`margin-left: ${(heading.depth - 1) * 1}rem`}>
                  <a
                    href={`#${heading.slug}`}
                    class="text-sm text-blue-600 hover:text-blue-800"
                  >
                    {heading.text}
                  </a>
                </li>
              ))}
          </ul>
        </nav>
      )
    }

    <!-- Article Content -->
    <div class="prose prose-lg max-w-none">
      <Content />
    </div>

    <!-- Article Footer -->
    <footer class="mt-12 border-t border-gray-200 pt-8">
      <div class="flex flex-wrap items-center justify-between gap-4">
        <div class="text-sm text-gray-600">
          Published on {formatDate(entry.data.pubDate)}
          {
            entry.data.updatedDate && (
              <span> • Updated on {formatDate(entry.data.updatedDate)}</span>
            )
          }
        </div>

        <div class="flex gap-2">
          <button
            class="rounded-md bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700"
          >
            Share Article
          </button>
        </div>
      </div>
    </footer>
  </article>
</Layout>src/pages/blog/[...slug].astro

Step 5: Collection Utilities and Helpers

Create utility functions for working with collections:

import { getCollection, getEntry, type CollectionEntry } from "astro:content";

// Blog utilities
export async function getBlogPosts() {
  const posts = await getCollection("blog", ({ data }) => !data.draft);
  return posts.sort(
    (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
  );
}

export async function getFeaturedPosts() {
  const posts = await getBlogPosts();
  return posts.filter(post => post.data.featured);
}

export async function getPostsByCategory(category: string) {
  const posts = await getBlogPosts();
  return posts.filter(
    post => post.data.category.toLowerCase() === category.toLowerCase()
  );
}

export async function getPostsByTag(tag: string) {
  const posts = await getBlogPosts();
  return posts.filter(post => post.data.tags.includes(tag));
}

export async function getRelatedPosts(
  currentPost: CollectionEntry<"blog">,
  limit = 3
) {
  const allPosts = await getBlogPosts();
  const relatedPosts = allPosts
    .filter(post => post.id !== currentPost.id)
    .filter(post => {
      // Find posts with matching tags or category
      const commonTags = post.data.tags.filter(tag =>
        currentPost.data.tags.includes(tag)
      );
      return (
        commonTags.length > 0 ||
        post.data.category === currentPost.data.category
      );
    })
    .slice(0, limit);

  return relatedPosts;
}

// Project utilities
export async function getProjects() {
  const projects = await getCollection("projects");
  return projects.sort(
    (a, b) => b.data.startDate.valueOf() - a.data.startDate.valueOf()
  );
}

export async function getFeaturedProjects() {
  const projects = await getProjects();
  return projects.filter(project => project.data.featured);
}

export async function getProjectsByTechnology(tech: string) {
  const projects = await getProjects();
  return projects.filter(project =>
    project.data.technologies.some(t =>
      t.toLowerCase().includes(tech.toLowerCase())
    )
  );
}

// Team utilities
export async function getTeamMembers() {
  const members = await getCollection("team", ({ data }) => data.isActive);
  return members.sort((a, b) => a.data.order - b.data.order);
}

// Services utilities
export async function getServices() {
  const services = await getCollection("services");
  return services.sort((a, b) => a.data.order - b.data.order);
}

export async function getServicesByCategory(category: string) {
  const services = await getServices();
  return services.filter(service => service.data.category === category);
}

// General utilities
export function getAllTags(posts: CollectionEntry<"blog">[]) {
  const tagCounts = new Map<string, number>();

  posts.forEach(post => {
    post.data.tags.forEach(tag => {
      tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
    });
  });

  return Array.from(tagCounts.entries())
    .sort((a, b) => b[1] - a[1])
    .map(([tag, count]) => ({ tag, count }));
}

export function getAllCategories(posts: CollectionEntry<"blog">[]) {
  const categoryCounts = new Map<string, number>();

  posts.forEach(post => {
    const category = post.data.category;
    categoryCounts.set(category, (categoryCounts.get(category) || 0) + 1);
  });

  return Array.from(categoryCounts.entries())
    .sort((a, b) => b[1] - a[1])
    .map(([category, count]) => ({ category, count }));
}

// Pagination utility
export function paginateArray<T>(
  array: T[],
  pageSize: number,
  pageNum: number
) {
  const totalPages = Math.ceil(array.length / pageSize);
  const currentPage = Math.max(1, Math.min(pageNum, totalPages));
  const startIndex = (currentPage - 1) * pageSize;
  const endIndex = startIndex + pageSize;

  return {
    data: array.slice(startIndex, endIndex),
    currentPage,
    totalPages,
    hasNext: currentPage < totalPages,
    hasPrev: currentPage > 1,
    totalCount: array.length,
  };
}src/utils/collections.ts

Step 6: RSS Feed Generation

Create an RSS feed for your blog:

import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import { SITE_TITLE, SITE_DESCRIPTION } from "../consts";

export async function GET(context) {
  const posts = await getCollection("blog", ({ data }) => !data.draft);

  return rss({
    title: SITE_TITLE,
    description: SITE_DESCRIPTION,
    site: context.site,
    items: posts
      .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
      .map(post => ({
        title: post.data.title,
        description: post.data.description,
        pubDate: post.data.pubDate,
        author: post.data.author,
        categories: [post.data.category, ...post.data.tags],
        link: `/blog/${post.id}/`,
        content: `
          <p>${post.data.description}</p>
          ${post.data.heroImage ? `<img src="${post.data.heroImage}" alt="${post.data.imageAlt || post.data.title}" />` : ""}
        `,
      })),
    stylesheet: "/rss/styles.xsl",
  });
}src/pages/rss.xml.ts

Step 7: Search Functionality

Implement client-side search:

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

const allPosts = await getCollection('blog', ({ data }) => !data.draft)

// Create search index
const searchData = allPosts.map(post => ({
  id: post.id,
  title: post.data.title,
  description: post.data.description,
  tags: post.data.tags,
  category: post.data.category,
  author: post.data.author,
  pubDate: post.data.pubDate.toISOString()
}))
---

<div class="relative max-w-md mx-auto">
  <input
    type="search"
    placeholder="Search articles..."
    class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
    id="search-input"
  />

  <div id="search-results" class="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-lg shadow-lg mt-1 max-h-96 overflow-y-auto hidden z-10">
    <!-- Results will be inserted here -->
  </div>
</div>

<script define:vars={{ searchData }}>
  const searchInput = document.getElementById('search-input')
  const searchResults = document.getElementById('search-results')

  let timeoutId

  searchInput.addEventListener('input', (e) => {
    const query = e.target.value.toLowerCase().trim()

    // Clear previous timeout
    clearTimeout(timeoutId)

    if (query.length < 2) {
      searchResults.classList.add('hidden')
      return
    }

    // Debounce search
    timeoutId = setTimeout(() => {
      performSearch(query)
    }, 300)
  })

  function performSearch(query) {
    const results = searchData.filter(post => {
      return (
        post.title.toLowerCase().includes(query) ||
        post.description.toLowerCase().includes(query) ||
        post.tags.some(tag => tag.toLowerCase().includes(query)) ||
        post.category.toLowerCase().includes(query) ||
        post.author.toLowerCase().includes(query)
      )
    }).slice(0, 10) // Limit to 10 results

    displayResults(results)
  }

  function displayResults(results) {
    if (results.length === 0) {
      searchResults.innerHTML = '<div class="p-4 text-gray-500">No results found</div>'
    } else {
      searchResults.innerHTML = results.map(post => `
        <a href="/blog/${post.id}/" class="block p-4 hover:bg-gray-50 border-b border-gray-100 last:border-b-0">
          <h3 class="font-semibold text-gray-900">${post.title}</h3>
          <p class="text-sm text-gray-600 mt-1">${post.description}</p>
          <div class="flex items-center gap-2 mt-2 text-xs text-gray-500">
            <span>${post.category}</span>
            <span>•</span>
            <span>${new Date(post.pubDate).toLocaleDateString()}</span>
          </div>
        </a>
      `).join('')
    }

    searchResults.classList.remove('hidden')
  }

  // Hide results when clicking outside
  document.addEventListener('click', (e) => {
    if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
      searchResults.classList.add('hidden')
    }
  })
</script>src/components/Search.astro

Step 8: Performance Optimization

Implement advanced performance optimizations:

---
interface Props {
  title: string;
  description: string;
  keywords?: string[];
  canonicalUrl?: string;
  image?: string;
}

const { title, description, keywords, canonicalUrl, image } = Astro.props;
const canonicalURL = new URL(canonicalUrl || Astro.url.pathname, Astro.site);
const socialImage = image ? new URL(image, Astro.site) : undefined;
---

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <!-- Primary Meta Tags -->
    <title>{title}</title>
    <meta name="title" content={title} />
    <meta name="description" content={description} />
    {keywords && <meta name="keywords" content={keywords.join(", ")} />}

    <!-- Canonical URL -->
    <link rel="canonical" href={canonicalURL} />

    <!-- Open Graph / Facebook -->
    <meta property="og:type" content="website" />
    <meta property="og:url" content={canonicalURL} />
    <meta property="og:title" content={title} />
    <meta property="og:description" content={description} />
    {socialImage && <meta property="og:image" content={socialImage} />}

    <!-- Twitter -->
    <meta property="twitter:card" content="summary_large_image" />
    <meta property="twitter:url" content={canonicalURL} />
    <meta property="twitter:title" content={title} />
    <meta property="twitter:description" content={description} />
    {socialImage && <meta property="twitter:image" content={socialImage} />}

    <!-- Favicon -->
    <link rel="icon" type="image/x-icon" href="/favicon.ico" />

    <!-- RSS Feed -->
    <link
      rel="alternate"
      type="application/rss+xml"
      title="RSS Feed"
      href="/rss.xml"
    />

    <!-- Preload critical resources -->
    <link
      rel="preload"
      href="/fonts/inter-var.woff2"
      as="font"
      type="font/woff2"
      crossorigin
    />

    <!-- Critical CSS inlined -->
    <style>
      /* Critical CSS for above-the-fold content */
      body {
        font-family: "Inter", system-ui, sans-serif;
        line-height: 1.6;
        margin: 0;
      }
      .header {
        position: sticky;
        top: 0;
        background: white;
        border-bottom: 1px solid #e5e7eb;
        z-index: 50;
      }
    </style>
  </head>
  <body>
    <slot />

    <!-- Service Worker for caching -->
    <script>
      if ("serviceWorker" in navigator) {
        navigator.serviceWorker.register("/sw.js");
      }
    </script>
  </body>
</html>src/layouts/BaseLayout.astro

Step 9: Build Optimization

Configure build optimization:

import { defineConfig } from "vite";

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Separate vendor chunks for better caching
          vendor: ["astro"],
          utils: ["./src/utils/collections.ts"],
        },
      },
    },
    // Enable CSS code splitting
    cssCodeSplit: true,
    // Minify CSS
    cssMinify: true,
    // Generate source maps for production debugging
    sourcemap: false,
    // Optimize chunk size
    chunkSizeWarningLimit: 1000,
  },
});vite.config.js

Best Practices Summary

  1. Use TypeScript schemas for type safety and validation
  2. Organize content logically with clear folder structures
  3. Optimize images with Astro’s built-in tools
  4. Implement pagination for large content collections
  5. Create utility functions for common operations
  6. Add search functionality for better UX
  7. Generate RSS feeds for content syndication
  8. Use proper SEO meta tags for better discoverability
  9. Implement caching strategies for performance
  10. Monitor build performance and optimize as needed

Development Commands

# Start development server
npm run dev

# Build for production
npm run build

# Preview production build
npm run preview

# Type check content
npm run astro check

Your Astro content collections setup is now ready for scalable, type-safe content management with excellent performance and developer experience!

Update Notes (January 2025)

This article has been updated to reflect the latest Astro v5.13.7 features and best practices:

All code examples have been verified against the latest Astro documentation and are production-ready.


Share this post on:

Previous Post
Digital Farming Solutions: Calgary's AgTech Innovation Hub