Build
Content model
Define typed collections, singletons, validation, and the layout on disk.
10 min readOne model defines the editor screens, validation rules, Astro collections, TypeScript types, and files. Because there is only one model, these five things cannot disagree.
The model is a plain TypeScript module, src/mica.ts by convention. It imports nothing from Astro, so a script or a test can read it without a build system. A field carries editorial meaning that a validator cannot: text({ label: "Title", maxLength: 120 }) has a label, a width, and a group. Mica makes the Zod validators from the model, never the other way.
Collections and singletons
A collection contains many entries. Each collection has one ID field that identifies an entry in the repository and on the website. A singleton is one settings document, such as navigation or site metadata. It has no ID and no list.
export const site = defineSite({
root: "./content",
label: "Harbor Lane Consulting",
collections: {
projects: collection({
label: "Projects",
url: "/projects/{id}",
sortable: true,
fields: {
title: text({ required: true, listed: true }),
id: id({ from: "title" }),
summary: richText(),
gallery: media({ multiple: true, accept: "image", alt: "required" }),
},
}),
},
});
root is where the content is. It is relative to the Astro project root, like Astro's own glob({ base }), and not relative to src/mica.ts or to the directory where the build starts. The default is ./content. Mica uses an absolute path as it is: use one when the content is in a separate repository that CI checks out in a different location. For content in a Git submodule, see Content in its own repository.
label is the website's name for its editors, with 60 characters at most. The editor shows it at the top of its menu, and a click on it opens the Overview. Without a label, the menu shows the host name of MICA_SITE_URL, else "Mica".
A collection has one id() field. Mica reads the ID from the name of the entry's folder or file, so the ID is never empty. Field reference lists the options of collection() and singleton(), such as labelSingular, defaultSort, sortable and url. With url and the address of the website, an entry shows "Open on website": see Preview changes.
Becoming Astro collections
// src/content.config.ts
import { micaCollections } from "@micacms/astro";
import { site } from "./mica";
export const collections = micaCollections(site);
You do not change astro.config. The loaders find the content root, write the model to content/.mica/schema.json for the editor, and reload in astro dev when an entry is saved. In CI, the build fails when the committed schema.json is not the current model.
Collections that Mica does not edit
micaCollections returns a plain object. Put your own Astro collections beside it:
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const glossary = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/glossary" }),
schema: z.object({ term: z.string() }),
});
export const collections = { ...micaCollections(site), glossary };
Only the collections in the model go to the editor. Editors see the content that they must edit, and nothing else. Pages read both kinds with getCollection. Give each collection a different name: if two names are the same, the last one in the object wins, and nothing tells you.
When an entry must point to an item of such data, declare the data as a reference set. See Reference data.
Field types
Mica includes text, textarea, rich text, number, boolean, date, email, URL, ID, select, object, repeater, relation, media, color, coordinates, and tags fields. Field reference gives the options of each type. A tags field is a free list of short words, such as keywords, stored as a list of strings; suggestions offers words while the editor types.
Each field can include an editor label, help text, group, width, list column, and required state. A field can also be read-only, or hidden from the editor. Specific fields add rules such as length, number range, accepted media, or select options.
A required field can be empty in a saved draft, so an editor can save work that is not complete. Publish refuses the draft until the field has a value. Each validation message names the field by its label and gives the limit, for example "Title can have at most 120 characters (now 134)."
A rich-text field is Markdoc in its own .mdoc file beside the entry. It is not in index.json. Markdoc escapes raw HTML and shows an undeclared {% tag %} as nothing, so rich text cannot add a script to the website. When an editor types {% in the text, Mica writes it as \{%, so that it stays text.
A media field holds a path. In the file, the path is relative to the entry's folder, for example media/exterior.jpg. A path that starts with /, for example /media/logo.png, is relative to the content root.
A media field with alt: "required" or alt: "optional" stores { path, alt }, and the editor shows an alt-text input under each image. Use it for every image that carries meaning. A media field without alt stores only the path, for a decorative image or a document. You can add alt to a field that has content: Mica reads each stored path as an image without alt text. With "required", the build fails until each of these images has alt text. In Astro, a media field with accept: "image" is an Astro image (ImageMetadata), optimized at build time, and { src, alt } with alt: <Image {...entry.data.cover} /> needs no helper.
The accept option of a media field also takes a list of file types: media({ accept: ["gpx", "kml"] }). Mica knows images, PDF, GPX, KML, GeoJSON, ZIP and CSV, and it checks the type of each upload from its bytes. A file that is not an image reaches Astro as a path. Read it at build time with mediaFile: see Recipes.
A file uploaded in an entry's form goes into that entry's own media/ folder, so the entry keeps its files when its ID changes, and loses them when it is deleted. A file uploaded in the Media view goes into the shared media/ folder, for a logo or another file that many entries use. The picker in a form offers the entry's own files and the shared ones. Mica does not keep videos: put them on a video service and link to them. The Media view deletes a file only when no field and no rich text uses it, and the delete waits on the Publish page like any other change. A site with thousands of photos keeps them in Git LFS: see Large files with Git LFS.
A relation field stores the ID of the target entry. defineSite refuses a relation to a collection that does not exist, so the error shows before a page renders. A relation to a reference set stores the id of an item.
A coordinates field stores { lat, lng }. With the option map, the editor shows a map to click on, and the value also holds the zoom of the map. The website draws the same place with LocationMap from @micacms/astro/components. The map tiles come from OpenFreeMap or from a style that you name. See Field reference and Recipes.
Localization (i18n)
A site can be multilingual. Declare the languages in defineSite() with locales and defaultLocale, and give each field that has a translation the option localized: true:
export const site = defineSite({
locales: ["en", "de"],
defaultLocale: "en",
collections: {
news: collection({
label: "News",
url: "/{locale}/news/{id}",
fields: {
title: text({ label: "Title", required: true, localized: true }),
id: id({ from: "title" }),
body: richText({ label: "Text", localized: true }),
},
}),
},
});
A localized field keeps a value for each language in the same entry: "title": { "en": "Opening hours", "de": "Öffnungszeiten" }. A rich-text field has one file for each language, such as body.de.mdoc. required applies to the main language. On the website, localize() gives the values of one language, with the fallback for a missing translation. Localization shows the Astro side, the addresses and the editor.
The file layout
Each entry and each singleton is a folder with an index.json file. Rich text and media go into the same folder. Astro does the same: its glob() loader gives house-a/index.json the ID house-a.
content/ ├── .mica/schema.json the model, written by the Astro build ├── .mica/references/ the index of each reference set, written by the Astro build ├── projects/ a collection: a folder for each entry │ ├── _order.json │ └── house-a/ │ ├── index.json the structured fields │ ├── summary.mdoc one file for each rich-text field │ └── media/exterior.jpg ├── team/ │ └── anna-vogt/ │ └── index.json structured fields only ├── navigation/ │ └── index.json a singleton └── media/ shared media, for example a logo
The shared media/ folder is beside the folders of the collections and singletons, so media is not a valid name for a collection or a singleton.
Keys stay in model order. A repeat save of the same values gives the same bytes. This keeps the Git diff focused. The content format specification has every rule, with examples that a test checks.
Change the model
Run an Astro build after each model change and commit the new .mica/schema.json with the model. In CI, the build fails until you do. The website uses the new model at once. astro check finds a template that uses an old field name; astro build does not check types. Mica reads the new model without a restart: an open page tells the editor to save and load the page again. A new required field makes old entries invalid until an editor adds the value. The build reports this problem before deployment. Some changes need more steps: see Model changes.
A new rich-text or media field adds a file to the folder of each entry when an editor fills it in.
Next
Read Architecture to see how the model, the repository, and the editors fit together.