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?
- Type Safety: Automatic TypeScript types for your content
- Performance: Optimized builds with static generation
- Flexibility: Support for Markdown, MDX, and JSON
- Developer Experience: Built-in validation and IntelliSense
- Scalability: Handle thousands of content files efficiently
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 devices
src/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
- Use TypeScript schemas for type safety and validation
- Organize content logically with clear folder structures
- Optimize images with Astro’s built-in tools
- Implement pagination for large content collections
- Create utility functions for common operations
- Add search functionality for better UX
- Generate RSS feeds for content syndication
- Use proper SEO meta tags for better discoverability
- Implement caching strategies for performance
- 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:
- Updated to Astro 5.13.7: All examples now use the current stable version
- Modern Loader API: Updated collection definitions to use the new
glob()
loader instead of deprecatedtype: 'content'
- Simplified Image Service: Removed deprecated
@astrojs/image
integration, using built-in Astro image optimization - Updated API Methods: Changed from
entry.render()
torender(entry)
for better performance - Entry ID Usage: Updated examples to use
entry.id
instead ofentry.slug
for consistency - TypeScript Improvements: Enhanced type safety with latest Astro content collection types
- Removed Experimental Flags: Cleaned up configuration by removing deprecated experimental features
All code examples have been verified against the latest Astro documentation and are production-ready.