How the schema becomes the admin
Every Zod shape the walker maps to a control, the two heuristics behind long text, and the one schema style that will not work today.
Exactly one module reads a collection’s Zod schema, and it hands that schema to an introspection walker that returns a list of field definitions the rail renders. There is no registry of field types to maintain and no second description of your content anywhere in the CMS. Which fields carry which role is your admin.config.ts.
The mapping
| Zod | Control |
|---|---|
z.string() |
Text input |
z.string() with .max() above 160 |
Textarea |
z.string() named description, about, excerpt, summary, bio or abstract |
Textarea |
z.string() named slug |
Slug field |
z.number() |
Number input, with .min() / .max() as bounds |
z.boolean() |
Toggle |
z.date() / z.coerce.date() |
Date picker |
z.enum([…]) |
Select |
z.array(<any of the above>) |
Repeating control over that leaf — chips for strings |
z.array(z.object({…})) |
Repeater with a panel per row |
z.object({…}) |
Nested group, up to four levels deep |
reference("authors") |
Reference picker |
z.union([z.string(), z.date()]) |
Date picker — the safer of the two for a human |
| a union of all strings | Text input |
| anything else | Read-only, with the reason printed |
.optional() marks the field not required. .default(…) is shown by the control but never written — writing it would emit a frontmatter key your schema was already filling in.
A .max() on a string becomes the input’s maxlength, which is deliberately not the same number as the SEO truncation advice beside it. A hard schema constraint and a search-result truncation point are two different facts; conflating them either lets you save an entry Zod will reject, or stops you twenty characters early for no reason the screen can explain.
Two heuristics, named as heuristics
Zod carries no “this is long prose” signal, so the textarea decision is a name list plus a length reading — the six names above, or a .max() over 160. If neither fires, you get a single-line input for a paragraph.
That is a known rough edge rather than a rule. If it guesses wrong for you, the honest fix today is a .max() that reflects the real constraint.
Unknown is a real answer
Anything the walker cannot map comes back as a read-only row with its reason printed, not as a guess and not as a dropped field.
pubDate: 2023-10-27T10:00:00.000Z
That is not a failure state and not a bug to report. It is the reader declining to be confident about a field whose shape it cannot act on, which is the only behaviour that does not eventually corrupt somebody’s frontmatter.
The same applies one level down: an array whose elements are not a list of objects answers not a list of objects rather than coercing.
The one schema style that does not work today
Your collection schema must be a plain z.object().
Astro also lets you declare a schema as a function of its context — which is how image() and reference() get theirs:
// This will throw.
const blog = defineCollection({
schema: ({ image }) =>
z.object({
title: z.string(),
cover: image(),
}),
});
Such a schema has no .shape until it is called, so the reader throws with a message naming the collection and saying exactly that. It is a clear failure rather than a silent empty form, but it is a failure.
// This works.
const blog = defineCollection({
schema: z.object({
title: z.string(),
cover: z.string().optional(),
}),
});
What to do about images
Use z.string() for any image the CMS should edit. That is what the shipped demo schema does, and the reasoning is worth borrowing:
image() gives you build-time validation and an optimised asset, but it resolves to an ImageMetadata object the rail cannot edit. And a social image has to be a plain file in public/ anyway — the crawler that fetches it runs no JavaScript and often will not follow a redirect.
So a string is both editable today and what the meta tag actually needs. If you have image() fields you do not need the CMS to edit, keep them in a separate collection that the CMS does not manage.
The walker does have an image path: a string carrying the marker astrocraft:image in its .describe() becomes an image control backed by your library. Wiring a SchemaContext schema through that stub is the documented upgrade, and it is not built.
The Schema tab
The editor’s rail can show you exactly what the walker concluded for the collection you are in — field by field, with the reason for any unknown. That is the fastest way to find out why a control is not what you expected, and it beats reasoning about the table above.
Validation happens twice
The publish gate parses every outgoing entry against this same schema and refuses the push, naming the field. Astro validates again at build time, because that is where the real gate is and nothing should route around it.
Notably, the save does not validate. A draft may be half-written, and refusing to save an unfinished entry is the wrong trade. If the two checks ever disagreed the build wins — and they cannot disagree for long, because they read the same file.
Troubleshooting
The editor throws naming a collection and z.object(). Your schema is a function of SchemaContext. See above.
The editor throws saying the schema is not a Zod 4 node. The walker reads Zod 4 internals. One Zod resolves in a normal Astro 7 install, through Astro itself, so this usually means a duplicated Zod in your dependency tree.
A field renders read-only. Check the Schema tab for its reason. A transform whose output type differs from its input is the classic one — string | Date in, Date out, and no control is correct for both sides.
A paragraph field is a one-line input. The textarea heuristic did not fire. Add a realistic .max() above 160, or name the field one of the six.
A field is missing entirely. It cannot be — an unmappable field still renders read-only. A field genuinely absent from the rail is absent from your schema.