Models for data, blocks for view
Structured content lives in workspace models and reaches a block through fields.relation - never as a repeater of records in block props.
cmssy splits a page's content into two layers with two different owners.
- Data is CMS-first. Structured, reusable content - testimonials, FAQ items, team members, offices - lives in workspace models and their records. Editors, or an AI client over MCP, create the model, add records, reorder and translate them, all without a deploy.
- View is code-first. A block is a typed React component in your repo. Its
propsschema holds what belongs to the view: copy, variants, layout switches - and references to data viafields.relation.
The bridge is fields.relation. The block's schema declares which model it renders, and the SDK hands the component fully-resolved records at render time. The block ships on deploy; the records change whenever an editor saves.
The anti-pattern: records inside a repeater
A fields.repeater holding { quote, author, role } items looks quicker, and for about a week it is. Then it traps the data inside one block instance on one page. It cannot be reused on another page, queried, sorted or referenced - and every copy drifts on its own.
The test is ownership, not shape. Reach for a repeater only for view-local structure: a list of feature bullets that exists nowhere else. The moment items are content with an identity - things you would add, translate or reuse independently of this page - they are records of a model.
fields.relation
fields.relation({
label: "Testimonials",
model: "testimonial", // the model's slug (required)
mode: "all", // bind every record; omit for editor-picked
sort: "order_asc", // <fieldKey>_asc / <fieldKey>_desc
limit: 12, // collection cap (default 50)
multiple: true, // picked mode: pick many instead of one
});There are two modes, and choosing between them is a question about who decides what appears:
mode: "all"- the field is bound to the model's record list. There is nothing to pick in the editor;sortandlimitshape the list. Use it for "render all of them" sections: FAQ, logos, testimonials.- picked (the default) - the editor picks one record, or several with
multiple: true. Use it when the page chooses: a featured case study, the three plans on a pricing page.
What the component receives
The stored value is a record id, or an array of them. But the SDK resolves relations server-side, before the component renders, so your component never sees an id:
mode: "all"ormultiple→CmssyModelRecord[]- picked single →
CmssyModelRecordorundefined
A record's fields live under record.data as a fieldKey → value map typed Record<string, unknown>. Narrow each value before rendering, and use record.id as the React key.
A picked single is undefined when the reference dangles - the record was deleted. No required flag can rule that out, so the component must render sensibly without it.
How resolution works
Resolution is one batched pass per page, before any server loader runs. Picked ids go through public.model.recordsByIds; mode: "all" fields go through public.model.records, deduped per model plus sort plus limit. Both carry the page's locale.
A relation never breaks a render. A fetch failure or a dangling id degrades to an empty list or an absent value, and logs [cmssy] relation resolution failed on the server. The editor canvas renders without the server resolve, so there the value is the degraded shape too - another reason the component must not assume data is present.
End to end: testimonials
One model, one block, no loader.
1. The model, CMS-first. In the admin - or over MCP with create_model - create testimonial with fields quote (textarea), author (text), role (text), order (number). Add records. This is data entry, not a deploy.
2. The block, code-first. The schema declares the relation; the component renders whatever records arrive:
// blocks/testimonials/block.ts
import { defineBlock, fields } from "@cmssy/react";
import Testimonials from "./Testimonials";
export const testimonialsProps = {
heading: fields.text({ label: "Heading" }),
items: fields.relation({
label: "Testimonials",
model: "testimonial",
mode: "all",
sort: "order_asc",
limit: 12,
}),
};
export const testimonialsBlock = defineBlock({
type: "testimonials",
label: "Testimonials",
component: Testimonials,
props: testimonialsProps,
});3. Adding a testimonial is now data entry. No pull request, no deploy, no developer. That is the whole point of the split.
Next steps
- Block schema & field types - every field type, including
relation. - GraphQL delivery API - querying records yourself when a relation is not enough.
- Server loaders - for data that relations cannot express.