withastro/astro · error · AstroError
ContentLoaderReturnsInvalidId
ContentLoaderReturnsInvalidId
Error message
The content loader for the collection **${String(collection)}** returned an entry with an invalid `id`:
${JSON.stringify(entry, null, 2)} What it means
Astro content collections accept two loader styles: a full Loader object, or a plain async function (CollectionLoader). When you use the function style, its return value is validated against loaderReturnSchema: either an array of objects where each has a string `id`, or a record of string keys to objects with an optional string `id`. If validation fails, Astro throws ContentLoaderReturnsInvalidId and JSON-stringifies the offending entry so you can see exactly which one is malformed.
Solutions
- Make every entry's `id` a string: map the data before returning it, e.g. `posts.map((p) => ({ ...p, id: String(p.id) }))`.
- Return the correct shape: an `Array<{ id: string, ... }>` or a `Record<string, { id?: string, ... }>` — nothing else.
- Add explicit TypeScript typing or a zod schema inside your loader so bad ids are caught at the boundary with a clearer message.
- Read the JSON-stringified entry in the error message to find which item failed, then fix that item at the source.
Example fix
// before
const Blog = defineCollection({
loader: async () => {
const res = await fetch('https://api.example.com/posts');
return res.json(); // [{ id: 1, title: '...' }] -> id is a number
},
});
// after
const Blog = defineCollection({
loader: async () => {
const res = await fetch('https://api.example.com/posts');
const posts: Array<Record<string, any>> = await res.json();
return posts.map((p) => ({ ...p, id: String(p.id) }));
},
}); Defensive patterns
Strategy: validation
Validate before calling
function assertLoaderReturnShape(data: unknown): void {
if (Array.isArray(data)) {
data.forEach((entry, i) => {
if (typeof (entry as any)?.id !== 'string')
throw new Error(`Entry ${i} is missing a string id: ${JSON.stringify(entry)}`);
});
} else if (data && typeof data === 'object') {
for (const [key, value] of Object.entries(data as object)) {
const id = (value as any)?.id;
if (id !== undefined && id !== key)
throw new Error(`Key ${key} does not match id ${id}`);
}
} else {
throw new Error('Loader must return an array or a plain object');
}
}
// in the loader, before returning:
const data = await fetchData();
assertLoaderReturnShape(data);
return data; Type guard
function isLoaderEntryArray(v: unknown): v is Array<{ id: string } & Record<string, unknown>> {
return (
Array.isArray(v) &&
v.every((e) => e !== null && typeof e === 'object' && typeof (e as any).id === 'string')
);
} Try / catch
try {
const posts = await getCollection('blog');
} catch (err) {
if ((err as any)?.code === 'ContentLoaderReturnsInvalidId') {
// message contains the JSON of the offending entry: log it, fix the source, re-sync
console.error((err as any).message);
} else throw err;
} Prevention
- Coerce ids at the boundary: always `String(item.id)` before returning from a function-style loader.
- Type the loader: `loader: async (): Promise<Array<{ id: string }>> => ...` so the compiler enforces the shape.
- Never return Maps, Sets, or class instances from a loader — plain arrays/objects only.
When it happens
Trigger: A function-style loader (e.g. `loader: async () => (await fetch(url)).json()`) returning entries whose `id` is a number (common with APIs/YAML/CSV), null, or missing entirely in the array form; or returning something that is neither an array nor a plain object (e.g. a string, a Map).
Common situations: Fetching CMS/API data where ids are numeric; parsing YAML or CSV sources that deserialize ids as non-strings; copying a `store.set`-based loader example but returning from the function instead; untyped loaders so TypeScript never flags the shape.
Related errors
- ContentLoaderInvalidDataError
- ExpectedImageOptions
- A content collection is defined with legacy features (e.g…
- An error was encountered while creating the JSON schema for…
- Collection loader for
AI-assisted analysis of withastro/astro@52e6c34790 (2026-08-18).
Data as JSON: /api/errors/d01bef4774abf678.
Report an issue: GitHub.
Appendix: source
Thrown at packages/astro/src/content/content-layer.ts:492
) {
const unsafeData = await handler();
const parsedData = loaderReturnSchema.safeParse(unsafeData);
if (!parsedData.success) {
const issue = parsedData.error.issues[0] as z.core.$ZodIssueInvalidUnion;
// Due to this being a union, zod will always throw an "Expected array, received object" error along with the other errors.
// This error is in the second position if the data is an array, and in the first position if the data is an object.
const parseIssue = Array.isArray(unsafeData) ? issue.errors[0] : issue.errors[1];
const error = parseIssue[0];
const firstPathItem = error.path[0];
const entry = Array.isArray(unsafeData)
? unsafeData[firstPathItem as number]
: unsafeData[firstPathItem as string];
throw new AstroError({
...AstroErrorData.ContentLoaderReturnsInvalidId,
message: AstroErrorData.ContentLoaderReturnsInvalidId.message(context.collection, entry),
});
}
const data = parsedData.data;
context.store.clear();
if (Array.isArray(data)) {
for (const raw of data) {
if (!raw.id) {
throw new AstroError({
...AstroErrorData.ContentLoaderInvalidDataError,
message: AstroErrorData.ContentLoaderInvalidDataError.message(
context.collection,
`Entry missing ID:\n${JSON.stringify({ ...raw, id: undefined }, null, 2)}`,
),View on GitHub (pinned to 52e6c34790)