Skip to content

Custom Content Types

Theodor Dimitriou edited this page Sep 24, 2026 · 2 revisions

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.

Where types come from

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.

The type definition

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.

Visibility and write policy

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.

Fields

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.

Field kinds

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

Reserved field names

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

Labels

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.

Media fields

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.

Ref fields and reference checking

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:

  • ref fields that point at a collection which no longer exists. These are refused;
  • media ids, which must be in the media library;
  • file ids, 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.

Repeaters

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 with day, opens and closes.
  • 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.
  • min and max are whole-number item counts. The server never accepts more than 200 items, whatever max says.
  • 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.

Multi-step forms (steps)

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.

Conditional fields (showIf)

{ "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.

Public forms

Setting writable: "public" turns the type into a form that anyone can submit.

The form page

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

The form schema for headless front ends

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.

Submitting

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:

  1. Rate limit: 10 submissions per 15 minutes per IP, counted separately for each form. Over the limit returns 429.
  2. Honeypot: if hp_url is filled in, the server replies 201 as if the submission worked, but stores nothing.
  3. Proof of work: a missing or stale token returns 403 "anti-spam check failed".
  4. Spam score: structural signals are scored. The score never causes a rejection. A flagged entry is stored with spam_flagged, spam_score and spam_reasons for 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 proof-of-work check

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:

  1. The page requests GET /api/captcha/challenge?surface=forms. It gets back { "enabled": false }, or { "enabled": true, "token", "bits" }.
  2. The /captcha.js widget finds a number n such that SHA-256 of <token>.<n> starts with bits zero bits. It puts <token>::<n> into pow_token.
  3. 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.

File uploads on public forms

A file field lets a visitor attach a document, such as a CV. The upload happens first and is kept private.

  1. POST /api/forms/<type>/upload as multipart/form-data, with the file in file, plus hp_url and pow_token.
  2. The response is { "id": "pf_…", "original_name", "size" }. It never includes a URL.
  3. 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.

Notifications and webhooks

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.

Moderation

With moderated: true:

  • public submissions arrive as pending and are hidden from the public;
  • entries created by staff arrive as approved;
  • public reads (GET list and single entry) return only approved entries;
  • 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.

The entries screen

/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, slug or email, whichever comes first;
  • file fields link to the staff-only download;
  • repeaters add and remove items;
  • showIf fields 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.

REST API

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.

Filtering and paging

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.

Webhook events

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.

Worked example

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

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

See also

Clone this wiki locally