Validation
This document describes all validation rules applied to page type and component schemas by TentoCMS.
Overview
Schemas are validated at two points:
- Client-side - As editors build schemas in the admin interface
- Server-side - When schemas are saved to the API
Server-side validation is the authoritative check. All validation errors returned from the API must be addressed before saving.
Core Validation Rules
1. No Duplicate Field Names
Each field in a schema must have a unique name.
Error Code: DUPLICATE_FIELD_NAME
Example (invalid):
{
"schema": [
{
"name": "title",
"label": "Title",
"type": "text",
"required": true
},
{
"name": "title", // ❌ Duplicate name
"label": "Post Title",
"type": "text",
"required": true
}
]
}
Fix: Use unique field names:
{
"schema": [
{
"name": "title",
"label": "Title",
"type": "text",
"required": true
},
{
"name": "postTitle", // ✅ Unique name
"label": "Post Title",
"type": "text",
"required": true
}
]
}
2. Valid Field Type
Field type must be one of the supported TentoCMS field types. See the Field Types Reference for the canonical catalogue of all supported types.
Error Code: INVALID_FIELD_TYPE
Example (invalid):
{
"name": "rating",
"label": "Rating",
"type": "star_rating", // ❌ Not a supported type
"required": false
}
Fix: Use a valid field type:
{
"name": "rating",
"label": "Rating",
"type": "number", // ✅ Valid type
"required": false,
"options": {
"min": 1,
"max": 5
}
}
3. Text Field Constraints
For text and textarea fields, if both minLength and maxLength are specified, minLength must not exceed maxLength.
Error Code: INVALID_LENGTH_RANGE
Example (invalid):
{
"name": "title",
"label": "Title",
"type": "text",
"required": true,
"options": {
"minLength": 200,
"maxLength": 100 // ❌ Max is less than min
}
}
Fix: Correct the range:
{
"name": "title",
"label": "Title",
"type": "text",
"required": true,
"options": {
"minLength": 50,
"maxLength": 200 // ✅ Max is greater than min
}
}
4. Number Field Constraints
For number fields, if both min and max are specified, min must not exceed max.
Error Code: INVALID_NUMBER_RANGE
Example (invalid):
{
"name": "quantity",
"label": "Quantity",
"type": "number",
"required": true,
"options": {
"min": 100,
"max": 10 // ❌ Max is less than min
}
}
Fix: Correct the range:
{
"name": "quantity",
"label": "Quantity",
"type": "number",
"required": true,
"options": {
"min": 10,
"max": 100 // ✅ Max is greater than min
}
}
5. Reference Field Validation
reference fields must specify a collectionType in options only in the default/collectionItem reference mode (referenceType unset or 'collectionItem'). It's forbidden when referenceType is 'user'/'blogPost', and unused when referenceType is 'page' (see the Field Types Reference for the full breakdown).
Error Codes:
MISSING_COLLECTION_TYPE- collectionType not provided incollectionItemmodeINVALID_COLLECTION_TYPE- specified collection doesn't exist — currently unreachable: this code only fires when anexistingCollectionTypeslist is passed intovalidateSchema(), and no production route passes it, so a nonexistentcollectionTypeis silently accepted today (see the note under Validation Flow below)
Example (invalid - missing):
{
"name": "author",
"label": "Author",
"type": "reference",
"required": true,
"options": {} // ❌ No collectionType
}
Example (accepted today despite the target not existing — INVALID_COLLECTION_TYPE never fires):
{
"name": "author",
"label": "Author",
"type": "reference",
"required": true,
"options": {
"collectionType": "nonexistent_collection" // Not currently rejected — see note above
}
}
Fix (missing case):
{
"name": "author",
"label": "Author",
"type": "reference",
"required": true,
"options": {
"collectionType": "authors" // ✅ Valid collection
}
}
6. Nested Component Validation
nestedComponent fields must specify a componentSlug in options.
Error Codes:
MISSING_COMPONENT_SLUG- componentSlug not providedINVALID_COMPONENT_REFERENCE- specified component doesn't existCIRCULAR_REFERENCE- component references itself
Example (invalid - missing):
{
"name": "header",
"label": "Header",
"type": "nestedComponent",
"required": true,
"options": {} // ❌ No componentSlug
}
Example (invalid - doesn't exist):
{
"name": "header",
"label": "Header",
"type": "nestedComponent",
"required": true,
"options": {
"componentSlug": "nonexistent" // ❌ Component not found
}
}
Example (invalid - circular):
{
"name": "nested",
"label": "Nested",
"type": "nestedComponent",
"required": true,
"options": {
"componentSlug": "header" // ❌ Component 'header' references back to this component
}
}
Fix:
{
"name": "header",
"label": "Header",
"type": "nestedComponent",
"required": true,
"options": {
"componentSlug": "header" // ✅ Valid existing component
}
}
7. Component Picker Validation
componentPicker fields can specify allowedComponents. All components in the list must exist.
Error Code: INVALID_COMPONENT_REFERENCE
Known gap: no self-reference check. Unlike
nestedComponent(see rule 8 below), the validator'scomponentPickerbranch (packages/shared/src/schema/validator.ts) only checks that each slug inallowedComponentsexists — it never checks whether the list includes the component's own slug. A component can currently list itself in its owncomponentPicker.allowedComponents, which the content editor would then let an author nest arbitrarily deep (component A containing an instance of itself, containing another instance of itself, and so on) — the same failure modeCIRCULAR_REFERENCEis meant to prevent fornestedComponent.
Example (invalid):
{
"name": "sections",
"label": "Page Sections",
"type": "componentPicker",
"required": false,
"options": {
"allowedComponents": ["hero", "nonexistent", "features"],
"maxItems": 10
}
}
Fix: Use only existing components:
{
"name": "sections",
"label": "Page Sections",
"type": "componentPicker",
"required": false,
"options": {
"allowedComponents": ["hero", "features", "testimonials"], // ✅ All exist
"maxItems": 10
}
}
8. Circular Reference Detection
This check only applies to nestedComponent fields. componentPicker fields are not covered — see the "Known gap" note in rule 7 above.
Error Code: CIRCULAR_REFERENCE
Direct reference (invalid, via nestedComponent):
Component A has nestedComponent field pointing to Component A
❌ Circular reference detected
Indirect reference chain (invalid, via nestedComponent):
Component A -> nestedComponent -> Component B
Component B -> nestedComponent -> Component A
❌ Circular reference detected (A -> B -> A)
Not actually blocked (documentation gap, not a guarantee):
Component A -> componentPicker allows [A]
⚠️ Currently ACCEPTED by validation — componentPicker never checks for self-reference
Valid structure:
Component A -> nestedComponent -> Component B
Component B -> nestedComponent -> Component C
Component C -> has no component references
✅ Valid hierarchy (no circular reference)
9. Nesting Depth Limit
The validator (packages/shared/src/schema/validator.ts) defines a 1-level nesting cap for nestedComponent fields, but it is currently unreachable from every production route. The check only fires when the caller passes an incremented nestingLevel into validateSchema(); POST/PUT on /admin/page-types, /admin/components, and /admin/collection-types all call it with nestingLevel: 0 (or omit it, which also defaults to 0) and never increment it when recursing into a nested component's own schema. In practice, nestedComponent chains deeper than 1 level are accepted, not rejected — only genuine circular references (rule 8 above) are actually caught.
Error Code: NESTING_LIMIT_EXCEEDED (defined, but not reachable today — see above)
Illustrative (2 levels deep) — currently ACCEPTED, not rejected:
Component A:
└─ nestedComponent -> Component B:
└─ nestedComponent -> Component C ⚠️ Not currently rejected, despite being "2 levels deep"
Also valid (1 level deep):
Component A:
└─ nestedComponent -> Component B:
└─ [no nested components] ✅ Valid (1 level)
How to structure composable components:
Instead of deep nesting, use componentPicker to compose components:
// Component: PageLayout
{
"schema": [
{
"name": "header",
"type": "nestedComponent",
"options": { "componentSlug": "header" }
},
{
"name": "sections",
"type": "componentPicker",
"options": {
"allowedComponents": ["hero", "features", "testimonials"]
}
}
]
}
// Component: Hero
{
"schema": [
{
"name": "title",
"type": "text"
},
{
"name": "buttons",
"type": "componentPicker",
"options": {
"allowedComponents": ["button"] // ✅ Max 1 level
}
}
]
}
Breaking Change Detection
When updating a schema that is actively in use (pages/content exist), the API warns about breaking changes.
Detected Breaking Changes
FIELD_REMOVED
A field that existed in the old schema is missing in the new schema.
Example:
// Old schema:
[
{ "name": "title", "type": "text" },
{ "name": "author", "type": "reference" }
]
// New schema:
[
{ "name": "title", "type": "text" }
// author field removed ❌
]
Warning Code: FIELD_REMOVED
Impact:
- Existing pages lose the
authorfield - Data is not deleted but becomes inaccessible
- Older page versions still have the data
FIELD_TYPE_CHANGED
A field's type has changed between old and new schema.
Example:
// Old:
{ "name": "rating", "type": "number" }
// New:
{ "name": "rating", "type": "text" } // ❌ Type changed
Warning Code: FIELD_TYPE_CHANGED
Impact:
- Existing numeric values may be invalid as text
- Type coercion may lose precision
- Content validation may fail
FIELD_NOW_REQUIRED
A field that was optional has become required.
Example:
// Old:
{ "name": "description", "type": "text", "required": false }
// New:
{ "name": "description", "type": "text", "required": true } // ❌ Now required
Warning Code: FIELD_NOW_REQUIRED
Impact:
- Existing pages without this field are now invalid
- Editors will see validation errors
- Pages must be updated to provide values
Non-Breaking Changes
These changes do NOT trigger warnings:
- Adding optional field - Existing content unaffected
- Changing label - No impact on data
- Changing placeholder - No impact on data
- Changing helpText - No impact on data
- Changing options - Always non-breaking today, full stop.
detectBreakingChanges()(packages/shared/src/schema/validator.ts) never inspects theoptionsobject at all — it only compares field presence,type, andrequired. This means tightening a constraint (e.g. loweringmaxLengthbelow existing content's length, or removing achoicesvalue that existing content uses) produces no warning whatsoever, even though it can make existing content fail content-level validation later. - Making required field optional - Existing content remains valid
- Reordering fields - Data structure unchanged
- Changing defaultValue - Only affects new content
Validation Error Responses
Error Response Format
{
"error": {
"code": "INVALID_SCHEMA",
"message": "Schema validation failed",
"details": {
"errors": [
{
"field": "fieldName",
"code": "ERROR_CODE",
"message": "Specific error message"
}
]
}
}
}
Example: Multiple Validation Errors
{
"error": {
"code": "INVALID_SCHEMA",
"message": "Schema validation failed",
"details": {
"errors": [
{
"field": "title",
"code": "INVALID_LENGTH_RANGE",
"message": "minLength cannot be greater than maxLength"
},
{
"field": "author",
"code": "MISSING_COLLECTION_TYPE",
"message": "Reference fields must specify a collectionType"
},
{
"field": "nested",
"code": "CIRCULAR_REFERENCE",
"message": "A component cannot reference itself"
}
]
}
}
}
Validation Error Reference
Complete Error Code List
| Code | Trigger | Severity | How to Fix |
|---|---|---|---|
DUPLICATE_FIELD_NAME | Same field name twice | Error | Rename field to be unique |
INVALID_FIELD_TYPE | Unknown field type | Error | Use a supported field type |
INVALID_LENGTH_RANGE | minLength > maxLength | Error | Fix constraint values |
INVALID_NUMBER_RANGE | min > max (or minItems > maxItems) | Error | Fix constraint values |
MISSING_COLLECTION_TYPE | Reference missing collection (collectionItem mode) | Error | Add collectionType option |
INVALID_COLLECTION_TYPE | Collection doesn't exist — defined but currently unreachable: no production route passes the existingCollectionTypes option validateSchema() needs to trigger it | Error (dead code today) | N/A — a nonexistent collectionType is silently accepted |
INVALID_PAGE_TYPE | Page reference targets a page type that doesn't exist — defined but currently unreachable: no production route passes the existingPageTypes option needed to trigger it | Error (dead code today) | N/A — a nonexistent pageType is silently accepted |
MISSING_COMPONENT_SLUG | NestedComponent missing slug | Error | Add componentSlug option |
INVALID_COMPONENT_REFERENCE | Component doesn't exist | Error | Reference existing component |
CIRCULAR_REFERENCE | Self or indirect loop — only checked for nestedComponent, not componentPicker (see rules 7–8 above) | Error | Remove circular reference |
NESTING_LIMIT_EXCEEDED | 2+ levels deep — defined but currently unreachable: no production route increments nestingLevel when recursing, so this never fires (see rule 9 above) | Error (dead code today) | N/A — deep nestedComponent chains are silently accepted |
UNSUPPORTED_OPTION | Option not applicable for this field configuration (e.g. collectionType/pageType on a user or blog-post reference) | Error | Remove the unsupported option |
MISSING_REPEATER_FIELDS | Repeater has no sub-fields defined | Error | Define at least one sub-field |
DISALLOWED_REPEATER_FIELD_TYPE | Repeater sub-field uses a disallowed type (componentPicker, nestedComponent, or repeater) | Error | Use an allowed sub-field type |
Breaking Change Warning Codes
These appear as warnings, not errors, and do not block saving:
| Code | Trigger | Data Impact |
|---|---|---|
FIELD_REMOVED | Field deleted from schema | Data lost/inaccessible |
FIELD_TYPE_CHANGED | Field type changed | Data type mismatch |
FIELD_NOW_REQUIRED | Optional became required | Existing items invalid |
Validation Flow
The steps below reflect what POST/PUT /admin/page-types and /admin/collection-types (apps/api/src/routes/{page-types,collection-types}.ts) actually do, in order. Steps 6 (circular reference) and 7 (nesting depth) run as part of validateSchema() (step 5) rather than as separate passes, and nesting depth is currently a no-op (see rule 9 above).
Creation (POST)
1. Input validation (Zod schema, via zValidator)
2. Slug-uniqueness check — runs BEFORE schema validation; 409 DUPLICATE_SLUG on collision
3. Duplicate field names check ⎫
4. Valid field types check ⎪
5. Field-specific option validation ⎬ all part of validateSchema()
6. Component reference validation ⎪
7. Circular reference detection ⎪ (nestedComponent only — see rule 8)
8. Nesting depth enforcement ⎭ (currently unreachable — see rule 9)
↓
If slug conflict → 409 Conflict
If schema validation fails → 400 Bad Request
If all pass → 201 Created
Update (PUT)
1. Input validation (Zod schema, via zValidator)
2. Fetch existing record; 404 NOT_FOUND if missing
3. Slug-uniqueness check, only if slug is being changed (collection types only —
page types don't allow changing slug via PUT at all)
4. Collection types only: singleton-conversion check — converting isSingleton
false→true with more than 1 existing item returns 400 SINGLETON_CONVERSION_ERROR
(this check is not documented elsewhere and runs BEFORE schema validation)
5. If a new `schema` is supplied, validateSchema() runs (steps 3-8 from Creation above)
↓ 400 Bad Request if it fails
6. Breaking change detection — CONDITIONAL, not unconditional: only runs when a new
`schema` was supplied AND at least one page/item already uses this type
(pageCount > 0 / itemCount > 0). No existing content → no breaking-change check
at all, even if the new schema removes fields.
↓
If structural validation fails → 400 Bad Request
If structural validation passes → 200 OK
If breaking changes found (and existing content) → 200 OK + warnings
Deletion (DELETE)
1. Check usage count
↓
If in use → 409 Conflict
If unused → 200 OK (soft delete)
Client-Side Validation
The admin UI's real-time validation is minimal — far less than the full rule set enforced server-side. FieldEditor.vue (apps/admin/app/components/schema/FieldEditor.vue) gates its Save button on a single isValid check: that label and name are both non-empty, trimmed strings. That's it.
Real-Time Checks
- Label is non-empty
- Name is non-empty
None of the following are checked client-side while building a schema — they're only caught when the schema is submitted and validated server-side:
- Duplicate field name detection
- Field type validation
- Constraint range validation (e.g.
minLength>maxLength) - Component existence lookup
- Circular reference detection
When Saving
Because client-side checks are minimal, saving to the API is where almost all validation actually happens. A field can look "valid" in the editor (non-empty label/name) and still be rejected by the server for any of the reasons above — always handle and surface the API's error response rather than assuming the UI already caught everything.
Error Display
Validation errors are displayed:
- Inline - Below the problem field
- Summary - At top of form
- Details - Expanded error messages with fix suggestions
Best Practices
- Plan Before Building - Know your field structure before creating
- Use Appropriate Types - Choose specific field types for data
- Set Constraints Early - Define min/max before collecting data
- Reference Existing Items - Don't reference collections/components that don't exist
- Test Component References - Verify circular reference prevention works
- Review Breaking Changes - Check warnings when modifying in-use schemas
- Document Constraints - Explain field limits to editors via helpText
- Version Your Schemas - Consider slug versioning for major changes
Troubleshooting
"Duplicate field name" Error
Problem: Two fields have the same name
Solution: Give each field a unique name (camelCase recommended)
"Invalid field type" Error
Problem: Field type is not recognized
Solution: Use one of the supported field types listed in the Field Types Reference
"minLength cannot be greater than maxLength" Error
Problem: Text field has minLength > maxLength
Solution: Ensure minLength ≤ maxLength
"Circular reference detected" Error
Problem: Component references itself or forms a chain
Solution: Remove self-references and ensure component hierarchy is acyclic
"Component nesting limit exceeded" Error
This error is not currently reachable (see rule 9 above) — deep nestedComponent chains are accepted today, not rejected. The guidance below describes the intended behavior if/when the check is wired up, and is still good practice regardless:
Problem: Component nesting deeper than 1 level
Solution: Use componentPicker instead of multiple nestedComponent fields

