All docs

Schema designer

Model the data your business actually has

A schema is the shape of one kind of record — an order, a guest, a property. Define its fields, attach traits for common behaviour, choose how it is stored, and every schema gets a REST surface automatically.

What a schema is

A schema is a named kind of record inside one organization — orders, guests, rooms. It carries a slug (used in routes), a display name, an ordered list of fields, and a set of traits that switch on common behaviour. Records of that schema are entries.

Schemas live per-organization, so each client's data model is its own — defining orders for one organization never touches another. Creating a schema also drops a client navigation item for it automatically, which you can rename or hide later. System schemas exist too: they are marked is_system and cannot be deleted.

You can build schemas by hand in the build console, start from a blueprint that brings a whole set of them, or drive everything from the admin API:

bash
# Define a new schema (a kind of record) in an organization
curl -X POST https://api.polimorf.com/admin/v1/schemas \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Content-Type: application/json' \
  -d '{
    "slug": "orders",
    "display_name": "Orders",
    "traits": ["timestamped", "publishable", "auditable"],
    "storage_mode": "jsonb"
  }'

Field types

Every field has a field_type drawn from a built-in registry. The type decides how a value is validated, stored, and rendered. When you create or update a field its config is validated against that type's own schema, so a malformed field is rejected at write rather than breaking reads later. The registry groups types into a handful of families:

FamilyTypes
Textstring, text, rich_text, markdown, code, slug
Numberinteger, decimal, money
Choiceboolean, enum, tags
Date / timedate, datetime, duration, calendar_interval, business_hours
Mediaimage, gallery, media
Relationreference, multi_reference, polymorphic_reference, tree_reference
Structuredrepeater, flexible_content, key_value, address, dimensions
Identifieremail, url, phone, uuid, color
Geogeo_point, geo_radius
Advancedjson, formula, slug_from, derived_reference

A few worth calling out: money stores an amount and currency together, so values carry their own currency rather than a bare number; reference links to a single entry while multi_reference, polymorphic_reference, and tree_reference cover one-to-many, cross-schema, and parent/child links; and json is a raw escape hatch with no schema enforcement. A handful of older types (number, time, currency, seo_meta, and the localized_* variants) are deprecated — hidden from the picker but still loadable so legacy records keep working.

bash
# Add a field to the schema. Field type + config are validated at write
curl -X POST https://api.polimorf.com/admin/v1/schemas/orders/fields \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Content-Type: application/json' \
  -d '{
    "slug": "total",
    "display_name": "Order total",
    "field_type": "money",
    "is_required": true
  }'

Per-field switches

Independent of type, a field can be marked is_required, is_unique, is_indexed, and is_localized (per-locale values on a text field — string, text, rich text, markdown — when the schema carries the localizable trait and uses jsonb storage). Not every type supports unique, index, or localization — the registry advertises that per type.

Traits

A trait is a reusable bundle of behaviour you opt a schema into by listing its slug. Traits add system fields, lifecycle, and side-effects without you hand-modelling them. They can declare conflicts and prerequisites — the registry rejects a combination that breaks those rules. There are nine built in:

TraitWhat it adds
timestampedAuto-managed created_at and updated_at.
publishablePublish status, published_at, and scheduling fields.
sluggedA unique slug, auto-generated from another field if absent.
soft_deleteReplaces hard delete with a deleted_at timestamp.
versionedSnapshots a version on every update for history.
auditableWrites an audit-log row on create, update, and delete.
ownableTracks an entry owner, set from the auth principal on create.
localizableEnables per-locale values for fields marked is_localized.
seoAdds a standard SEO meta field.

Traits are part of the schema definition, so you set them at creation and change them later through a migration (see below) — adding one is additive, removing one is treated as destructive and asks for confirmation.

Storage: JSONB or promoted columns

Each schema picks a storage_mode. The default, jsonb, keeps every non-system field in a single data JSONB column on a shared content_entries table — fast to evolve, no DDL per field. Marking a field is_indexed creates an expression index so filters and sorts on it stay quick.

promoted mode instead gives the schema its own physical table, one column per field, addressed by promoted_table_name. Most promoted columns are stored as plain text — the field type stays a logical overlay — so range filters cast to the field's real type at query time, and price > 9 orders numerically rather than lexically. Both backends present the same record shape (id, slug, status, data, _meta) to the rest of the platform, so which one you choose is an internal performance decision, not an API-shape one.

Switching modes is staged

The migration engine plans jsonb_to_promoted / promoted_to_jsonb as confirmation-gated steps, but the data-moving worker for a live storage switch is not yet wired — those steps report as not implemented rather than silently running no DDL. Pick the mode at design time; it is the safe path today.

Computed fields

Three field types are derived rather than typed in by hand — the platform fills them in for you:

  • formula — read-only; evaluates an expression over the entry's other fields when the entry is read. Writing a value is rejected.
  • derived_reference — read-only; resolves by walking a dotted relationship path (e.g. author.organization) across expanded relations when the entry is read. Writing a value is rejected.
  • slug_from — generates a slug from a chosen source_field at write time, with a fallback_pattern when the source is empty.

Formulas run through a small sandboxed expression evaluator: arithmetic, comparisons, boolean logic, conditionals, and a fixed set of pure functions (abs, round, min, max, len, and the str/int/float/bool casts). There is no attribute access, no arbitrary calls, and bounded exponents — names resolve only from the entry's own field values. Computed fields are re-evaluated to a fixed point, so a formula that references another computed field resolves regardless of declaration order; an unresolved or cyclic reference simply settles to null rather than breaking the read.

json
{
  "slug": "line_total",
  "display_name": "Line total",
  "field_type": "formula",
  "config": { "expression": "quantity * unit_price" }
}

Migrations and change_field_type

Changing a schema runs through a two-step migration flow. You preview a new definition to get a plan — an ordered list of steps plus any warnings — then execute that plan by id. The planner diffs the old and new shapes and orders steps safest-first: additive before mutating before destructive.

OperationWhenConfirmation
add_fieldA new field appears.No
rename_fieldSame type + column, slug changed.No
change_field_typeA field changes type.Only if lossy
update_field_configConfig changed in place.No
toggle_localizationis_localized flipped.Only when turning off
add_traitA trait is added.No
remove_fieldA field is dropped.Yes
remove_traitA trait is removed.Yes

Any plan that contains a destructive step is flagged requires_confirmation and must be re-submitted with confirm=true; you can also pass dry_run=true to walk every step without applying it. Because field types are a logical overlay, change_field_type usually needs no DDL — promoted columns are already TEXT and JSONB holds any value, so new writes simply validate against the new type. The one case that touches existing rows is switching to a numeric type, where numeric-looking string values are coerced to real numbers so sorting and aggregation work on old data (opt out with coercion="skip"). Lossless pairs such as string → text or integer → decimal skip the confirmation gate.

bash
# 1 · Preview the diff — returns a plan with steps + warnings, no writes
curl -X POST https://api.polimorf.com/admin/v1/schemas/orders/migrations/preview \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Content-Type: application/json' \
  -d '{ "new_definition": { "slug": "orders", "fields": [ ... ] } }'

# 2 · Execute it by plan_id. Destructive plans need confirm=true
curl -X POST https://api.polimorf.com/admin/v1/schemas/orders/migrations/execute \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Content-Type: application/json' \
  -d '{ "plan_id": "<plan_id>", "dry_run": false, "confirm": true }'

Destructive moves are guarded

Removing a field or trait, and deleting a whole schema, permanently destroy data and history. Schema deletion additionally requires echoing the slug back as a confirm parameter, so it can never happen by accident.

Every schema gets a REST surface

You never wire up endpoints by hand. The moment a schema exists, its entries are reachable through the dynamic content API — list, read, create, update, and delete — scoped to the organization and governed by the same traits and field validation you defined. A schema's storage mode, traits, and computed fields are all invisible at the boundary; callers see one consistent record shape.

See the API reference for the auth flow, organization scoping, filtering, and pagination, and core concepts for how per-organization isolation works underneath.