Astro 5 Content Layer API: What Changed, What Broke, and Why It's Worth It

javascript dev.to

Astro 5 shipped the Content Layer API as stable, replacing the experimental Content Collections from v2/v3. If you have a blog, docs site, or content-heavy project on Astro — this migration is happening whether you want it to or not.

Here's what changed, what's genuinely better, and how to migrate without losing a day to it.

What the Content Layer Actually Is

Content Collections in Astro v2/v3 were filesystem-bound. Your content lived in src/content/, each collection was a directory, Astro scanned it at build time, and you got typed entries. It worked. It was also rigid.

The Content Layer decouples where content lives from how Astro accesses it. Content can come from:

  • Local files (same as before)
  • Remote APIs
  • Databases
  • CMSes (Contentful, Sanity, Notion)
  • Git history
  • Anything you can write a loader for

The defineCollection() API still exists. The loader property is new.

What Changed in the API

v3 Syntax (Old)

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

export const collections = {
  blog: defineCollection({
    type: 'content',
    schema: z.object({
      title: z.string(),
      date: z.coerce.date(),
      tags: z.array(z.string()).default([]),
    }),
  }),
};
Enter fullscreen mode Exit fullscreen mode

v5 Syntax (New)

// src/content.config.ts  ← FILE LOCATION CHANGED
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

export const collections = {
  blog: defineCollection({
    loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
    schema: z.object({
      title: z.string(),
      date: z.coerce.date(),
      tags: z.array(z.string()).default([]),
    }),
  }),
};
Enter fullscreen mode Exit fullscreen mode

Key changes:

  1. Config file moved: src/content/config.tssrc/content.config.ts
  2. type removed: replaced by loader
  3. glob() loader handles filesystem content (import from astro/loaders)
  4. base path is now relative to project root, not src/content/

The Breaking Changes

entry.render() Is Gone

This is the one that burns people the most.

In v3:

const { Content } = await entry.render();
Enter fullscreen mode Exit fullscreen mode

In v5:

import { render } from 'astro:content';
const { Content } = await render(entry);
Enter fullscreen mode Exit fullscreen mode

It's a function import now, not a method on the entry. Find all .render() calls:

grep -r "entry.render\|\.render()" src/ --include="*.astro" --include="*.ts"
Enter fullscreen mode Exit fullscreen mode

getCollection() Still Works, getEntryBySlug() Deprecated

getEntryBySlug() is deprecated in favor of getEntry() with a consistent ID:

// v3 (deprecated)
const post = await getEntryBySlug('blog', 'my-post-slug');

// v5
const post = await getEntry('blog', 'my-post-slug');
Enter fullscreen mode Exit fullscreen mode

The ID is now the file path relative to base, without extension. So src/content/blog/2026/my-post.md → ID is 2026/my-post.

Image Handling Changed

In v3, image() schema helper from astro:content handled image optimization. In v5, use the standard Astro image schema:

import { defineCollection, z } from 'astro:content';
import { image } from 'astro:assets';

export const collections = {
  blog: defineCollection({
    loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
    schema: ({ image }) => z.object({  // ← image passed as context
      cover: image().optional(),
      title: z.string(),
    }),
  }),
};
Enter fullscreen mode Exit fullscreen mode

Remote Loaders — The Real Upgrade

This is why the migration is worth it. Remote loaders let you pull content from any source with full type safety:

// Custom loader: pull posts from a headless CMS
function contentfulLoader({ spaceId, accessToken }: { spaceId: string, accessToken: string }) {
  return {
    name: 'contentful-loader',
    async load({ store, logger }) {
      logger.info('Fetching posts from Contentful...');

      const response = await fetch(
        `https://cdn.contentful.com/spaces/${spaceId}/entries?content_type=blogPost`,
        { headers: { Authorization: `Bearer ${accessToken}` } }
      );

      const data = await response.json();

      for (const entry of data.items) {
        store.set({
          id: entry.sys.id,
          data: {
            title: entry.fields.title,
            date: new Date(entry.fields.publishedAt),
            body: entry.fields.body,
          },
        });
      }
    },
  };
}

export const collections = {
  blog: defineCollection({
    loader: contentfulLoader({
      spaceId: import.meta.env.CONTENTFUL_SPACE_ID,
      accessToken: import.meta.env.CONTENTFUL_ACCESS_TOKEN,
    }),
    schema: z.object({
      title: z.string(),
      date: z.coerce.date(),
      body: z.string(),
    }),
  }),
};
Enter fullscreen mode Exit fullscreen mode

The store is persisted between builds. Incremental builds only re-fetch changed content.

Incremental Build Performance

This is the other reason to migrate. v5 loaders have a store that persists between dev server refreshes. For large content sites (500+ posts), this is dramatic:

Scenario v3 v5
Cold build (500 posts) ~12s ~12s
Hot reload after edit ~8s ~0.3s
Dev server start (warm) ~8s ~1s

The store caches parsed + validated content. Restarts don't re-parse everything.

Migration Checklist

# 1. Move config file
mv src/content/config.ts src/content.config.ts

# 2. Find all render() calls
grep -rn "\.render()" src/ --include="*.astro"

# 3. Find all getEntryBySlug calls
grep -rn "getEntryBySlug" src/ --include="*.ts" --include="*.astro"

# 4. Update each collection in config
# - Remove 'type' property
# - Add loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/<name>' })

# 5. Run build to surface remaining issues
npm run build
Enter fullscreen mode Exit fullscreen mode

The TypeScript errors that surface will point you directly to the remaining issues. Expect 2-4 hours for a medium content site.

Is It Worth It?

For a static blog with 20 posts: the migration is mechanical but not urgent. You have time.

For a content-heavy site pulling from multiple sources, or one where build times are starting to matter: migrate now. The remote loader story is genuinely better, and the incremental build speed on dev is noticeable enough that it changes how you work.

Astro's team has been clear: Content Collections (v3 API) will be removed in a future major version. The sooner you migrate, the less it hurts.


Building a content-driven developer tool or SaaS? The Ship Fast Skill Pack includes production Astro + content pipeline patterns for launching fast without the migration debt.

Source: dev.to

arrow_back Back to Tutorials