Content Modeling
Content Modeling
ModulaCMS uses a dynamic schema system where content structure is defined at runtime, not in application code. You create content types (called datatypes), define their properties (fields), and assign fields to datatypes -- all through the API or admin panel. No code deployment, no database migrations, no restart required.
This guide covers how the schema system works and how to use it to model your content.
Concepts
Datatype -- A content type definition that describes a kind of content. A "Blog Post" datatype, a "Page" datatype, a "Hero Section" datatype. Analogous to a "post type" in WordPress or a "content type" in Contentful. Datatypes are stored in the database and can be created or modified at any time.
Field -- A property definition that belongs to a datatype. A "Title" field of type text, a "Body" field of type richtext, a "Featured Image" field of type media. Each field has a parent_id that points to the datatype it belongs to.
Content data -- An instance of a datatype. When you create a blog post, you create a content data record that references the "Blog Post" datatype. Content data records form a tree structure (covered in content-trees.md).
Content field -- The actual value for a field on a specific content data instance. If the field definition says "Title, type text," the content field holds the actual string "My First Post."
Field type -- The kind of input a field represents. Field types are stored in the field_types table and can be extended at runtime -- you are not limited to the built-in set. Custom field types can be registered and used alongside the defaults.
How the Pieces Fit Together
The content model has two layers: definitions and instances.
Definitions (the schema):
- Datatypes define what kinds of content exist
- Fields define what properties those content types have, each assigned to a single datatype via
parent_id
Instances (the content):
- Content data records are instances of datatypes
- Content field records hold the values for each field on each content data instance
datatypes
|
+--- fields (via fields.parent_id -> datatypes.datatype_id)
|
v
content_data (instances of datatypes)
|
v
content_fields (values for fields on instances)
Each content data record points to its datatype via datatype_id. Each content field record points to both its content data instance (content_data_id) and its field definition (field_id).
Datatype Classifications
Every datatype has a type field that classifies its role. Types prefixed with underscore (_) are engine-reserved and trigger built-in behavior. All other values are user-defined pass-through strings.
Reserved Types
| Type | Purpose |
|---|---|
_root |
Tree entry point. Every route's content tree must have exactly one root node whose datatype is _root. |
_reference |
Triggers tree composition. Resolves _id field values and attaches the referenced content trees as children. See Tree Composition. |
_nested_root |
Assigned at runtime during tree composition. When a _reference node's subtree is fetched, the fetcher replaces the subtree root's original type with _nested_root so the tree builder's root-finding logic (IsRootType) works recursively without modification. The _nested_root type persists in the delivered JSON output. |
_system_log |
Synthetic node injected when a reference cannot be resolved. Contains error details. |
_collection |
Marks content as a queryable collection. Signals to clients that children support filtering and pagination via the query API. |
_global |
Singleton site-wide content (menus, footers, settings). Not tied to a route — delivered via the /globals endpoint. |
_plugin |
Plugin-provided content. Actual types use the _plugin_{name} namespace (e.g., _plugin_analytics), registered by the plugin system during initialization. |
User-Defined Types
Any string that does not start with _ is a valid user-defined type. Use descriptive strings to categorize your component 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 types are pass-through -- the engine stores them but does not assign them special behavior. They are useful for organizing datatypes in the admin interface.
Parent-Constrained Datatypes
A datatype can have a parent_id pointing to another datatype. This enforces a structural rule: instances of the child datatype can only appear as children of instances of the parent datatype in the content tree.
For example, a "Featured Card" datatype with parent_id pointing to a "Cards" datatype can only be nested under a "Cards" instance in the content tree.
Creating Datatypes
Create a datatype by posting 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"
}
To create a child-constrained datatype, include parent_id:
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"
}'
Creating Fields
Create a field definition by posting to /api/v1/fields. The parent_id assigns the field to a datatype:
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": "{\"required\": true, \"maxLength\": 200, \"placeholder\": \"Enter post title\"}",
"validation": "{}",
"ui_config": "{}"
}'
The parent_id points to the datatype this field belongs to. The data field accepts a JSON string containing metadata such as validation rules, placeholder text, and help text. The type field can be any type registered in the field_types table -- including the built-in types and any custom types you have added.
Built-in Field Types
The built-in admin panel and TUI have editor components for the following field types only. These are the default types that ship with ModulaCMS. The field_types table exists to support custom admin panels that implement their own editor components for additional field types.
Each field type determines two things: the editor component rendered in the admin UI, and (for some types) backend behavior during content delivery. The stored value is always a string -- the field type tells consumers how to interpret it.
| Type | Editor Component | 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 |
datetime |
Date and time picker | ISO 8601 datetime |
boolean |
True/false toggle | "true" or "false" |
select |
Dropdown selection | Selected option value |
media |
Media asset picker | Media ID (ULID) |
_id |
Content node picker | Content data ID (ULID). On _reference datatype nodes, the composition engine resolves this value to fetch and attach referenced subtrees at delivery time. See Tree Composition. |
json |
Structured JSON editor | JSON string |
slug |
URL-safe slug input | Slug string |
email |
Email input | Email address |
url |
URL input | URL string |
plugin |
Plugin-provided editor (inline or overlay) | Opaque string (plugin decides format) |
Custom Field Types
The field_types table is an extension point for adding new field types beyond the built-in set. When a field uses a custom type and its ui_config column is blank, the admin panel and TUI fall back to a plain text input. The ui_config JSON column can specify an editor component to use for the custom type, allowing the built-in admin panel and TUI to render a specialized editor without requiring a custom frontend.
Register a custom field type:
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"
}'
Custom admin panels (built with the SDKs or as standalone frontends) can also use the field_types table to discover registered types and render their own editor components. The backend stores the type string and field value as-is -- interpretation and validation for custom types is the responsibility of the rendering layer.
Field Column Purposes
| Column | Purpose |
|---|---|
parent_id |
The datatype this field belongs to (references datatypes.datatype_id) |
label |
Human-readable display name shown in the UI |
data |
Generic text column with no strictly defined purpose. Some output format transformers read from it. Available for any application-specific metadata. |
validation |
JSON blob containing composable validation rules. Uses a planned rule-based validation system where rules are composed declaratively -- no regex. |
ui_config |
JSON blob containing TUI display configuration. Controls how the field renders in the SSH TUI admin interface. |
type |
Field type string, referencing a registered type in the field_types table |
Viewing 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"
This is useful when building content editors that need to render forms based on the datatype schema.
Tree Composition
Tree composition allows a content tree to include content from other content trees by reference. This is how shared content (navigation menus, footers, sidebars) gets embedded into multiple pages without duplication.
How It Works
- Create a datatype with
type = "_reference"(e.g., "Menu Reference"). - Add one or more
_idfields to it. Each field holds thecontent_data_idof a content node to reference. - Place an instance of the
_referencedatatype in your content tree. - When the content delivery endpoint assembles the tree, it detects
_referencenodes, fetches the referenced content trees, and attaches them as children of the reference node.
Example: Shared Navigation Menu
Suppose you have a main navigation menu that should appear on every page.
Step 1: Create the menu content. Create a _root datatype called "Main Menu" with fields for menu items. Create a route for it (e.g., slug main-menu) and populate it with content.
Route: slug = "main-menu"
└── content_data (_root "Main Menu")
├── content_data ("Menu Item")
│ └── content_field: label = "Home"
├── content_data ("Menu Item")
│ └── content_field: label = "About"
└── content_data ("Menu Item")
└── content_field: label = "Contact"
Step 2: Create a reference datatype. Create a datatype with type = "_reference" and label "Menu Reference". Add an _id field to it:
# Create the _reference datatype
curl -X POST http://localhost:8080/api/v1/datatype \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{"label": "Menu Reference", "type": "_reference"}'
# Create an _id field on it
curl -X POST http://localhost:8080/api/v1/fields \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{
"parent_id": "MENU_REFERENCE_DATATYPE_ID",
"label": "menu",
"type": "_id",
"data": "{}",
"validation": "{}",
"ui_config": "{}"
}'
Step 3: Use the reference in page content trees. In each page's content tree, add an instance of the "Menu Reference" datatype. Set its _id field value to the content_data_id of the Main Menu's root node.
Route: slug = "homepage"
└── content_data (_root "Page")
├── content_data (_reference "Menu Reference") <- references Main Menu
│ └── content_field: menu = "01JNRW..." <- content_data_id of Main Menu root
├── content_data ("Hero Section")
│ └── content_field: heading = "Welcome"
└── content_data ("Footer")
Step 4: Content delivery resolves the reference. When a client requests GET /api/v1/content/homepage, the composition engine:
- Detects the
_referencenode ("Menu Reference"). - Reads its
_idfield value (01JNRW...). - Fetches and builds the referenced content tree (the Main Menu).
- Attaches the menu tree as a child of the reference node.
- Returns the fully composed tree.
The menu content lives in one place but appears in every page that references it. Update the menu once, and all pages reflect the change.
Composition Behavior
- Depth-bounded. Composition recurses up to
composition_max_depthlevels (default 10, configurable inmodula.config.json). If a referenced tree itself contains_referencenodes, those are also resolved, up to the depth limit. - Concurrent. Sibling references at the same level are resolved concurrently (up to 10 goroutines) for performance.
- Circular reference detection. If a reference would create a cycle (A references B which references A), a
_system_lognode is injected instead with an error message explaining the cycle. - Graceful degradation. If a referenced content node does not exist or cannot be fetched, a
_system_lognode is injected with error details rather than failing the entire request. The rest of the tree is returned normally.
_system_log Nodes
When composition encounters an error, it injects a synthetic _system_log node with four fields:
| Field | Description |
|---|---|
error |
Error type: circular_reference, max_depth, not_found, or build_failed |
message |
Human-readable description of the problem and how to fix it |
reference_id |
The content_data_id that could not be resolved |
parent_label |
The label of the _reference datatype that triggered the error |
These nodes are visible in the JSON output and can be used by frontends to display appropriate error states or be filtered out in production.
Example: Building 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"}'
Note the datatype_id from the response. For this example, assume 01JNRW5V6QNPZ3R8W4T2YH9B0D.
2. Create the fields (each assigned to the datatype via parent_id):
# Title 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": "Title",
"type": "text",
"data": "{\"required\": true, \"maxLength\": 200}",
"validation": "{}",
"ui_config": "{}"
}'
# Body 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": "Body",
"type": "richtext",
"data": "{\"required\": true}",
"validation": "{}",
"ui_config": "{}"
}'
# Excerpt 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": "Excerpt",
"type": "textarea",
"data": "{\"maxLength\": 300}",
"validation": "{}",
"ui_config": "{}"
}'
# 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
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": "{\"required\": true}",
"validation": "{}",
"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.
Creating Content Instances
Once a datatype schema is defined, create content instances with /api/v1/contentdata:
curl -X POST http://localhost:8080/api/v1/contentdata \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{
"route_id": "01JNRW9P2DKTZ6Q4M8W3B5J7CL",
"datatype_id": "01JNRW5V6QNPZ3R8W4T2YH9B0D",
"status": "draft"
}'
Then set field values with /api/v1/contentfields:
curl -X POST http://localhost:8080/api/v1/contentfields \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-H "Content-Type: application/json" \
-d '{
"content_data_id": "01JNRWBM4FNRZ7R5N9X4C6K8DM",
"field_id": "01JNRW7K8CNQZ5P3R9W6TJ4MAS",
"field_value": "My First Blog Post",
"route_id": "01JNRW9P2DKTZ6Q4M8W3B5J7CL"
}'
Content status values: draft, published.
Modifying Schemas at Runtime
You can add fields to an existing datatype, remove fields, or modify them at any time.
Adding a field to an existing datatype: create the field with parent_id set to the target datatype. The new field appears immediately in the content editor. Existing content instances do not automatically have values for the new field -- they get populated when edited.
Removing a field: delete the field definition. This cascades -- all content field values for that field across all content instances are deleted.
Deleting a datatype is blocked if content instances reference it. Delete or reassign the content first.
API Reference
All endpoints require authentication and the appropriate permission.
| 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 single 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 single 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/contentdata |
content:read |
List all content data |
| POST | /api/v1/contentdata |
content:create |
Create a content data instance |
| GET | /api/v1/contentdata/ |
content:read |
Get a single content data instance (?q=ID) |
| PUT | /api/v1/contentdata/ |
content:update |
Update a content data instance |
| DELETE | /api/v1/contentdata/ |
content:delete |
Delete a content data instance (?q=ID) |
| GET | /api/v1/contentfields |
content:read |
List all content fields |
| POST | /api/v1/contentfields |
content:create |
Create a content field value |
| GET | /api/v1/contentfields/ |
content:read |
Get a single content field (?q=ID) |
| PUT | /api/v1/contentfields/ |
content:update |
Update a content field value |
| DELETE | /api/v1/contentfields/ |
content:delete |
Delete a content field value (?q=ID) |
All list endpoints support pagination with limit and offset query parameters.
Notes
- All IDs are 26-character ULIDs, time-sortable and globally unique.
- The
datacolumn on fields is a generic text column with no enforced structure. Some output format transformers read from it. - The
validationcolumn stores composable validation rules (JSON). Theui_configcolumn stores TUI display configuration (JSON). Neither is enforced by the backend at write time. - Deleting a field definition cascades to all content field values for that field.
- Deleting a datatype cascades to its field definitions.
- Content data instances belong to a route. Every content query is scoped by
route_id. See routing.md for details. - Types prefixed with
_are reserved for engine use. Attempting to create a datatype with an unrecognized_-prefixed type returns an error.