Content Modeling
Content Modeling
Define your content schema by creating datatypes and fields, then populate them with structured data that ModulaCMS delivers to your frontend.
Datatypes
A datatype is a content type definition. It describes a kind of content -- a "Blog Post," a "Hero Section," a "Product Card." Think of it as a "post type" in WordPress or a "content type" in Contentful.
Every datatype has a human-readable label (shown in the admin panel and TUI), a name which typically matches the label but contains no spaces or uppercase letters for use in a front end, and a type that categorizes its role.
Datatype types
The type field controls how ModulaCMS treats the datatype. Types starting with _ are reserved and trigger built-in behavior. All other values are user-defined.
Reserved types:
| Type | Purpose |
|---|---|
_root |
Tree entry point for route-based content. Every route's content tree starts with one _root node. |
_reference |
Embeds shared content from another tree. ModulaCMS resolves the referenced content and attaches it at delivery time. |
_nested_root |
Root of a composed subtree. Assigned by the engine during tree composition -- not user-created. |
_system_log |
Synthetic node injected when a reference cannot be resolved. Contains error details. |
_collection |
Marks content as a queryable collection. Clients can filter and paginate children via the query API. |
_global |
Tree entry point for site-wide content (menus, footers, settings). Not tied to a route -- accessed via the /globals endpoint. |
_plugin |
Plugin-provided content. Uses the _plugin_{name} namespace (e.g., _plugin_analytics). |
Reserved types support optional suffixes separated by underscore. For example, _reference_menu has the base type _reference with the suffix menu. The suffix is metadata for the admin panel (e.g., filtering dropdowns) and does not change engine behavior.
ModulaCMS rejects datatype creation if you use an unrecognized _-prefixed type.
User-defined types:
Any string not starting with _ is a valid user-defined type. Use descriptive strings to organize your datatypes:
| Example Type | Use Case |
|---|---|
section |
Layout sections (Hero, Footer, Sidebar) |
container |
Grouping containers (Cards, Tabs, Accordion) |
card |
Individual card components |
element |
Atomic UI elements |
User-defined types are pass-through -- ModulaCMS stores them but doesn't assign them special behavior. They help you categorize datatypes in the admin interface.
Create a datatype
Create a datatype by sending a POST request to /api/v1/datatype:
curl -X POST http://localhost:8080/api/v1/datatype \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{
"label": "Blog Post",
"type": "_root"
}'
Response (HTTP 201):
{
"datatype_id": "01JNRW5V6QNPZ3R8W4T2YH9B0D",
"parent_id": null,
"label": "Blog Post",
"type": "_root",
"author_id": "01JMKW8N3QRYZ7T1B5K6F2P4HD",
"date_created": "2026-02-27T10:00:00Z",
"date_modified": "2026-02-27T10:00:00Z"
}
Note the datatype_id from the response -- you'll use it when creating fields.
Constrain datatypes by parent
A datatype can reference a parent datatype. This enforces a structural rule: instances of the child datatype can only appear under instances of the parent datatype in the content tree.
For example, a "Featured Card" datatype with parent_id pointing to a "Cards Container" datatype can only be nested under a "Cards Container" in the tree.
curl -X POST http://localhost:8080/api/v1/datatype \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{
"label": "Featured Card",
"type": "card",
"parent_id": "01JNRW6A7BMXY4K9P2Q5TH3JCR"
}'
Fields
A field is a single property within a datatype. Fields define what data an editor fills in when creating content -- a "Title" of type text, a "Body" of type richtext, a "Featured Image" of type media.
Each field stores its configuration across three JSON columns that separate concerns: data for type-specific settings, validation for composable rules, and ui_config for rendering hints.
Create a field
Create a field and assign it to a datatype using the parent_id:
curl -X POST http://localhost:8080/api/v1/fields \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{
"parent_id": "01JNRW5V6QNPZ3R8W4T2YH9B0D",
"label": "Post Title",
"type": "text",
"data": "{}",
"validation": "{\"rules\": [{\"rule\": {\"op\": \"required\"}}, {\"rule\": {\"op\": \"length\", \"cmp\": \"lte\", \"n\": 200}}]}",
"ui_config": "{\"placeholder\": \"Enter post title\"}"
}'
The parent_id is the datatype this field belongs to.
Field types
Each field type determines the editor component shown in the admin panel and TUI, and tells consumers how to interpret the stored value.
| Type | What it does | Stored value |
|---|---|---|
text |
Single-line text input | Plain text |
textarea |
Multi-line plain text input | Plain text |
richtext |
Rich text / HTML editor | HTML string |
number |
Numeric input | Number as string |
date |
Date picker | ISO 8601 date (YYYY-MM-DD) |
datetime |
Date and time picker | Datetime string (accepts RFC 3339, YYYY-MM-DDTHH:MM:SS, or YYYY-MM-DD HH:MM:SS) |
boolean |
True/false toggle | "true", "false", "1", or "0" |
select |
Dropdown from predefined options (configured in data) |
Selected option value |
media |
Media asset picker | Media ID |
_id |
Content node picker. On _reference datatypes, ModulaCMS resolves this value and attaches the referenced content at delivery time. |
Content data ID |
json |
Structured JSON editor | JSON string |
slug |
URL-safe slug input (lowercase, numbers, hyphens) | Slug string |
email |
Email input with format validation | Email address |
url |
URL input with format validation | URL string |
_title |
Marks this field as the display name of a datatype. Used by the admin panel and TUI to label content nodes. | Plain text |
plugin |
Plugin-provided editor with custom input UI | Opaque string (plugin decides format) |
Good to know: All field values are stored as strings regardless of type. Numbers become their string representation, booleans become
"true"or"false", and references become ID strings. The field type tells your frontend how to interpret the value.
Field properties
| Property | Purpose |
|---|---|
label |
Human-readable name shown in the editor UI |
type |
Determines the editor component and value format |
data |
JSON string for type-specific configuration (see The data column) |
validation |
JSON string with composable validation rules (see Validation rules) |
ui_config |
JSON string controlling how the field renders in the TUI (see UI config) |
translatable |
Whether the field stores per-locale values for localization |
roles |
Restrict visibility to specific user roles. Unrestricted by default. |
The data column
The data column holds configuration that is specific to the field's type. Most field types use "{}" (empty). The types that use data are:
| Field type | Data format | Example |
|---|---|---|
select |
Options list | {"options": ["draft", "review", "published"]} |
richtext |
Toolbar configuration | {"toolbar": ["bold", "italic", "link", "heading"]} |
_id |
Relation config (required) | {"target_datatype_id": "01JNRW...", "cardinality": "one"} |
Select options can also use a label/value pair format for display labels that differ from stored values:
[{"label": "In Review", "value": "review"}, {"label": "Published", "value": "published"}]
Relation config fields:
target_datatype_id(required) — the datatype ID that this field referencescardinality—"one"or"many"max_depth(optional) — limits resolution depth for nested references
Validation rules
The validation column holds composable rules that ModulaCMS enforces when content is saved. Rules are expressed as a JSON object with a rules array. Each entry is either a flat rule or a group of rules combined with all_of (AND) or any_of (OR) logic.
Rule operations:
| Op | What it checks | Required fields |
|---|---|---|
required |
Value is non-empty | (none) |
contains |
Value contains a substring or character class | value or class |
starts_with |
Value starts with a substring or character class | value or class |
ends_with |
Value ends with a substring or character class | value or class |
equals |
Exact match | value |
length |
Rune count of value | cmp, n |
count |
Count occurrences of substring or class members | cmp, n, and value or class |
range |
Numeric value comparison | cmp, n |
item_count |
Count items in comma-separated list or JSON array | cmp, n |
one_of |
Value is in a fixed set | values |
Comparison operators (for length, count, range, item_count): eq, neq, gt, gte, lt, lte
Character classes (for contains, starts_with, ends_with, count): uppercase, lowercase, digits, symbols, spaces
Examples:
Required field with max 200 characters:
{
"rules": [
{"rule": {"op": "required"}},
{"rule": {"op": "length", "cmp": "lte", "n": 200}}
]
}
Number field between 0 and 100:
{
"rules": [
{"rule": {"op": "range", "cmp": "gte", "n": 0}},
{"rule": {"op": "range", "cmp": "lte", "n": 100}}
]
}
Must contain at least one uppercase letter OR one digit:
{
"rules": [
{
"group": {
"any_of": [
{"rule": {"op": "contains", "class": "uppercase"}},
{"rule": {"op": "contains", "class": "digits"}}
]
}
}
]
}
Any string-based rule (contains, starts_with, ends_with, equals, one_of) supports "negate": true to invert the check. Groups can nest up to 10 levels deep. Custom error messages can be set per rule with the "message" field.
UI config
The ui_config column controls how the field renders in the SSH TUI. The widget property overrides the default Bubbletea bubble component used for the field type. All properties are optional.
| Property | Type | Purpose |
|---|---|---|
widget |
string | Override the default Bubbletea bubble component for this field type |
placeholder |
string | Placeholder text shown in the input |
help_text |
string | Descriptive text shown below the field label |
hidden |
boolean | Hide the field from the content editor form |
Available widgets:
| Widget | TUI bubble | Use case |
|---|---|---|
markdown |
Textarea | Markdown editing for text fields |
rich-text |
Textarea | Rich text editing for text fields |
code-editor |
Textarea | Code editing for text fields |
json-editor |
Textarea | JSON editing for text fields |
toggle |
Boolean | Toggle switch for boolean fields |
radio |
Select | Radio button group for select fields |
date-picker |
DatePicker | Calendar date picker |
datetime-picker |
DateTimePicker | Date and time picker |
time-picker |
TimePicker | Time-only picker |
color-picker |
(default fallback) | Stored for frontend use, no TUI equivalent |
slider |
(default fallback) | Stored for frontend use, no TUI equivalent |
range |
(default fallback) | Stored for frontend use, no TUI equivalent |
checkbox-group |
(default fallback) | Stored for frontend use, no TUI equivalent |
tags |
(default fallback) | Stored for frontend use, no TUI equivalent |
password |
(default fallback) | Stored for frontend use, no TUI equivalent |
file-upload |
(default fallback) | Stored for frontend use, no TUI equivalent |
image-upload |
(default fallback) | Stored for frontend use, no TUI equivalent |
map |
(default fallback) | Stored for frontend use, no TUI equivalent |
Widgets without a TUI equivalent fall back to the field type's default bubble. These widget values are still stored and available for frontend clients or the admin panel to interpret.
Example -- override a text field to use a markdown editor with help text:
{
"widget": "markdown",
"placeholder": "Write your bio here",
"help_text": "Supports basic markdown formatting"
}
When ui_config is "{}" or omitted, the TUI uses the field type's default bubble (e.g., text fields get a text input, boolean fields get a toggle).
Register custom field types
You can add field types beyond the built-in set. Register a custom type, and it becomes available for any field:
curl -X POST http://localhost:8080/api/v1/fieldtypes \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{
"type": "color",
"label": "Color Picker"
}'
View a datatype with its fields
The composed view endpoint returns a datatype with all its field definitions in a single response:
curl "http://localhost:8080/api/v1/datatype/full?q=01JNRW5V6QNPZ3R8W4T2YH9B0D" \
-H "Cookie: session=YOUR_SESSION_COOKIE"
Use this when building content editors that need to render forms based on the datatype schema.
Example: build a blog post schema
Here is the full sequence for creating a "Blog Post" datatype with five fields.
1. Create the datatype:
curl -X POST http://localhost:8080/api/v1/datatype \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{"label": "Blog Post", "type": "_root"}'
2. Create the fields:
# Title field — required, max 200 chars
curl -X POST http://localhost:8080/api/v1/fields \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{
"parent_id": "01JNRW5V6QNPZ3R8W4T2YH9B0D",
"label": "Title",
"type": "text",
"data": "{}",
"validation": "{\"rules\": [{\"rule\": {\"op\": \"required\"}}, {\"rule\": {\"op\": \"length\", \"cmp\": \"lte\", \"n\": 200}}]}",
"ui_config": "{}"
}'
# Body field — required, custom toolbar
curl -X POST http://localhost:8080/api/v1/fields \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{
"parent_id": "01JNRW5V6QNPZ3R8W4T2YH9B0D",
"label": "Body",
"type": "richtext",
"data": "{\"toolbar\": [\"bold\", \"italic\", \"link\", \"heading\", \"list\"]}",
"validation": "{\"rules\": [{\"rule\": {\"op\": \"required\"}}]}",
"ui_config": "{}"
}'
# Excerpt field — optional, max 300 chars, with help text
curl -X POST http://localhost:8080/api/v1/fields \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{
"parent_id": "01JNRW5V6QNPZ3R8W4T2YH9B0D",
"label": "Excerpt",
"type": "textarea",
"data": "{}",
"validation": "{\"rules\": [{\"rule\": {\"op\": \"length\", \"cmp\": \"lte\", \"n\": 300}}]}",
"ui_config": "{\"help_text\": \"Short summary for listing pages and SEO\"}"
}'
# Featured Image field
curl -X POST http://localhost:8080/api/v1/fields \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{
"parent_id": "01JNRW5V6QNPZ3R8W4T2YH9B0D",
"label": "Featured Image",
"type": "media",
"data": "{}",
"validation": "{}",
"ui_config": "{}"
}'
# Publish Date field — required
curl -X POST http://localhost:8080/api/v1/fields \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{
"parent_id": "01JNRW5V6QNPZ3R8W4T2YH9B0D",
"label": "Publish Date",
"type": "datetime",
"data": "{}",
"validation": "{\"rules\": [{\"rule\": {\"op\": \"required\"}}]}",
"ui_config": "{}"
}'
3. Verify the schema:
curl "http://localhost:8080/api/v1/datatype/full?q=01JNRW5V6QNPZ3R8W4T2YH9B0D" \
-H "Cookie: session=YOUR_SESSION_COOKIE"
The response contains the datatype and all five field definitions.
Modify schemas at runtime
Add, remove, or change fields on an existing datatype at any time. No restart or migration required.
Add a field -- Create a new field with parent_id set to the target datatype. The field appears immediately in the content editor. Existing content doesn't automatically have values for the new field -- values get populated when editors update the content.
Remove a field -- Delete the field definition. ModulaCMS cascades the deletion to all content field values for that field across all content instances.
Delete a datatype -- Deletion is blocked if content instances reference the datatype. Delete or reassign the content first.
Good to know: Deleting a field permanently removes all stored values for that field. Deleting a datatype cascades to its field definitions.
Shared content with references
Content trees can include content from other trees by reference. This is how shared content (navigation menus, footers, sidebars) gets embedded into multiple pages without duplication.
- Create a datatype with
type = "_reference"(e.g., "Menu Reference"). - Add an
_idfield to it that points to the content you want to embed. - Place an instance of the reference datatype in your content tree.
- When a frontend client requests the page, ModulaCMS detects the reference, fetches the referenced content, and attaches it as children of the reference node.
The referenced content lives in one place. Update it once, and every page that references it reflects the change.
Good to know: Configure the maximum reference depth via
composition_max_depthinmodula.config.json(default: 10). If a reference can't be resolved, ModulaCMS returns the rest of the tree normally and includes error details in the response.
API reference
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /api/v1/datatype |
datatypes:read |
List all datatypes |
| POST | /api/v1/datatype |
datatypes:create |
Create a datatype |
| GET | /api/v1/datatype/ |
datatypes:read |
Get a datatype (?q=ID) |
| GET | /api/v1/datatype/full |
datatypes:read |
Get a datatype with all fields (?q=ID) |
| PUT | /api/v1/datatype/ |
datatypes:update |
Update a datatype |
| DELETE | /api/v1/datatype/ |
datatypes:delete |
Delete a datatype (?q=ID) |
| GET | /api/v1/fields |
fields:read |
List all fields |
| POST | /api/v1/fields |
fields:create |
Create a field |
| GET | /api/v1/fields/ |
fields:read |
Get a field (?q=ID) |
| PUT | /api/v1/fields/ |
fields:update |
Update a field |
| DELETE | /api/v1/fields/ |
fields:delete |
Delete a field (?q=ID) |
| GET | /api/v1/fieldtypes |
field_types:read |
List registered field types |
| POST | /api/v1/fieldtypes |
field_types:create |
Register a custom field type |
All list endpoints support pagination with limit and offset query parameters.
Good to know: All IDs are 26-character ULIDs -- time-sortable and globally unique.
Next steps
Now that you have a content schema, create content and organize it into trees.