# 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.

Source: https://editor.astrocraftthemes.com/docs/schema-fields/

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`](/docs/configuration/).

## 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.

```yaml
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:

```ts
// 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.

```ts
// 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](/docs/entry-fields/) 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.