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

  1. Make every entry's `id` a string: map the data before returning it, e.g. `posts.map((p) => ({ ...p, id: String(p.id) }))`.
  2. Return the correct shape: an `Array<{ id: string, ... }>` or a `Record<string, { id?: string, ... }>` — nothing else.
  3. Add explicit TypeScript typing or a zod schema inside your loader so bad ids are caught at the boundary with a clearer message.
  4. 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

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


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)