Content Collections: Type-Safe Content Management for Modern Web Apps

contenttypescriptnextjsastrobuild-tools

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.readFileSync provides 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.