Skip to main content
@shmVirus

Astro Content Collections: The Gotchas That Survive a Real Project

Practical lessons about loaders, schemas, IDs, rendering, dates, and static routes when moving an established site to Astro’s current content layer.

Evergreen

9 min read1,731 words

Content collections look simple in a small example: define a schema, place Markdown in a folder, call getCollection(), and render the result. That model is accurate, but it hides the decisions that become important once collections drive several parts of a site.

A real project may use the same entries to build index pages, detail routes, search records, a sitemap, home-page highlights, and previous/next navigation. A weak assumption about IDs or dates then spreads across all of those surfaces.

These are the issues I would check first when creating or migrating an Astro content layer.

The content config moved for a reason

Current Astro projects use a top-level content configuration file:

src/
├── content.config.ts
└── content/
    ├── projects/
    ├── publications/
    └── writing/

Older projects may still have src/content/config.ts. That file belonged to the legacy collections system. Moving it is not only a path change: current collections also require a loader that explains where entries come from.

import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";

const writing = defineCollection({
  loader: glob({
    pattern: "**/*.{md,mdx}",
    base: "./src/content/writing",
  }),
  schema: z.object({
    title: z.string(),
    date: z.coerce.date(),
    summary: z.string(),
  }),
});

export const collections = { writing };

The explicit loader makes the collection portable. Content does not have to live under src/content, and a project can combine local files with other data sources without pretending they are the same mechanism.

Give every collection its own base

A broad loader such as base: "./src/content" with a clever pattern can work, but separate bases are usually easier to reason about:

glob({ pattern: "**/*.{md,mdx}", base: "./src/content/writing" })

Now an entry ID is relative to the writing directory, and moving the publications directory cannot affect writing URLs. The small repetition in configuration buys clearer ownership.

IDs are not legacy slugs

Legacy content entries exposed a slug property. Loader-based entries expose an id. For a Markdown file named:

src/content/writing/content-collections-gotchas.md

the glob loader produces an ID suitable for addressing that entry:

entry.id // "content-collections-gotchas"

The migration mistake is easy to make:

// Legacy pattern: undefined on loader-based entries
params: { slug: entry.slug }

// Current pattern
params: { slug: entry.id }

An undefined route parameter may not fail during content synchronization. It fails later, while Astro generates static routes, with an error such as Missing parameter: slug. The stack trace points at the route generator rather than at the collection migration that caused the value to disappear.

Search every consumer, not only getStaticPaths(). IDs often appear in:

  • card links;
  • search indexes;
  • XML sitemaps;
  • maps keyed by entry;
  • DOM IDs;
  • home-page sections;
  • previous and next article links.

A repository-wide search for .slug is a much more reliable migration strategy than waiting for each broken link to appear.

Rendering is now a function

Another legacy pattern is rendering directly from an entry:

const { Content } = await entry.render();

Current Astro exposes render() from astro:content:

---
import { getCollection, render } from "astro:content";

const entries = await getCollection("writing");
const entry = entries[0];
const { Content, headings } = await render(entry);
---

<Content />

This API also provides the headings extracted from Markdown. They can power a table of contents without parsing the HTML:

const tableOfContents = headings.filter(
  (heading) => heading.depth === 2 || heading.depth === 3
);

The rendering change is another reason to audit every detail page after a migration. Index pages can build successfully while the first article request fails because only detail routes render Markdown bodies.

Coerce dates at the boundary

YAML frontmatter represents a date as text. Application code usually wants a Date. Coerce once in the schema:

schema: z.object({
  title: z.string(),
  date: z.coerce.date(),
})

Then sorting is explicit:

entries.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());

Without coercion, code tends to accumulate a mixture of string comparison, new Date() calls, and optional chaining. That is both harder to read and easier to get subtly wrong.

Use an unambiguous ISO value in frontmatter:

date: "2026-07-17"

If the site cares about a publication time rather than a calendar date, include a timezone. A date-only value is usually appropriate for articles because readers do not care whether a post was published at 09

or 09
.

Defaults belong in schemas

Optional fields are useful when absence has meaning. They are less useful when every consumer immediately invents the same fallback.

Consider topics and featured status:

schema: z.object({
  topics: z.array(z.string()).default([]),
  featured: z.boolean().default(false),
})

Every entry now provides an array and a boolean. Components can map topics or test featured status without repeating ?? [] and ?? false.

Use defaults for stable application behavior. Keep a field optional when “not provided” is genuinely distinct. A publication DOI, for example, is naturally optional. An array of tags is usually better represented as an empty array.

Avoid a schema that mirrors the page

It is tempting to add frontmatter for every visible section:

intro:
firstSection:
secondSection:
closingParagraph:

That turns Markdown into an awkward data-entry form. The schema should describe metadata the application needs to sort, filter, label, or connect. The article’s argument belongs in the Markdown body.

A good question for each field is: does code need to understand this value? If only a reader needs it, it probably belongs in prose.

Discriminated unions are useful for mixed collections

Sometimes one collection intentionally holds related entry types. A teaching collection might contain courses and chapters:

const courses = defineCollection({
  loader: glob({
    pattern: "**/*.{md,mdx}",
    base: "./src/content/courses",
  }),
  schema: z.discriminatedUnion("kind", [
    z.object({
      kind: z.literal("course"),
      course: z.string(),
      title: z.string(),
      outcomes: z.array(z.string()).optional(),
    }),
    z.object({
      kind: z.literal("chapter"),
      course: z.string(),
      title: z.string(),
      path: z.string(),
      order: z.number().int().nonnegative().optional(),
    }),
  ]),
});

The kind field gives TypeScript and the schema a shared way to distinguish the shapes. This is safer than a single object with fifteen optional fields.

The tradeoff is that consumers need to narrow the union:

const chapters = entries.filter(
  (entry) => entry.data.kind === "chapter"
);

If two entry types do not share routes, queries, or presentation, separate collections may still be simpler. Use a union because the types form one domain, not merely because they happen to be stored nearby.

Static routes should carry the entry

For a detail page, getStaticPaths() can pass the full entry as a prop:

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

export async function getStaticPaths() {
  const writing = await getCollection("writing");

  return writing.map((entry) => ({
    params: { slug: entry.id },
    props: { entry },
  }));
}

const { entry } = Astro.props as {
  entry: CollectionEntry<"writing">;
};

const { Content } = await render(entry);
---

This keeps route generation and route rendering aligned. The page does not need to query the collection a second time and hope it resolves the same identifier.

For previous and next navigation, sort once inside getStaticPaths() and pass the neighbors with each route. Sorting again within each generated page is needless work and creates another place for date-order bugs.

Search and sitemap code are schema consumers

Generated endpoints often escape attention during a migration because they do not have a visual page. They still depend on the content model.

A search record might look like:

{
  title: entry.data.title,
  section: "Writing",
  url: `/writing/${entry.id}`,
  text: [
    entry.data.summary,
    ...entry.data.topics,
  ].filter(Boolean).join(" "),
}

A sitemap uses the same ID:

for (const post of await getCollection("writing")) {
  paths.add(`/writing/${post.id}`);
}

When detail routes change successfully but search results still contain undefined, the site can appear healthy until a reader uses the search modal. Treat generated endpoints as first-class pages in the verification checklist.

Markdown processors are now explicit

Astro’s default Markdown processor changed. A project that supplies Remark or Rehype plugins—mathematics, directives, custom code tabs, or another Unified plugin—needs the corresponding processor adapter.

import { unified } from "@astrojs/markdown-remark";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";

export default defineConfig({
  markdown: {
    processor: unified({
      remarkPlugins: [remarkMath],
      rehypePlugins: [rehypeKatex],
    }),
  },
});

Putting plugins directly under markdown.remarkPlugins may still trigger compatibility behavior, but configuring the processor explicitly communicates the dependency and avoids deprecated configuration.

This is not strictly a content collection concern, yet migrations often reveal both issues together: content is loaded through the new layer while Markdown still expects the legacy Unified pipeline.

Build errors arrive in layers

Framework upgrades often stop at the first incompatible assumption. Fixing that error does not mean the migration is finished.

A typical sequence can be:

  1. missing Markdown processor adapter;
  2. legacy content config path;
  3. collections without loaders;
  4. undefined route parameters from entry.slug;
  5. detail pages calling entry.render();
  6. generated endpoints using old identifiers.

This is normal. Run the build after each coherent change and treat the next failure as new information. Avoid making ten speculative changes before checking whether the previous one was correct.

A migration checklist

When moving an established Astro site to loader-based content collections, I use this list:

  • move configuration to src/content.config.ts;
  • import z from astro/zod;
  • add an explicit loader to every collection;
  • preserve collection-specific schemas and defaults;
  • replace entry slug usage with id;
  • replace entry.render() with render(entry);
  • inspect getEntry() calls for the expected IDs;
  • check every static route parameter;
  • update card links, search records, and sitemap paths;
  • configure a Unified processor if Remark/Rehype plugins are used;
  • run the production build;
  • request at least one URL from each content family;
  • inspect both an index page and a rendered Markdown page.

The last two checks matter. A successful synchronization proves that entries were discovered and validated. It does not prove that routes, rendering, and cross-links are correct.

Closing thought

Content collections are most valuable when the rest of the application can stop caring where content came from. Loaders define the source, schemas define the contract, and entry IDs define the address. Pages should depend on those stable pieces instead of filesystem folklore.

Most collection “gotchas” are boundary problems: a date is converted too late, a filename convention leaks into a component, or a legacy property survives in a generated endpoint. Make the boundaries explicit, and the content layer becomes pleasantly uneventful—which is exactly what infrastructure should be.

Written bySabbir Hosen Mamun

Academic • Programmer • Linux Enthusiast