Content Collections: Type-Safe Content Management for Modern Web Apps
Content Collections is a modern content management solution designed specifically for build-time content processing. Unlike runtime content loaders that read files on each request, Content Collections transforms your markdown, MDX, and other content files into optimized, type-safe data structures during the build process, providing automatic TypeScript inference and seamless integration with popular frameworks.
Why Content Collections?
Traditional content management approaches in static site generators often suffer from several limitations:
- No Type Safety: Manual file reading with
fs.readFileSyncprovides no TypeScript types for frontmatter or content - Runtime Overhead: Reading and parsing files on every request or page render adds unnecessary overhead
- Manual Slug Generation: Developers must write custom scripts to extract slugs and metadata
- Framework Coupling: Content loading logic often needs to be duplicated across different frameworks
- Validation Gaps: Frontmatter validation happens at runtime (if at all), leading to production errors
Content Collections addresses these challenges by providing a build-time, type-safe, and framework-agnostic content processing solution.
Core Features
Content Collections brings professional-grade content management to modern web applications:
Build-Time Processing
- Zero Runtime Overhead: All content is processed during build time, not at runtime
- Optimized Output: Generated collections are optimized for production use
- Fast Builds: Efficient incremental processing with intelligent caching
- Static Generation: Perfect for SSG (Static Site Generation) workflows
Type Safety with Zod
Content Collections uses Zod schemas for validation and type inference:
import { defineCollection } from '@content-collections/core';
import { z } from 'zod';
const articles = defineCollection({
name: 'articles',
directory: 'articles',
include: '**/*.md',
schema: z.object({
title: z.string(),
date: z.string(),
summary: z.string(),
tags: z.array(z.string()).default([]),
author: z.string().optional(),
}),
});
This schema provides:
- Automatic validation at build time
- TypeScript type inference for your content
- IDE autocomplete for all frontmatter fields
- Compile-time safety that catches errors before deployment
Framework Integration
Native plugins for popular frameworks:
- Next.js:
@content-collections/next- integrates with Next.js build pipeline - Vite:
@content-collections/vite- works with Astro, SvelteKit, and other Vite-based frameworks - Framework Agnostic: Generated collections work in any JavaScript environment
Getting Started
Installation is straightforward:
pnpm add -D content-collections @content-collections/core zod
Basic Configuration
Create a content-collections.ts file in your project root or content package:
import { defineCollection, defineConfig } from '@content-collections/core';
import { z } from 'zod';
const articles = defineCollection({
name: 'articles',
directory: 'articles',
include: '**/*.md',
schema: z.object({
title: z.string(),
date: z.string(),
summary: z.string(),
tags: z.array(z.string()).default([]),
}),
transform: (data) => {
// Add computed fields
const slug = data._meta.path
.replace(/^articles\//, '')
.replace(/\.md$/, '');
return {
...data,
slug,
};
},
});
export default defineConfig({
collections: [articles],
});
Directory Structure
content/
├── content-collections.ts # Configuration
├── articles/ # Content directory
│ ├── article-one.md
│ ├── article-two.md
│ └── article-three.md
└── .content-collections/ # Generated output (gitignored)
└── generated/
└── index.ts # Type-safe exports
Framework Integration
Next.js Integration
Install the Next.js plugin:
pnpm add -D @content-collections/next
Configure in next.config.mjs:
import { createContentCollectionPlugin } from '@content-collections/next';
const withContentCollections = createContentCollectionPlugin({
configPath: './content-collections.ts',
});
export default withContentCollections({
// your Next.js config
});
Use in your pages:
import { allArticles } from '.content-collections/generated';
export default function ArticlesPage() {
return (
<div>
<h1>Articles</h1>
{allArticles.map((article) => (
<article key={article.slug}>
<h2>{article.title}</h2>
<p>{article.summary}</p>
<time>{article.date}</time>
</article>
))}
</div>
);
}
// Generate static params for SSG
export async function generateStaticParams() {
return allArticles.map((article) => ({
slug: article.slug,
}));
}
Astro Integration
Install the Vite plugin:
pnpm add -D @content-collections/vite
Configure in astro.config.mjs:
import { defineConfig } from 'astro/config';
import contentCollections from '@content-collections/vite';
export default defineConfig({
vite: {
plugins: [
contentCollections({
configPath: './content-collections.ts',
}),
],
},
});
Use in your Astro pages:
---
import { allArticles } from '.content-collections/generated';
const sortedArticles = [...allArticles].sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
---
<div>
<h1>Articles</h1>
{sortedArticles.map((article) => (
<article>
<h2>{article.title}</h2>
<p>{article.summary}</p>
<time datetime={article.date}>{article.date}</time>
</article>
))}
</div>
Advanced Features
Transform Functions
Transform functions let you add computed fields and process content:
const articles = defineCollection({
name: 'articles',
directory: 'articles',
include: '**/*.md',
schema: z.object({
title: z.string(),
date: z.string(),
content: z.string(),
}),
transform: (data) => {
// Generate slug from filename
const slug = data._meta.path
.replace(/^articles\//, '')
.replace(/\.md$/, '');
// Calculate reading time
const wordsPerMinute = 200;
const wordCount = data.content.split(/\s+/).length;
const readingTime = Math.ceil(wordCount / wordsPerMinute);
// Remove first h1 heading to avoid duplicates
const cleanedContent = data.content
.replace(/^#\s+[^\n]*\n+/, '')
.trimStart();
return {
...data,
slug,
readingTime,
content: cleanedContent,
};
},
});
Multiple Collections
Define multiple collections for different content types:
const articles = defineCollection({
name: 'articles',
directory: 'content/articles',
include: '**/*.md',
schema: z.object({
title: z.string(),
date: z.string(),
summary: z.string(),
}),
});
const authors = defineCollection({
name: 'authors',
directory: 'content/authors',
include: '**/*.md',
schema: z.object({
name: z.string(),
bio: z.string(),
avatar: z.string().optional(),
social: z.object({
twitter: z.string().optional(),
github: z.string().optional(),
}).optional(),
}),
});
export default defineConfig({
collections: [articles, authors],
});
Monorepo Support
Content Collections works perfectly in monorepos:
monorepo/
├── packages/
│ └── content/
│ ├── content-collections.ts
│ ├── articles/
│ └── src/
│ └── index.ts
└── apps/
├── next-app/
│ └── next.config.mjs # Points to ../../packages/content/content-collections.ts
└── astro-app/
└── astro.config.mjs # Points to ../../packages/content/content-collections.ts
Export from your content package:
// packages/content/src/index.ts
export { allArticles, type Article } from '../.content-collections/generated';
// Helper functions
export function getArticle(slug: string) {
return allArticles.find((article) => article.slug === slug);
}
export const articleSlugs = allArticles.map((article) => article.slug);
Benefits Over Alternatives
Compared to traditional approaches like custom loaders or Contentlayer:
vs. Manual File Reading
Traditional Approach:
// Runtime overhead on every request
const article = fs.readFileSync(`./articles/${slug}.md`, 'utf-8');
const { data, content } = matter(article);
// No type safety, no validation
Content Collections:
// Type-safe, build-time processed
const article = getArticle(slug);
// Full TypeScript inference, validated at build time
vs. Contentlayer
- Actively Maintained: Content Collections is actively developed, while Contentlayer development has slowed
- Better DX: Simpler configuration and clearer error messages
- Framework Flexibility: Works with any framework via Vite plugin
- Smaller Bundle: Leaner runtime footprint
vs. Astro Content Collections
- Framework Agnostic: Works with Next.js, Astro, SvelteKit, and any Vite-based framework
- Shared Collections: Use the same content across multiple apps in a monorepo
- Flexible Configuration: More control over transforms and processing
Use Cases
Content Collections excels in several scenarios:
Technical Blogs
Perfect for developer blogs with code examples and technical content:
- Type-safe frontmatter for metadata
- Automatic slug generation
- Reading time calculations
- Tag-based organization
- Author relationships
Documentation Sites
Build comprehensive documentation with validated structure:
- Version-specific docs
- Multi-language support
- Table of contents generation
- Cross-references between pages
- Consistent metadata
Marketing Sites
Manage marketing content with type safety:
- Landing page sections
- Case studies and testimonials
- Team member bios
- Product features
- Blog posts and announcements
Monorepo Applications
Share content across multiple applications:
- Central content package
- Framework-agnostic exports
- Consistent types across apps
- Single source of truth
- Reduced duplication
Best Practices
1. Define Comprehensive Schemas
const articles = defineCollection({
schema: z.object({
// Required fields
title: z.string().min(1).max(100),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
summary: z.string().min(50).max(300),
// Optional with defaults
tags: z.array(z.string()).default([]),
published: z.boolean().default(true),
// Validated enums
category: z.enum(['tutorial', 'guide', 'reference', 'news']),
// Explicit content property
content: z.string(),
}),
});
2. Use Transforms for Computed Fields
transform: (data) => {
const slug = generateSlug(data._meta.path);
const readingTime = calculateReadingTime(data.content);
const excerpt = generateExcerpt(data.content, 160);
return {
...data,
slug,
readingTime,
excerpt,
};
}
3. Organize Collections by Type
content/
├── articles/
│ ├── 2024/
│ │ ├── article-one.md
│ │ └── article-two.md
│ └── 2023/
│ └── article-three.md
├── pages/
│ ├── about.md
│ └── privacy.md
└── authors/
├── john-doe.md
└── jane-smith.md
4. Export Helper Functions
export function getArticlesByTag(tag: string) {
return allArticles.filter((article) =>
article.tags.includes(tag)
);
}
export function getRecentArticles(limit = 5) {
return [...allArticles]
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, limit);
}
Conclusion
Content Collections represents a modern approach to content management that prioritizes type safety, build-time optimization, and developer experience. By processing content at build time and generating type-safe collections, it eliminates entire classes of runtime errors while providing excellent performance and flexibility.
Whether you're building a technical blog, documentation site, or complex multi-framework monorepo, Content Collections provides the tools and patterns you need to manage content professionally without sacrificing type safety or developer experience.
Its framework-agnostic design, powerful transform capabilities, and comprehensive type inference make it an excellent choice for any project requiring robust content management with modern TypeScript tooling.