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:
# 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:
| Family | Types |
|---|---|
| Text | string, text, rich_text, markdown, code, slug |
| Number | integer, decimal, money |
| Choice | boolean, enum, tags |
| Date / time | date, datetime, duration, calendar_interval, business_hours |
| Media | image, gallery, media |
| Relation | reference, multi_reference, polymorphic_reference, tree_reference |
| Structured | repeater, flexible_content, key_value, address, dimensions |
| Identifier | email, url, phone, uuid, color |
| Geo | geo_point, geo_radius |
| Advanced | json, 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.
# 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 markedis_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:
| Trait | What it adds |
|---|---|
| timestamped | Auto-managed created_at and updated_at. |
| publishable | Publish status, published_at, and scheduling fields. |
| slugged | A unique slug, auto-generated from another field if absent. |
| soft_delete | Replaces hard delete with a deleted_at timestamp. |
| versioned | Snapshots a version on every update for history. |
| auditable | Writes an audit-log row on create, update, and delete. |
| ownable | Tracks an entry owner, set from the auth principal on create. |
| localizable | Enables per-locale values for fields marked is_localized. |
| seo | Adds 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 plansjsonb_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 anexpressionover the entry's other fields when the entry is read. Writing a value is rejected.derived_reference— read-only; resolves by walking a dotted relationshippath(e.g.author.organization) across expanded relations when the entry is read. Writing a value is rejected.slug_from— generates a slug from a chosensource_fieldat write time, with afallback_patternwhen 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.
{
"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.
| Operation | When | Confirmation |
|---|---|---|
| add_field | A new field appears. | No |
| rename_field | Same type + column, slug changed. | No |
| change_field_type | A field changes type. | Only if lossy |
| update_field_config | Config changed in place. | No |
| toggle_localization | is_localized flipped. | Only when turning off |
| add_trait | A trait is added. | No |
| remove_field | A field is dropped. | Yes |
| remove_trait | A 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.
# 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.