withastro/astro · error · AstroError

DuplicateContentEntrySlugError

DuplicateContentEntrySlugError

Error message

**${collection}** contains multiple entries with the same slug: `${id}`. Slugs must be unique.

Entries: 
- ${existingEntry.filePath}
- ${relativePath}

What it means

The glob() loader generates an id per file (by default the slugified path). If an already-stored entry with the same id points at a different file and that older file still exists on disk, two live files claim one id: Astro reports DuplicateContentEntrySlugError — warn by default, throw when `prerenderConflictBehavior` is 'error'. If the old file no longer exists, it is treated as a rename in progress and no error is raised.

Source

Thrown at packages/astro/src/content/loaders/glob.ts:198

				const parsedData = await parseData({
					id,
					data,
					filePath,
				});

				if (existingEntry && existingEntry.filePath && existingEntry.filePath !== relativePath) {
					// Check the old file still exists - if not, this is likely a rename and
					// the unlink event just hasn't been processed yet
					const oldFilePath = new URL(existingEntry.filePath, config.root);
					if (existsSync(oldFilePath)) {
						const message = AstroErrorData.DuplicateContentEntrySlugError.message(
							collection,
							id,
							existingEntry.filePath,
							relativePath,
						);
						if (config.prerenderConflictBehavior === 'error') {
							throw new AstroError({
								...AstroErrorData.DuplicateContentEntrySlugError,
								message,
							});
						} else if (config.prerenderConflictBehavior !== 'ignore') {
							logger.warn(message);
						}
					}
				}

				if (entryType.getRenderFunction && !globOptions.deferRender) {
					let render = renderFunctionByContentType.get(entryType);

					if (!render) {
						render = await entryType.getRenderFunction(config);
						// Cache the render function for this content type, so it can re-use parsers and other expensive setup
						renderFunctionByContentType.set(entryType, render);
					}
					let rendered: RenderedContent | undefined = undefined;

View on GitHub (pinned to e294953aa8)

Solutions

  1. Delete or rename the duplicate file so each id maps to exactly one file (e.g. remove post.md after creating post.mdx).
  2. If both must exist, supply a custom `generateId` that includes the extension or another distinguishing part: `generateId: ({ entry, data }) => slugify(entry).replace(/\.md$/, '') + (entry.endsWith('.mdx') ? '-mdx' : '')`.
  3. Check whether the 'old' file really still exists — if it is a rename your watcher has not processed, saving again or restarting the dev server clears it.
  4. Set `prerenderConflictBehavior` to 'ignore' only as a last resort; the store keeps one arbitrary winner.

Example fix

// before
src/content/blog/
  post.md    // slug: post
  post.mdx   // slug: post  -> duplicate id

// after
src/content/blog/
  post.mdx   // slug: post  (old post.md deleted)

// or keep both with distinct ids
const Blog = defineCollection({
  loader: glob({
    pattern: '**/*.{md,mdx}',
    generateId: ({ entry, data }) => data.slug ?? slugify(entry),
  }),
});
Defensive patterns

Strategy: validation

Validate before calling

import { readdirSync } from 'node:fs';
import { extname, join, relative } from 'node:path';

function findSlugCollisions(dir: string, root: string): Map<string, string[]> {
  const bySlug = new Map<string, string[]>();
  const walk = (d: string) => {
    for (const f of readdirSync(d, { withFileTypes: true })) {
      const full = join(d, f.name);
      if (f.isDirectory()) walk(full);
      else {
        const rel = relative(root, full).replace(/\\/g, '/');
        const slug = rel.slice(0, rel.length - extname(rel).length);
        bySlug.set(slug, [...(bySlug.get(slug) ?? []), rel]);
      }
    }
  };
  walk(dir);
  return new Map([...bySlug].filter(([, files]) => files.length > 1));
}

Try / catch

try {
  await getCollection('blog');
} catch (err) {
  if ((err as any)?.code === 'DuplicateContentEntrySlugError') {
    // message lists both file paths; rename/delete one or adjust generateId
  } else throw err;
}

Prevention

When it happens

Trigger: `blog/post.md` and `blog/post.mdx` in the same collection (both slugify to 'post'); a custom `generateId` returning the same id for different files; same-stem files differentiated only by extension or casing on case-sensitive filesystems.

Common situations: Mixing markdown flavors in one collection; migrating .md to .mdx and leaving both copies; custom generateId that uses frontmatter (e.g. a shared `title`) instead of the filename; dev-server HMR where the rename detection has not caught up yet.

Related errors


AI-assisted analysis of withastro/astro@e294953aa8 (2026-08-18). Data as JSON: /api/errors/ddc8cb87a2390266. Report an issue: GitHub.