Content Modeling
Техническое руководство из исходного проекта Notty. Примеры, параметры и эксплуатационные ограничения.
Notty CMS uses JSON schemas to define content types. Schemas live in the schemas/ directory of your project and are auto-synced to the database on server startup.
Schema Structure
Every schema has this shape:
{
"kind": "collectionType",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article",
"description": "Blog articles"
},
"options": {
"draftAndPublish": true,
"timestamps": true
},
"attributes": {
"title": { "type": "string", "required": true }
}
}
Kind
collectionType— standard collection with many entries (articles, products, users)singleType— singleton with exactly one entry (homepage, site settings)
Options
| Option | Type | Default | Description |
|---|---|---|---|
draftAndPublish |
boolean | false | Enable draft/publish workflow |
timestamps |
boolean | true | Auto-add createdAt and updatedAt |
softDelete |
boolean | false | Enable deletedAt instead of hard delete |
i18n.enabled |
boolean | false | Enable content localization |
i18n.locales |
string[] | all system locales | Restrict to specific locales |
i18n.defaultLocale |
string | system default | Default locale for this type |
workflow |
object | — | Enable approval workflow (see Publishing) |
Composite Indexes
Define database indexes at the schema level:
{
"indexes": [
{ "fields": ["slug", "locale"], "type": "unique" },
{ "fields": ["title"], "type": "fulltext" },
{ "fields": ["category_id", "published_at"], "type": "index" }
]
}
Field Types
String
Short text, stored as VARCHAR. Use for titles, names, slugs.
{
"type": "string",
"required": true,
"unique": false,
"minLength": 1,
"maxLength": 255,
"default": "Untitled"
}
Text
Longer text, stored as TEXT/LONGTEXT. Use for descriptions, excerpts.
{
"type": "text",
"required": false,
"minLength": 0,
"maxLength": 65535
}
Rich Text
HTML or structured text, stored as LONGTEXT. Rendered as a rich-text editor in admin.
{
"type": "richtext"
}
Integer
Whole numbers, stored as INT.
{
"type": "integer",
"required": true,
"min": 0,
"max": 100,
"default": 0
}
Big Integer
Large numbers, stored as BIGINT. Use for IDs from external systems.
{
"type": "bigint"
}
Decimal
Precise floating-point numbers, stored as DECIMAL. Use for prices, coordinates.
{
"type": "decimal",
"min": 0,
"max": 99999.99
}
Boolean
True/false, stored as BOOLEAN.
{
"type": "boolean",
"default": false,
"required": true
}
Date
Date only (no time), stored as DATE.
{
"type": "date"
}
DateTime
Date and time, stored as TIMESTAMP.
{
"type": "datetime",
"default": "now"
}
JSON
Arbitrary JSON data. Use when structure varies or for complex nested data.
{
"type": "json",
"default": {}
}
Enum
One value from a predefined list.
{
"type": "enum",
"enum": ["draft", "review", "approved", "rejected"],
"default": "draft",
"required": true
}
Media
Reference to a file in the media library. See Media Management.
{
"type": "media",
"allowedTypes": ["images"],
"multiple": false
}
allowedTypes accepts: "images", "videos", "audios", "files". Omit for any type.
Set "multiple": true to allow multiple files (gallery).
Relations
Relations link content types together. Notty supports four relation types.
Many-to-One
An article belongs to one category. Many articles share the same category.
// schemas/article.json
{
"attributes": {
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "category"
}
}
}
One-to-Many
A category has many articles. Define the inverse side with mappedBy:
// schemas/category.json
{
"attributes": {
"articles": {
"type": "relation",
"relation": "oneToMany",
"target": "article",
"mappedBy": "category"
}
}
}
One-to-One
A user has one profile. The owning side defines the foreign key:
// schemas/user-profile.json
{
"attributes": {
"user": {
"type": "relation",
"relation": "oneToOne",
"target": "user"
}
}
}
Many-to-Many
Articles have many tags, and tags belong to many articles:
// schemas/article.json
{
"attributes": {
"tags": {
"type": "relation",
"relation": "manyToMany",
"target": "tag",
"inversedBy": "articles"
}
}
}
// schemas/tag.json
{
"attributes": {
"articles": {
"type": "relation",
"relation": "manyToMany",
"target": "article",
"mappedBy": "tags"
}
}
}
Cascade Delete
Control what happens when a related entry is deleted:
{
"type": "relation",
"relation": "manyToOne",
"target": "category",
"onDelete": "SET NULL"
}
Options: CASCADE, SET NULL, RESTRICT.
Working with Relations via API
Create with relation (by ID):
curl -X POST http://localhost:2102/api/content/article \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "title": "My Article", "category": 1 }'
Link an existing entry:
curl -X POST http://localhost:2102/api/content/article/1/link/tags \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "id": 5 }'
Unlink:
curl -X DELETE http://localhost:2102/api/content/article/1/unlink/tags \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "id": 5 }'
Populate relations in queries:
curl "http://localhost:2102/api/content/article?populate=category,tags" \
-H "Authorization: Bearer $TOKEN"
Components
Components are reusable field groups. Define them once, use in multiple content types.
Define a Component
Create components/shared/seo-meta.json:
{
"uid": "shared.seo-meta",
"category": "shared",
"info": {
"displayName": "SEO Meta",
"description": "SEO metadata for any page",
"icon": "mdi:search-web"
},
"attributes": {
"metaTitle": { "type": "string", "maxLength": 60 },
"metaDescription": { "type": "text", "maxLength": 160 },
"metaKeywords": { "type": "string" },
"noIndex": { "type": "boolean", "default": false }
}
}
Component UIDs follow the pattern category.name (e.g., shared.seo-meta, blocks.hero).
Use a Component in a Schema
Single component (one instance):
{
"seo": {
"type": "component",
"component": "shared.seo-meta"
}
}
Repeatable component (array of instances):
{
"gallery": {
"type": "component",
"component": "blocks.image-slide",
"repeatable": true,
"min": 1,
"max": 20
}
}
Component API Payloads
Create:
{
"title": "About Us",
"seo": {
"metaTitle": "About Our Company",
"metaDescription": "Learn about our team."
}
}
Repeatable component:
{
"gallery": [
{ "imageUrl": "/uploads/photo1.jpg", "caption": "Office" },
{ "imageUrl": "/uploads/photo2.jpg", "caption": "Team" }
]
}
Dynamic Zones
Dynamic zones allow mixing different component types in an ordered list. Use them for page builders, flexible content blocks.
Define Components for Dynamic Zone
// components/blocks/hero.json
{
"uid": "blocks.hero",
"category": "blocks",
"info": { "displayName": "Hero Section", "icon": "mdi:view-dashboard" },
"attributes": {
"title": { "type": "string", "required": true },
"subtitle": { "type": "text" },
"backgroundImage": { "type": "media", "allowedTypes": ["images"] }
}
}
// components/blocks/text-block.json
{
"uid": "blocks.text-block",
"category": "blocks",
"info": { "displayName": "Text Block", "icon": "mdi:text-box" },
"attributes": {
"content": { "type": "richtext", "required": true }
}
}
// components/blocks/cta.json
{
"uid": "blocks.cta",
"category": "blocks",
"info": { "displayName": "Call to Action", "icon": "mdi:cursor-default-click" },
"attributes": {
"heading": { "type": "string", "required": true },
"buttonText": { "type": "string", "required": true },
"buttonUrl": { "type": "string", "required": true },
"style": { "type": "enum", "enum": ["primary", "secondary", "outline"] }
}
}
Use a Dynamic Zone
// schemas/page.json
{
"kind": "collectionType",
"info": {
"singularName": "page",
"pluralName": "pages",
"displayName": "Page"
},
"attributes": {
"title": { "type": "string", "required": true },
"slug": { "type": "string", "unique": true },
"blocks": {
"type": "dynamicZone",
"components": ["blocks.hero", "blocks.text-block", "blocks.cta"]
}
}
}
Dynamic Zone API Payloads
Every block must include __component to identify its type:
{
"title": "Home",
"slug": "home",
"blocks": [
{
"__component": "blocks.hero",
"title": "Welcome to Our Site",
"subtitle": "Building the future"
},
{
"__component": "blocks.text-block",
"content": "<p>We are a team of passionate developers.</p>"
},
{
"__component": "blocks.cta",
"heading": "Ready to start?",
"buttonText": "Contact Us",
"buttonUrl": "/contact",
"style": "primary"
}
]
}
Block order in the request is preserved in the response.
Conditional Fields
Show or hide fields based on another field's value:
{
"type": { "type": "enum", "enum": ["internal", "external"] },
"internalPage": {
"type": "relation",
"relation": "manyToOne",
"target": "page",
"condition": {
"field": "type",
"operator": "eq",
"value": "internal"
}
},
"externalUrl": {
"type": "string",
"condition": {
"field": "type",
"operator": "eq",
"value": "external"
}
}
}
Available operators: eq, neq, in, notIn, exists, empty.
System Schemas
Notty ships with built-in schemas: user, media, media-folder, role. Their core fields are protected — you cannot modify or remove them. But you can extend them with additional fields by creating extension files:
// schemas/user.extension.json
{
"attributes": {
"bio": { "type": "text" },
"avatar": { "type": "media", "allowedTypes": ["images"] }
}
}
Extension fields merge with the system schema at runtime.
Validation Rules Summary
| Rule | Applies to | Example |
|---|---|---|
required |
all fields | "required": true |
unique |
string, integer, email | "unique": true |
minLength / maxLength |
string, text | "maxLength": 255 |
min / max |
integer, bigint, decimal | "min": 0, "max": 100 |
enum |
enum | "enum": ["a", "b", "c"] |
min / max (repeatable) |
component (repeatable) | "min": 1, "max": 10 |
Full Example: E-commerce Product
{
"kind": "collectionType",
"info": {
"singularName": "product",
"pluralName": "products",
"displayName": "Product"
},
"options": {
"draftAndPublish": true,
"timestamps": true,
"i18n": { "enabled": true }
},
"indexes": [
{ "fields": ["slug", "locale"], "type": "unique" },
{ "fields": ["price"], "type": "index" }
],
"attributes": {
"name": {
"type": "string",
"required": true,
"maxLength": 200,
"pluginOptions": { "i18n": { "localized": true } }
},
"slug": {
"type": "string",
"unique": true
},
"description": {
"type": "richtext",
"pluginOptions": { "i18n": { "localized": true } }
},
"price": {
"type": "decimal",
"required": true,
"min": 0
},
"sku": {
"type": "string",
"unique": true,
"required": true
},
"inStock": {
"type": "boolean",
"default": true
},
"images": {
"type": "media",
"allowedTypes": ["images"],
"multiple": true
},
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "product-category"
},
"tags": {
"type": "relation",
"relation": "manyToMany",
"target": "tag",
"inversedBy": "products"
},
"seo": {
"type": "component",
"component": "shared.seo-meta"
},
"sections": {
"type": "dynamicZone",
"components": ["blocks.hero", "blocks.text-block", "blocks.cta"]
}
}
}
Источник: docs/guide/content-modeling.md. Снимок документации исходного проекта. Технический справочник сохраняет язык оригинала.