devlog #3: The Astro Content Collections Migration Epic

My journey migrating from static files to a dynamic, type-safe content architecture using Astro Content Collections and MDX.

devlog #3: The Astro Content Collections Migration Epic

This devlog chronicles my epic journey migrating the Signals & Systems blog from static, hardcoded Astro files to a modern, maintainable, and type-safe content system using Astro Content Collections and MDX.

The Starting Point

As I mentioned in my first devlog, my vision for Signals & Systems is to create a platform that elegantly presents interconnected articles with a focus on readability and interactivity. With the interactive components now in place, I needed a robust system to manage content relationships without hardcoding references.

My articles started as static .astro files in src/pages/devlog/. Content was hardcoded, making updates and navigation brittle:

  • I had to update multiple files when adding a new article
  • Type safety was inconsistent across pages
  • Navigation between related articles required manual linking
  • Series membership wasn’t systematically tracked
  • Interactive components (charts, callouts, code blocks) were used, but not easily reusable across content

The Migration Plan

After evaluating options for creating a more maintainable content system, I developed a migration plan:

  • Move all articles to src/content/devlog/ as MDX files for rich content and component support
  • Define schemas for articles and series in src/content/config.ts using Zod for type safety
  • Implement utility functions in src/utils/content.ts to fetch, filter, and sort content from collections
  • Create dynamic routes ([slug].astro) for articles and series

Content Flow Architecture

Here’s a visualization of how content now flows through my platform:

Content Flow Through Platform ArchitectureContent CollectionsSchema ValidationUtility FunctionsDynamic RoutesUI Components

Path of content from collections through validation, utilities, and dynamic routes to UI components

Content Schema Implementation

The cornerstone of my new architecture is type-safe content schemas defined in src/content/config.ts using Zod for validation:

src/content/config.ts
import { defineCollection, z } from 'astro:content';

// Define the schema for articles with strict type validation
const articleCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    publishDate: z.date(),
    series: z.string().optional(),
    order: z.number().optional(),
    coverImage: z.string()
  })
});

// Define the schema for series
const seriesCollection = defineCollection({
  type: 'data',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    articleCount: z.number(),
    startDate: z.date()
  })
});

// Export collections to register them
export const collections = {
  'devlog': articleCollection,
  'series': seriesCollection
};

Complementing my schema, I developed utility functions in src/utils/content.ts to provide a clean API for content retrieval:

src/utils/content.ts
import { getCollection } from 'astro:content';

// Fetch all articles with type safety
export async function getAllArticles() {
  return await getCollection('devlog');
}

// Fetch all series data
export async function getAllSeries() {
  return await getCollection('series');
}

// Get articles for a specific series
export async function getSeriesArticles(seriesSlug: string) {
  const articles = await getAllArticles();
  return articles.filter(article => 
    article.data.series && 
    article.data.series.toLowerCase() === seriesSlug.toLowerCase())
    .sort((a, b) => (a.data.order || 0) - (b.data.order || 0));
}

Information

Migration Complete: I’ve successfully transitioned to Astro Content Collections, giving me powerful content management capabilities with complete type safety throughout the content pipeline.

Dynamic Routes Implementation

Creating dynamic routes for content was a crucial part of the migration. By moving article pages from src/pages/devlog/ to src/content/devlog/, I needed to update my routing:

src/pages/devlog/[slug].astro
---
// src/pages/devlog/[slug].astro
import { getCollection } from 'astro:content';
import ArticleLayout from '../../layouts/ArticleLayout.astro';

// Generate routes for all devlog articles
export async function getStaticPaths() {
  const devlogEntries = await getCollection('devlog');
  return devlogEntries.map(entry => ({
    params: { slug: entry.slug },
    props: { entry },
  }));
}

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

<ArticleLayout frontmatter={entry.data} slug={entry.slug}>
  <Content />
</ArticleLayout>

Series Navigation

One of the most significant improvements is my series navigation. Previously, I had to manually specify previous and next articles. Now, I can derive this information from the content structure:

src/layouts/ArticleLayout.astro
// Get all articles in this series, sorted by order
const seriesArticles = await getSeriesArticles(seriesSlug);

// Find the current article's position in the series
const currentIndex = seriesArticles.findIndex(article => 
  article.slug === currentArticleSlug);

// Determine previous and next articles
const prevArticle = currentIndex > 0 ? 
  seriesArticles[currentIndex - 1] : null;
const nextArticle = currentIndex < seriesArticles.length - 1 ? 
  seriesArticles[currentIndex + 1] : null;

This code automatically calculates the previous and next articles in a series based on the article’s position, making navigation between related content seamless.

Migration Effort Breakdown

Here’s a concrete breakdown of the development effort required for each part of the migration:

This chart shows the real development effort behind my migration. Bug fixes required the most work (350+ lines across 12 files), while the initial content schema setup was surprisingly minimal (45 lines across 3 files). Component updates involved the most widespread changes, touching 8 different files to ensure compatibility with the new content system.

Major Challenges & Debugging

1. Content Not Appearing

One of my most perplexing issues was when all articles suddenly disappeared from the site:

Warning

Root Cause: A syntax error in ConsentPopup.astro broke Astro’s content sync mechanism, causing all articles to disappear.

Solution: I fixed the syntax error, restarted the dev server, and validated content sync was working correctly.

2. Double-Prefixed Slugs

My routing was broken because I had an issue with path prefixes:

Warning

Root Cause: Slugs were stored as /devlog/{slug} and then /devlog/ was prepended again in links.

Solution: I changed to store only the filename as slug and prepend /devlog/ only in UI components.

3. MDX Schema/Frontmatter Mismatches

Many articles failed to render because of strict schema validation:

MDX Frontmatter Example
// Example frontmatter mismatch
// Schema expected:
title: string;
description: string;
publishDate: date; // ISO format

// But MDX had:
title: "Article Title"
description: "Description"
publishDate: "May 21, 2025" // Wrong format

This required meticulous checking of all frontmatter fields against my schema definitions.

4. Component Rendering in MDX

Getting components to render correctly in MDX was tricky:

Information

I had to ensure all .astro components were imported at the top of each MDX file and properly referenced in the content.

5. TypeScript Errors

TypeScript was relentlessly enforcing type safety, especially with my dynamic routes:

TypeScript Error Example
// Error: Parameter 'article' implicitly has an 'any' type
const currentIndex = seriesArticles.findIndex(article => 
  article.slug === currentArticleSlug);

// Fixed with explicit type
const currentIndex = seriesArticles.findIndex((article: {
  slug: string;
  // other properties...
}) => article.slug === currentArticleSlug);

Implementation Backtracking

Several times during the migration, I had to backtrack and revert changes due to new issues:

  • I tried different slug formats, switching between relative and absolute paths
  • I experimented with different data structures for storing article relationships
  • I made changes to layout components then had to roll some back for compatibility

This iterative process, while frustrating at times, led to a more robust and well-tested solution.

Key Benefits of the New System

  • Type Safety: Complete schema validation ensures content consistency
  • Component Reuse: MDX makes it easy to embed interactive components in content
  • Centralized Management: Content lives in a dedicated directory structure
  • Automatic Navigation: Series relationships are derived from content metadata
  • Better Developer Experience: Content editing is separated from presentation logic

Lessons Learned

Key Takeaways

  • Astro’s content system is powerful but strict: schema and frontmatter must match exactly
  • Syntax errors in any component can break the entire content sync mechanism
  • MDX enables rich, interactive content, but requires careful import and usage of components
  • Debugging content loading requires checking both code and build output
  • Consistent path handling is crucial for proper navigation

Next Steps

With my content architecture now stable and robust, I’m ready to build on this foundation:

  • Implementing tag-based browsing across article categories
  • Adding related article suggestions based on content similarity
  • Building a search functionality leveraging my centralized content structure
  • Documenting all custom components for easier content creation

Join the Conversation

This migration was one of my most challenging but rewarding development efforts. Have thoughts or feedback? Connect on GitHub or LinkedIn.

References


JELL

JELL

Innovator, Educator & Technologist

JELL is an innovator, educator, and technologist exploring the confluence of AI, higher education, and ethical technology. Through Signals & Systems, JELL shares insights, experiments, and reflections on building meaningful digital experiences, and other random things.