astro-content-collections
Skill2 starsChanged 3 months agoMITView on GitHub
Design, audit, and migrate Astro content collections. Use when auditing existing collections against best practices, moving hardcoded content or loose Markdown into collections, or designing the content structure for a new Astro site. Covers collection boundaries, format choice (YAML/JSON/Markdown/MDX), loaders, Zod schemas, and the query-side mistakes that fail silently.
1 other project uses a version of this.
Adopted by
Most recently taken up by
- Lexington-Themes/astro-skillstheir own version, first seen Nov 8, 2026
Other versions1 project uses a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 1
- Lexington-Themes/astro-skills★ 2same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
The file
We split this skill into the rules it gives an agent
6 rules. About 0 are common, 2 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 16
Verify against the repo before acting: src/content.config.ts present → collections exist.
- Very specific: line 23
Read astro from package.json.
- Distinctive: line 224
Read-only when the audit is the job: report first, fix only what the user picks.
- Very specific: line 227
Read src/content.config.ts, each collection's directory for the real layout and a sample entry, package.json for the Astro version, and — …
- Very specific: line 260
2. Propose a plan, and stop: Write nothing until the user agrees.
- Distinctive: line 269
Do not delete the old files yet.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Astro content collections
Route
Take the job from what the user asked for, or ask if not clear. Audit — existing collections checked or improved. Migrate — existing content moved into collections. Greenfield — a site with no collections yet; migrate from step 2, with the intended pages as the inventory.
Verify against the repo before acting: src/content.config.ts present → collections exist;
content still living in .astro pages or ad-hoc data files → migration, whether or not
collections already exist. If intent and the repo disagree, say so and confirm — never switch
jobs silently.
Version
Read astro from package.json. import { z } from 'astro/zod' — valid on 5, 6 and 7.
| Astro | Zod | Spellings |
| 5.x | 3 | z.string().email(), z.string().url(), z.number().int(), .refine(fn, { message }) |
| 6.x, 7.x | 4 | z.email(), z.url(), z.int(), .refine(fn, { error }) |
Examples below use Zod 4. This API needs Astro 5+; on 4 or below, stop and say the site needs
upgrading rather than writing the legacy src/content/config.ts.
Legacy markers on a repo that is already on 5+: z imported from astro:content or
astro:schema (still works — cleanup, not a bug), await entry.render() where render(entry)
from astro:content belongs, and src/content/config.ts with type: 'content' and no loader.
Collection boundaries
Q1–Q3 decide what the collections are. Each YES exits; a NO falls through to the next.
Q1. Would a wrong-but-valid value be expensive? ──► CODE, behind review
Q2. Does anything else point at it, or does it
genuinely appear in more than one place? ──► ITS OWN COLLECTION
Q3. Is it an ordered whole — a nav, a set of steps? ──► AN ARRAY FIELD on a broader collection
all three NO ───► a field on the collection where it appearsThe same three questions across five kinds of content:
| Content | Goes | Because |
| A Stripe price ID, an API key, a signed-off legal page | code, behind review | Q1 — wrong-but-valid, and expensive. The price shown on the page is not: wrong there is visible and fixed in a minute |
| Authors, credited on every article | its own collection | Q2 — articles reference() it |
| Brand name, contact email, footer text | its own settings collection | Q2 — every layout reads it, and it renders no page of its own |
| The nav menu | an array field on settings | Q3 — an entry per link scatters the order across files |
| The nine sections of the landing page | fields on one home entry | all NO — one page, and nothing else points at it |
A schema where most fields are .optional() usually means Q2 was answered once for two kinds of
content at once — blog posts need a date and an author, case studies a client and a result. Split
them, unless the variation is the editor composing each entry, which is Q6 and wants MDX instead.
Every entry is a record or a document
A record is filled in. A document is written. Q4–Q7 give each collection its format and loader.
Q4. Delete the body. Is the entry still the same thing?
│
├─ NO — it is nothing without its body ──────────────────────► DOCUMENT
│ │
│ └─ Q5. Does the body need components (callout, demo, chart)?
│ ├─ YES ─► MDX, glob()
│ └─ NO ─► Markdown, glob()
│
└─ YES — a team member without a bio is still a team member ─► RECORD
│
└─ Q6. Should the editor arrange the page, instead of the template — or does the layout vary entry to entry?
├─ YES ─► MDX, glob() — the arrangement leaves the schema
└─ NO
│
└─ Q7. Are entries added and reordered as one list?
├─ YES ─► YAML, file() — every entry in one file
└─ NO ─► YAML, glob() — a file eachThe same four questions across seven kinds of content:
| One entry is | Format, loader | Because |
| A guide with callouts and a chart in the prose | MDX, glob({ pattern: '**/*.mdx', base: './src/content/guides' }) | Q4 no — nothing without its body; Q5 yes |
| A changelog entry, plain prose | Markdown, glob({ pattern: '**/*.md', base: './src/content/log' }) | Q5 no — MDX would make a renamed component a build failure and every editor learn the component set, for nothing. |
/team and /about, each a different set of blocks | MDX, glob({ pattern: '*.mdx', base: './src/content/pages' }) | Q6 yes — the editor arranges the page, so the arrangement leaves the schema and one catch-all route renders whatever it finds |
| A team member with a three-paragraph bio | YAML, file('./src/content/team/team.yaml') | Q4 yes — delete the bio and it is still a team member, so a body does not make a document; Q7 yes — nine of them, reordered together |
| The nine sections of the landing page | YAML, glob({ pattern: '*.yaml', base: './src/content/home' }) | Q4 yes — every field is a headline or a label; Q6 no — the design owns the layout, so an editor can change every word and still not put the FAQ above the hero |
| Brand name, contact email, footer text | YAML, glob({ pattern: '*.yaml', base: './src/content/settings' }) | Q7 no — one file, edited on its own |
| Pricing rows exported from billing | JSON, file('./src/data/pricing.json') | another system writes it; a hand-edited file would want YAML's comments and multiline text |
Two tells that a format answer was wrong: real prose in a YAML block scalar, which loses links and
emphasis and means Q4 should have said document; and a listing that opens bodies with render()
to build a card, which means a field that listing shows is missing from frontmatter.
Glob patterns
Two independent choices, both silent when wrong — a pattern that matches nothing loads nothing and fails nothing.
Depth. * matches the top level only; **/* matches every subfolder. Default to **/*
unless the directory is deliberately flat: it costs nothing there, and it is what stops an entry
added in a subfolder from silently never loading.
Extension. One collection can take several. '**/*.{md,mdx}' is how a Markdown collection
gains components — npx astro add mdx, widen the brace, and existing .md entries keep working.
Schemas
A schema is not only a build check. It generates the TypeScript types, a CMS generates its editing form from it, and an agent writes valid entries against it without guessing. A constraint expressed as a field is visible to all three; a rule hidden inside a function is visible to none.
Required is the right default. Reach for .optional() only when absence is meaningful — when
a template genuinely branches on it — because every optional field is a branch every consumer has
to write. Reach for .default() for settings and flags (layout variants, toggles, column counts):
entry files stay minimal, and templates never branch because the value is always there.
Tie every limit to a reason the site can name. z.string().min(1).max(60) because longer
titles clip the index cards. A title that must fit a card is a design constraint the build is
right to enforce; "descriptions read best around 120 characters" is taste, and taste encoded as a
build failure teaches editors to fight the schema.
Prefer an enum wherever the design has a fixed set. z.enum(['accent', 'forest', 'grape'])
becomes a dropdown in a CMS and a union in TypeScript. A hex regex accepts #ff00ff and yields a
validation error the editor has to decode.
Nest fields that are consumed together, usually a component's props. Prefix clusters —
heroTitle, heroBody, ctaLabel — want to be hero: z.object({ … }). Optionality then lands
at the right level, so a half-filled section cannot ship, and <Hero {...entry.data.hero} />
gains a field with no page template in between. Fields that merely sound related stay flat.
Share sub-schemas. const link = z.object({ label: z.string().max(24), href: z.string() })
above the collections, reused by the nav, the footer and every CTA; .extend() where two
collections share a base. Once components import them too, move them to src/schemas.ts, where
z.infer<typeof link> gives the component its props from the same source.
.refine() is a last resort, only for rules that genuinely span fields. Attach the error to a
field with path so both the build failure and a CMS form point at it:
.refine((e) => !e.updatedDate || e.updatedDate >= e.publishDate,
{ error: 'updatedDate cannot be earlier than publishDate', path: ['updatedDate'] })Images
schema: ({ image }) =>
z.object({ name: z.string(), avatar: image().optional() });image() validates the path at build time and gives Astro intrinsic dimensions, so <Image>
cannot shift layout. Frontmatter paths resolve relative to the entry file.
image()exists only inside the schema function. A shared sub-schema containing one must takeSchemaContext:export const avatar = ({ image }: SchemaContext) => z.object({ src: image(), alt: z.string() }).image()does not support.refine().- Unoptimized assets (logos, favicons) live in
/publicand stayz.string(). External images usez.url()and need explicit dimensions passed to<Image>.
References
// in the schema
author: reference('team'),
related: z.array(reference('guides')).default([]),
// where it resolves
const author = await getEntry(guide.data.author);
if (!author) throw new Error(`${guide.id}: author ${guide.data.author.id} not found`);A reference stores a link instead of a copy, so a fact used in several places — a person's job
title, a plan's price — has one home. getEntry() and getEntries() resolve them, fully typed.
Every resolution needs a guard that throws, because reference() validates the link's shape
and never the target's existence: delete an entry and every reference to it still passes the
build. Neither shortcut works — author!.data.name fails on a type error naming a bundled chunk
rather than the entry, and author?.data.name renders nothing where the name should be.
Singletons
Site settings, the homepage, a privacy policy. Still its own collection and schema, shaped as
glob({ pattern: '*.yaml', base: './src/content/settings' }) at a directory holding exactly one
file — never file(), which reads a file as a set of entries and so cannot describe one.
Silent failures
With these failures, the build still passes but the page can render wrong content. Write against this list on every job, and check it first on an audit.
getCollection()guarantees no order. The docs call it non-deterministic and platform-dependent, so it can differ on the build machine. Every listing needs an explicit.sort(), on whatever field this site orders by.getCollection()returns every entry, unpublished included. Where the schema has a publication field — a boolean, a status enum, a date that can be in the future — every listing must filter on it:getCollection('guides', (e) => !e.data.draft). Most schemas have none, and then there is nothing to filter. Read the schema, don't assume.- Every
getEntry()needs a guard that throws. A reference to a deleted entry, or a singleton whose file was renamed, passes every build and returnsundefinedat render — nothing else catches it. strictNullChecksmust be on for those guards to mean anything. With it off, everygetEntry()result types as non-nullable, so a missing guard raises no type error and the guards that are there read as dead code.*matches the top level only. Nested content needs'**/*.md', or those entries never load and nothing fails.- A nested ID contains
/, so the route must be[...slug].astro, not[slug].astro. - Blank YAML parses to
null, not to a missing field..optional()rejectsnull; use.nullish()where an editor may leave the key empty. z.string()accepts""..min(1)is the difference between the field existing and the field saying something.
Audit
Read-only when the audit is the job: report first, fix only what the user picks. When it closes a migration, fix the findings inside that migration without asking again.
Read src/content.config.ts, each collection's directory for the real layout and a sample entry,
package.json for the Astro version, and — above all — every file that queries a collection,
because the failures that ship wrong content live at the call sites, not in the config.
Then walk what you found against this skill, in this order: the silent failures; Q1–Q3, for anything modelled as a collection that shouldn't be, or scattered across entries that should be one; Q4–Q7, for a format or loader that doesn't match how the content is actually written and edited; the glob patterns; and the schema rules.
Number every finding, worst first. Each one gives:
- file:line
- What is wrong, in a sentence
- What happens if nobody fixes it — the concrete consequence, so the user can prioritise
without knowing the rule. "The blog index reorders itself on the next deploy, and two drafts are
already public" ranks itself; "unsorted
getCollection()" does not - The fix, as the replacement code
Lead with how many of them ship wrong content today, because that is the number deciding what the user does next. If none do, say so plainly before anything else.
Migrate
Inventory, propose, stop for approval, then one collection at a time. Boundaries are cheap to change now and expensive once files have moved.
1. Inventory. Content hides in three places, and all three need finding before anything is
designed: prose and repeated markup inside .astro pages and components; ad-hoc data files under
src/, usually an exported array or a JSON blob; and Markdown or MDX that already exists.
Note what each piece is, who edits it, and how often. A fact printed on more than one page is the strongest signal it needs a home. Present the inventory as a table.
2. Propose a plan, and stop. Write nothing until the user agrees. Per collection: what one entry is, in a sentence; format and loader with the reason from the tree; schema sketch with required/optional/defaulted; references; and URL impact — what keeps its URL, what moves, what appears or disappears. Separately, what deliberately stays in code, with the reason. Order most entries and simplest shape first, so the first collection proves the config, the sync and the render path while the schema is still trivial.
3. Execute, one collection at a time. Define it in src/content.config.ts → move the content
→ rewrite the templates to read the collection → npx astro sync and fix what it names → build.
Do not delete the old files yet.
4. Verify before deleting anything. A passing build proves every entry matches its schema and nothing beyond that — an unresolved reference is a warning in the log, not an error. Compare the rendered pages and the list of URLs against the pre-migration build. Only then delete the old files, in their own commit.
5. Run the audit over the result, and iterate until it comes back clean or every remaining finding is a false positive.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

