TentoCMS
Api

Validation

Validation rules applied to page type and component schemas.

This document describes all validation rules applied to page type and component schemas by TentoCMS.

Overview

Schemas are validated at two points:

  1. Client-side - As editors build schemas in the admin interface
  2. 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 in collectionItem mode
  • INVALID_COLLECTION_TYPE - specified collection doesn't exist — currently unreachable: this code only fires when an existingCollectionTypes list is passed into validateSchema(), and no production route passes it, so a nonexistent collectionType is 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 provided
  • INVALID_COMPONENT_REFERENCE - specified component doesn't exist
  • CIRCULAR_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's componentPicker branch (packages/shared/src/schema/validator.ts) only checks that each slug in allowedComponents exists — it never checks whether the list includes the component's own slug. A component can currently list itself in its own componentPicker.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 mode CIRCULAR_REFERENCE is meant to prevent for nestedComponent.

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 author field
  • 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 the options object at all — it only compares field presence, type, and required. This means tightening a constraint (e.g. lowering maxLength below existing content's length, or removing a choices value 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

CodeTriggerSeverityHow to Fix
DUPLICATE_FIELD_NAMESame field name twiceErrorRename field to be unique
INVALID_FIELD_TYPEUnknown field typeErrorUse a supported field type
INVALID_LENGTH_RANGEminLength > maxLengthErrorFix constraint values
INVALID_NUMBER_RANGEmin > max (or minItems > maxItems)ErrorFix constraint values
MISSING_COLLECTION_TYPEReference missing collection (collectionItem mode)ErrorAdd collectionType option
INVALID_COLLECTION_TYPECollection doesn't exist — defined but currently unreachable: no production route passes the existingCollectionTypes option validateSchema() needs to trigger itError (dead code today)N/A — a nonexistent collectionType is silently accepted
INVALID_PAGE_TYPEPage reference targets a page type that doesn't exist — defined but currently unreachable: no production route passes the existingPageTypes option needed to trigger itError (dead code today)N/A — a nonexistent pageType is silently accepted
MISSING_COMPONENT_SLUGNestedComponent missing slugErrorAdd componentSlug option
INVALID_COMPONENT_REFERENCEComponent doesn't existErrorReference existing component
CIRCULAR_REFERENCESelf or indirect loop — only checked for nestedComponent, not componentPicker (see rules 7–8 above)ErrorRemove circular reference
NESTING_LIMIT_EXCEEDED2+ 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_OPTIONOption not applicable for this field configuration (e.g. collectionType/pageType on a user or blog-post reference)ErrorRemove the unsupported option
MISSING_REPEATER_FIELDSRepeater has no sub-fields definedErrorDefine at least one sub-field
DISALLOWED_REPEATER_FIELD_TYPERepeater sub-field uses a disallowed type (componentPicker, nestedComponent, or repeater)ErrorUse an allowed sub-field type

Breaking Change Warning Codes

These appear as warnings, not errors, and do not block saving:

CodeTriggerData Impact
FIELD_REMOVEDField deleted from schemaData lost/inaccessible
FIELD_TYPE_CHANGEDField type changedData type mismatch
FIELD_NOW_REQUIREDOptional became requiredExisting 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

  1. Plan Before Building - Know your field structure before creating
  2. Use Appropriate Types - Choose specific field types for data
  3. Set Constraints Early - Define min/max before collecting data
  4. Reference Existing Items - Don't reference collections/components that don't exist
  5. Test Component References - Verify circular reference prevention works
  6. Review Breaking Changes - Check warnings when modifying in-use schemas
  7. Document Constraints - Explain field limits to editors via helpText
  8. 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


Copyright © 2026