Repository navigation
Custom Content Types
How to define your own collections, such as events, team members, enquiries or job applications, with validated fields, a REST API, an admin entries screen and optional public forms. It is for operators building a site's data model and for developers reading or writing those collections over the API.
A content type becomes available on the next request after you save it. It gets:
- REST endpoints at
/api/content/<name>; - validation from its field rules;
- an entries screen at
/admin/content/<name>, listed under Collections in the sidebar; - optionally, a public form at
/forms/<name>.
No restart, plugin or deploy is needed.
| Source | How | Editable in the admin? |
|---|---|---|
| Admin builder | Admin → Content types (/admin/content-types, admin only) |
Yes |
| Plugins | Declared in a plugin manifest, or registered with registerContentType()
|
Shown read-only, with the source named |
| Built in |
comment and review, when switched on in Settings |
No |
The builder and plugin manifests are validated by the same code, src/core/field-rule-build.ts, so both accept the same field kinds and the same rule options. The builder's types are stored as one list in the custom_content_types setting, and the whole list is replaced on each save.
The following names are reserved: post(s), category/categories, user(s), media, theme(s), setting(s), plugin(s), auth, backup, content, search, newsletter, contact, messages, product(s), brand(s), product-category/product-categories, order(s), customer(s), changes, comment(s) and review(s).
Plugin authors: see Plugins and PLUGIN_DEVELOPMENT.md.
| Key | Required | Meaning |
|---|---|---|
name |
yes | kebab-case, 2 to 41 characters, ^[a-z][a-z0-9-]{1,40}$. It becomes the API path |
label |
yes | Singular label, such as "Event" (up to 80 characters) |
labelPlural |
no | Plural label for the admin. Defaults to the label plus "s". The builder has no field for it |
fields |
yes | 1 to 40 fields |
visibility |
no | Who may read: staff (default) or public
|
writable |
no | Who may create: staff (default) or public. public makes the type a form |
moderated |
no |
true holds public submissions until approved |
notifyOnSubmission |
no |
true emails the site's admin address when a public submission arrives |
steps |
no | Step titles for a multi-step form (2 to 10 steps; a single step is not stored) |
An install can hold up to 20 admin-defined types.
Both default to staff. You have to choose public explicitly.
visibility |
writable |
Typical use |
|---|---|---|
staff |
staff |
Internal records |
public |
staff |
Content a front end renders (events, team) |
staff |
public |
A private form: enquiry, job application, RSVP |
public |
public |
A guestbook, or comments with moderated: true
|
If you choose public for both, the builder warns you, because then anyone can read everything anyone submits.
An anonymous request for a staff-only type gets 404, the same answer as for a type that does not exist. That way the API does not reveal which private collections a site has.
Each field has a name (^[A-Za-z_][A-Za-z0-9_]{0,40}$), a rule, and optionally a label, a step and a showIf.
A field is required unless its rule has "optional": true. The builder shows this as a required checkbox.
These are all the kinds in FIELD_TYPES:
| Kind | Builder label | Accepts | Rule options |
|---|---|---|---|
string |
Text | A string without control characters |
min, max (length). With no max, the limit is 20,000 characters |
number |
Number | A JSON number or a plain numeric string |
min, max, int (whole numbers only) |
boolean |
Yes / No |
true or false
|
none |
enum |
Choice (options) | One of the listed values |
values: 1 to 50 strings, each up to 120 characters |
date |
Date |
YYYY-MM-DD or an ISO date-time. The day must actually exist |
none |
email |
Something shaped like a@b.c, up to 200 characters |
none | |
url |
URL | An absolute http or https URL, up to 2000 characters |
none |
slug |
Slug | Lowercase letters, digits and single dashes, up to 200 characters | none |
id |
Id (non-empty text) | 1 to 64 characters from A-Z a-z 0-9 _ -
|
none |
array |
List | A list of strings or of numbers |
of: "string" (default) or "number", max (items) |
ref |
Link to another type | The id of an entry in another collection |
to: the target collection's name (required) |
media |
Image / file | The id of a media-library item | none |
file |
not in the builder | A file uploaded through the public form's upload endpoint (pf_ + 20 hex characters) |
none |
repeater |
Repeating group | A list of small records |
fields or layouts, plus min and max (items) |
In the builder, the options box sets values for a Choice field and to for a Link field. The builder has no controls for min, max, int, of or layouts, or for the file kind. It keeps any of these that are already on a field when you save. To set them, send the definitions to PUT /api/content-types or declare them in a plugin manifest.
| Name | Why |
|---|---|
_status |
Holds the moderation state. The server sets it, and submitters cannot |
hp_url, pow_token
|
The form's honeypot and proof-of-work fields |
__proto__, constructor, prototype
|
Unsafe object keys |
<media>_url, <media>_alt
|
Added on read for each media field, see below |
_layout (inside a repeater) |
Records which layout an item uses |
label is what editors and visitors see, such as "Seats left" for seats_left. It is plain text, up to 80 characters. Without it, screens show a readable version of the field name.
A media field stores the media item's id. When the entry is read, the API adds two extra fields next to it:
-
<name>_url: the file's current URL; -
<name>_alt: its alt text, only when the file has some.
The stored id never changes, so moving the site to a new domain or replacing the file does not break the entry. If the file has been deleted, no _url is added. This also applies to media fields inside repeaters.
A ref field stores the id of an entry in the collection named by to. Every create and update checks that the target exists. If it does not, the request fails with a 422 validation error and the entry is not stored. The same check applies to:
-
reffields that point at a collection which no longer exists. These are refused; -
mediaids, which must be in the media library; -
fileids, which must be an upload that is still on disk.
The check also covers fields inside repeaters. On an update, only fields present in the request body are checked. Source: src/lib/content-refs.ts.
The to collection does not need to exist when you define the type. It only needs to exist when an entry is written.
A repeater is an ordered list of items, where each item is a small record. It comes in two forms, and a repeater uses one or the other:
-
fields: every item has the same shape, such as opening hours withday,opensandcloses. -
layouts: each item picks one of up to 12 named shapes, and the item records its choice in_layout. Layout names are kebab-case. This is the "flexible content" pattern.
Limits:
- One level only. A repeater cannot contain another repeater.
- Up to 20 sub-fields per repeater or per layout.
-
minandmaxare whole-number item counts. The server never accepts more than 200 items, whatevermaxsays. - Validation errors name the item and field, for example
hours[3].opens.
The builder can create and edit fields repeaters. It shows layout repeaters and preserves them, but cannot edit them.
Give the type a steps list of titles, then give each field a step number starting at 1. Fields without a step go on step 1. A field cannot name a step beyond the list. The public form then shows one step at a time. Steps affect only how the form is displayed. Validation covers the whole record.
{ "name": "company", "rule": { "type": "string", "optional": true },
"showIf": { "field": "enquiry_type", "equals": "business" } }A field with showIf is shown only when an earlier field equals the given value. Values are compared as strings, so true, "true" and 1 versus "1" all match. Each field can have one condition, and the only test is equality. Because a condition can only name a field declared before it, two fields can never depend on each other in a loop.
The server applies the same rule on every write, whether it comes from the form or directly from the API:
- a hidden field becomes optional;
- a value submitted for a hidden field is thrown away;
- if a field is hidden, any field that depends on it is hidden too.
showIf only changes what the form shows. It does not control who can see a field. The whole definition is served to the browser, so a field only staff should see must not be in a public form's type at all.
Setting writable: "public" turns the type into a form that anyone can submit.
/forms/<type> renders a working form for any type that accepts public submissions. It includes the honeypot and the anti-spam check. A theme can put the same form elsewhere with the SubmissionForm component. A type without public writes returns a real 404 at that address.
GET /api/forms/<type> is public. It returns only what a front end needs to draw the form:
{
"name": "enquiry",
"label": "Enquiry",
"labelPlural": null,
"steps": null,
"stepCount": 1,
"fields": [
{ "name": "email", "label": "Your email", "rule": { "type": "email" }, "step": 1, "showIf": null }
],
"honeypot": "hp_url",
"powField": "pow_token",
"submitTo": "/api/content/enquiry"
}It returns 404 for unknown types and for types that do not accept public writes.
Send POST /api/content/<type> with a JSON body containing the declared fields plus the two extra form fields:
| Field | Value |
|---|---|
hp_url |
The honeypot. It must be present and empty. Real visitors never see it |
pow_token |
The proof-of-work answer, required when the anti-spam check is on for public forms |
The server checks each submission in this order:
-
Rate limit: 10 submissions per 15 minutes per IP, counted separately for each form. Over the limit returns
429. -
Honeypot: if
hp_urlis filled in, the server replies201as if the submission worked, but stores nothing. -
Proof of work: a missing or stale token returns
403"anti-spam check failed". -
Spam score: structural signals are scored. The score never causes a rejection. A flagged entry is stored with
spam_flagged,spam_scoreandspam_reasonsfor a person to review.
Only declared fields are stored. The server adds submitted_at, and _status: "pending" if the type is moderated. The response is { "id": "<new id>" } and nothing else, so a submitter never sees what else is stored. Admins and editors who post to the same endpoint are treated as staff: they skip these checks and their entries are approved immediately.
Source: src/lib/public-submission.ts.
The anti-spam check runs entirely on your server, with no third-party CAPTCHA. It is off by default. Turn it on for all public forms at once under Settings → Anti-spam check → "Public forms". The flow:
- The page requests
GET /api/captcha/challenge?surface=forms. It gets back{ "enabled": false }, or{ "enabled": true, "token", "bits" }. - The
/captcha.jswidget finds a numbernsuch that SHA-256 of<token>.<n>starts withbitszero bits. It puts<token>::<n>intopow_token. - The server checks the signature, the expiry (10 minutes), the surface and the difficulty. Each token can be used only once.
The difficulty defaults to 15 bits. CAPTCHA_BITS changes it, within the range 8 to 22. Protected forms need JavaScript in the visitor's browser. Source: src/lib/captcha.ts.
A file field lets a visitor attach a document, such as a CV. The upload happens first and is kept private.
-
POST /api/forms/<type>/uploadasmultipart/form-data, with the file infile, plushp_urlandpow_token. - The response is
{ "id": "pf_…", "original_name", "size" }. It never includes a URL. - Put that id in the file field when you submit the entry.
| Limit | Value |
|---|---|
| Formats | PNG, JPEG, GIF, WebP, PDF |
| Size | 5 MB per file |
| Rate | 5 uploads per 15 minutes per IP, per form |
| Total per form | 1024 MB by default (form_upload_quota_mb setting). A full form answers 413 with code forms.upload_quota_exceeded
|
| Unclaimed uploads | Deleted after 24 hours (form_upload_orphan_hours setting) |
The endpoint returns 404 for a type that has no file field. Uploaded files are not stored under /uploads. Only signed-in staff can download them, from /api/forms/file/<id>, and they are sent as attachments rather than displayed in the browser.
With notifyOnSubmission: true and an email channel configured, each submission sends a plain-text email listing the fields. If the type has an email field, its value is used as the Reply-To address. See Email. Every public submission also fires the content.submitted webhook event.
With moderated: true:
- public submissions arrive as
pendingand are hidden from the public; - entries created by staff arrive as
approved; - public reads (
GETlist and single entry) return onlyapprovedentries; - staff see every state, including
rejected.
The entries screen shows how many are waiting, filters by state, and has Publish and Hide buttons. Over the API, staff change the state by sending "_status": "approved" | "rejected" | "pending" in a PUT. Entries written before a type became moderated have no _status, and count as approved. Source: src/core/moderation.ts.
/admin/content/<type>, for admins and editors. It builds its form from the type's fields:
- media fields show a thumbnail, a searchable picker for the media library, and Upload new (PNG, JPEG, GIF, WebP) right there;
-
ref fields are a dropdown listing the target collection's entries. Each entry is shown by its
name,title,label,slugoremail, whichever comes first; - file fields link to the staff-only download;
- repeaters add and remove items;
-
showIffields appear and disappear as you fill in the form.
The table shows the first media field as a thumbnail, followed by up to four other columns. Taxonomy terms for a collection cannot be set on this screen. Staff set them over the API by sending a terms object (for example { "brand": ["acme"] }) with a create or update. Terms are only kept for taxonomies whose Applies to list includes the type. Public submissions cannot set terms.
| Method | Path | Who |
|---|---|---|
GET |
/api/content/<type> |
Anyone if visibility: public, otherwise any signed-in user or API key |
GET |
/api/content/<type>/<id> |
Same as the list |
POST |
/api/content/<type> |
Admins and editors, or anyone if writable: public
|
PUT |
/api/content/<type>/<id> |
Admins and editors. Partial update: send only the fields to change. null or "" clears an optional field |
DELETE |
/api/content/<type>/<id> |
Admins and editors |
GET |
/api/content-types |
Any signed-in user: every registered type with its fields and source |
PUT |
/api/content-types |
Admin: replaces the full list of admin-defined types |
GET |
/api/forms/<type> |
Public form schema, see above |
Records come back as { "id", "data": { …fields }, … }. For API keys, the content scope governs these routes (content:read, content:write). See Admin tour.
GET /api/content/event?where.city=Athens&limit=20&page=2
| Parameter | Meaning |
|---|---|
where.<field>=<value> |
Keep entries whose declared field equals the value, compared as strings. For array fields, any matching item counts. Repeat the parameter to combine filters with AND. Undeclared field names are ignored |
limit |
Page size, at most 200 |
offset |
Rows to skip |
page |
1-based page, used with limit
|
With no limit, at most 1000 rows come back. meta includes total, count, limit, offset, page and hasMore. Filters apply after the visibility and moderation rules, so a filter can never show you entries you are not allowed to see. The only comparison is equality.
| Event | When |
|---|---|
content.created |
A staff member created an entry |
content.updated |
An entry was updated |
content.deleted |
An entry was deleted (the payload has type and id) |
content.submitted |
A public form submission was stored |
Each payload has type (the collection name) and entity. Subscribe with content.* to get all four. See Webhooks. Entry changes also appear in the change feed at /api/content/changes. Anonymous callers only see changes to public types.
An event type, publicly readable and edited by staff. It links to a venue, has a photo, and has a repeating schedule:
[
{
"name": "venue",
"label": "Venue",
"visibility": "public",
"fields": [
{ "name": "name", "rule": { "type": "string", "min": 2, "max": 120 } },
{ "name": "city", "rule": { "type": "string", "max": 80 } }
]
},
{
"name": "event",
"label": "Event",
"labelPlural": "Events",
"visibility": "public",
"writable": "staff",
"fields": [
{ "name": "title", "label": "Title", "rule": { "type": "string", "min": 3, "max": 140 } },
{ "name": "starts", "label": "Starts on", "rule": { "type": "date" } },
{ "name": "venue", "label": "Venue", "rule": { "type": "ref", "to": "venue" } },
{ "name": "photo", "label": "Photo", "rule": { "type": "media", "optional": true } },
{ "name": "seats", "label": "Seats", "rule": { "type": "number", "min": 0, "int": true } },
{ "name": "format", "rule": { "type": "enum", "values": ["in-person", "online"] } },
{ "name": "stream_url", "label": "Stream link",
"rule": { "type": "url", "optional": true },
"showIf": { "field": "format", "equals": "online" } },
{ "name": "schedule", "label": "Schedule",
"rule": { "type": "repeater", "max": 20, "optional": true, "fields": [
{ "name": "time", "rule": { "type": "string", "max": 5 } },
{ "name": "talk", "rule": { "type": "string", "max": 200 } }
] } }
]
}
]Save it (admin session or admin API key):
curl -X PUT https://cms.example.com/api/content-types \
-H "Authorization: Bearer abk_…" -H "Content-Type: application/json" \
--data @types.jsonPUT replaces all admin-defined types. Always send the complete list, including types you are not changing. A read then returns the resolved photo URL:
{ "id": "…", "data": { "title": "Spring meetup", "venue": "v1", "photo": "m42",
"photo_url": "/uploads/2026/03/ab12…-w1600.webp", "photo_alt": "Speakers on stage" } }A private, two-step enquiry form that emails the site's admin address:
{
"name": "enquiry", "label": "Enquiry",
"visibility": "staff", "writable": "public", "notifyOnSubmission": true,
"steps": [{ "title": "About you" }, { "title": "Your question" }],
"fields": [
{ "name": "email", "label": "Your email", "rule": { "type": "email" } },
{ "name": "kind", "rule": { "type": "enum", "values": ["personal", "business"] } },
{ "name": "company", "rule": { "type": "string", "optional": true },
"showIf": { "field": "kind", "equals": "business" } },
{ "name": "message", "step": 2, "rule": { "type": "string", "min": 10, "max": 4000 } }
]
}It is live at /forms/enquiry, and its schema is served at /api/forms/enquiry.
- Content and media
- Headless and REST API
- Webhooks
- Plugins
- Security
- Importing: CSV and JSON export and import of any collection
AstroBaaS is free software under GPL-3.0-or-later. This wiki describes the latest release; the repository and its CHANGELOG are the final word. Found a mistake here? Open an issue.
Getting started
Using the CMS
Building with it
Extending
Commerce and email
Operating
Project