Rebounder Tech Blog

Written by the people who actually run these systems in production.

Astro 7 Breaks Content Collections in Three Places

公開 読了時間 約3分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

In Astro 7, src/content/config.ts is rejected with LegacyContentConfigError: the config moves to src/content.config.ts, collections need a loader, and entry.render() becomes render(entry).

The short version

Astro 7 changed three things about content collections at once: where the config file lives, the now-required loader, and the render API. Fixing one only reveals the next.

src/content/config.ts   →  src/content.config.ts    location
type: 'content'         →  loader: glob({ ... })    how entries are read
await entry.render()    →  await render(entry)      rendering

The first thing you see is LegacyContentConfigError. That message points at only the first of the three. Move the file and the build gets further, then hits the second. You end up walking them one at a time, so it is faster to fix all three up front.

What it looks like

After upgrading Astro, the build dies while reading the content collection config.

LegacyContentConfigError

The error says nothing about the contents of the config. You can re-read the zod schema looking for a mistake and find nothing wrong with it. What fails is the fact that a config file exists at that path at all.

Once moved, it stops on the collection definition instead. Further along, generating the article pages, it reports that entry.render() does not exist. Because it arrives in three stages, you fix and rebuild, fix and rebuild.

Why

All three are part of the same change. Collections went from “implicitly read a directory” to “declare where you read what from”.

Location

src/content/config.ts no longer works. It goes to src/content.config.tsdirectly under src, not inside the directory, with the separator changing from a slash to a dot. The two look almost identical, so scanning a diff by eye will miss it.

loader is required

Instead of the old type: 'content', you pass how entries are read.

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const posts = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
  schema: z.object({ /* ... */ }),
});

export const collections = { posts };

base is relative to the project root, not to the config file. Get it wrong and there is no error — the collection is simply empty. The build succeeds and only the index pages come out blank. That is the failure mode that is hardest to notice.

id and rendering

An entry’s identifier is id, not slug, and it is the path relative to base with the extension dropped. With per-language directories that means ja/<slug>. Anywhere you build a URL needs to strip the leading directory.

Rendering is a function, not a method on the entry.

import { getCollection, render, type CollectionEntry } from 'astro:content';

const { Content, headings } = await render(entry);

What comes back is unchanged, so only the shape of the call is rewritten. But it is needed at every call site.

Fixing it

This order works.

  1. Move src/content/config.ts to src/content.config.ts
  2. Drop type from each collection and pass glob({ pattern, base }) as loader
  3. Rewrite entry.render() to render(entry) and import render from astro:content
  4. Change references to entry.slug to entry.id and strip the directory prefix

Only the fourth produces no error. The build passes and the URLs are broken, so after the replacement, open one real path and check it.

Why it took a while

The migration steps are in the official upgrade guide. We still walked them one at a time because the first error message named only one of the three changes.

LegacyContentConfigError says “the location is old” and nothing else. You stop there, move the file, rebuild, and only the next error tells you about the second change. Fixing error-by-error means you never learn there were three changes at all. Reading the whole set of changes the moment the first error appears is faster — it was a question of order, not of difficulty.

To avoid repeating this, the three points are now a note at the top of the schema’s source-of-truth file. Anyone touching the config opens that file, so it lands in front of them before they go looking for the migration guide.

A similar shape on the deploy side is in Three traps in deploying to Vercel.

よくある質問

Q1How do I get rid of LegacyContentConfigError?

Move the config file from src/content/config.ts to src/content.config.ts. It sits directly under src rather than inside the directory, and the separator changes from a slash to a dot. The location itself is what changed, so editing the file's contents will not clear the error.

Q2I moved the file and got a different error

There is no loader. Collections used to take type: 'content' or 'data'; now you declare where the collection reads its entries from. For Markdown, pass a pattern and a base directory to glob from astro/loaders.

Q3It says entry.render() does not exist

The render entry point moved. Instead of a method on the entry, import render from astro:content and call render(entry). What comes back is the same — the Content component and the list of headings. Every call site needs the rewrite.

Q4Can I still use entry.slug?

It is id now. id is the path relative to the base directory with the extension dropped. If you split content into per-language subdirectories it looks like ja/foo, so anywhere you build a URL needs to strip the leading directory.

この記事の根拠

  • ドキュメントファイル 9〜14行目
  • TypeScriptファイル 12〜16行目
  • Astroファイル 1〜3行目

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。