Designing Data Schemas

A data schema defines a content type — the shape of a thing your project is about. A course. A lesson. An instructor. You write the schema once, and it drives validation, the editor's forms, and how records reach your components.

This guide is about the design decisions you make when you model a set of related types — how to group fields, when to nest, when to connect one type to another, and — just as important — what doesn't belong in a content schema at all. It runs on one example throughout: the content model behind a course platform.

Sites and app-based solutions — one data model. The uniweb CLI builds more than static sites. A uniweb project can be a content-driven site (file-backed, prerendered, no server) or an app-based solution — the same site backed by its own backend: a database, user accounts, commerce, and your data schemas served dynamically. The backend is optional, and it doesn't change how you design your data. The schemas in this guide are the data model either way; the backend is what gives them users, access control, and persistence. Where that line falls is its own section below.

For the field-by-field syntax (types, format, localized, namespaces), see Data Schemas. For how a record is written out, see Entity Content Structure. For how records reach your components at runtime, see Working with Data.


Start by naming the types

Before writing any fields, list the things your domain is about. For a course platform, the content is:

  • course — a unit of learning
  • lesson — a single teachable piece (sometimes part of a course, sometimes reusable)
  • instructor — the person who teaches, as a public profile
  • program — a curriculum made of several courses

Each becomes a schema. The interesting part isn't the fields inside each one — it's how they relate. That's what the rest of this guide is about.

Notice who's not on that list. A course platform obviously has students, enrollments, and purchases — and none of them are content types. A student is a user account; an enrollment is a record the backend writes when someone buys. They aren't data you author. Knowing what to leave out is half of good schema design — we come back to it in Content vs. the backend.

Reuse before inventing: the standards (@std/person, @std/event, …) already cover common shapes. An instructor profile is just a person — reach for @std/person rather than rolling your own. This guide writes its own @/… types to stay self-contained, but a real project leans on @std/* wherever one fits.


Sections: group fields into named parts

The simplest schema is a flat list of fields — one record's worth of data:

name: lesson
fields:
  title:   { type: string, required: true }
  body:    richtext
  minutes: { type: int }

When a type has distinct groups of fields, or repeating data, you organize them into named sections. A section is a namespace for a group of fields. It holds one record by default; add many: true to make it a repeating list of records.

A type can have several sections. Use multiple single-record sections to keep distinct concerns apart; add many: true for "many of something." Mark one single-record section brief: true — the type's lean summary: its title and the few fields that identify it. The brief is the type's stand-in wherever it's shown in short form (in a reference from another type, a card, a search result), so keep it to the essentials.

name: course
sections:
  identity:                 # the brief — what the course is
    brief: true
    fields:
      title:   { type: string, required: true }
      summary: richtext
      level:   { type: string, enum: [intro, intermediate, advanced] }
  details:                  # a second section — a separate concern
    fields:
      credits: { type: int }
      weeks:   { type: int }

Field or section? A field holds one value, or a simple list ({ type: string, many: true }). A section holds a whole record, or many records. Reach for a section when the group has its own structure — multiple fields, repetition, or nesting; keep it a field when it's a single value or a flat list.


Subsections: model hierarchy

A section can contain child sections — a section nested inside another, with its own fields. This is how you model hierarchy: a course has modules, and each module has lessons.

name: course
sections:
  identity:
    brief: true
    fields:
      title: { type: string, required: true }

  modules:                  # many modules per course
    many: true
    fields:
      title: { type: string }
    sections:
      lessons:              # many lessons per module
        many: true
        fields:
          title: { type: string }
          body:  richtext

(A section can hold both its own fields and child sections, as modules does here.) A record nests the same way: each module is a record with a lessons array inside it (see Entity Content Structure). The hierarchy is pure structure — no IDs to wire up, no back-pointers; the data tree mirrors the schema tree.

Subsections are the right tool when the nested data is owned by and local to its parent — modules and lessons only exist inside their course. When that's not true, you reach for a reference instead.


Embed or reference?

This is the central modeling decision. You have a second type — say instructor — and another type needs to point at it. Two options:

Embed — put the fields directly inside, as a subsection. The data lives in the parent and belongs to it.

Reference — store a pointer to a separate record with a ref field (a relation, in database terms):

instructor: { ref: '@/person' }   # points at a separate person record

A ref field stores a pointer to a record of another type, by its name — the two records stay separate. Choose between embed and reference by asking "does this thing exist on its own?"

Embed (subsection)Reference (ref)
Owned by one parentExists independently
No meaning outside itReused across parents
Lives and dies with its parentOutlives any one parent
Lessons within a courseAn instructor who teaches several courses

The lesson question. Are lessons part of one course (embed them as a subsection), or are they a reusable type that several courses include (make lesson its own schema and reference it)? Both are valid — the answer is about your domain, not the framework. Make lesson standalone when lessons are reused or managed on their own; embed them when they're just the structure of one course.


Relationships with attributes: edge attributes

Sometimes the relationship itself has data — facts that belong to neither end alone. A program includes a course as required or elective, in a particular order. "Required" isn't a property of the program, nor of the course — it's a property of the link between them.

Model this as a many: true section whose items carry a ref plus sibling fields. The reference names the other end; the sibling fields are the edge attributes — the relationship's own data (the columns you'd put on a join table, if you think in SQL):

name: program
sections:
  identity:
    brief: true
    fields:
      title: { type: string, required: true }

  courses:                  # the program's courses, each link carrying its own data
    many: true
    fields:
      course:      { ref: '@/course' }                 # the reference
      requirement: { type: string, enum: [required, elective] }   # edge attribute
      order:       { type: int }                                   # edge attribute

Here the edge attributes live inside a parent (program), because the relationship clearly belongs to the program's side. That's the common case for a content-to-content join.

When the relationship is its own type

Sometimes a relationship is important enough to be a type in its own right — it has its own attributes and you query it directly. The textbook LMS example is enrollment: a student takes a course, with an enrollment date and a grade.

name: enrollment
fields:
  student:     { ref: '@/person' }   # one end
  course:      { ref: '@/course' }   # the other end
  enrolled_on: { type: date }                    # edge attribute
  grade:       { type: string, enum: [A, B, C, D, F, incomplete] }
  status:      { type: string, enum: [active, completed, withdrawn] }

Structurally this is a perfect standalone relationship type — two references plus data on the link — and for a content-to-content join, it's exactly the right shape. But look closely at this one: the student end isn't content. A student is a person who logs in and pays. That single fact moves enrollment out of your content schemas entirely — and it's the cleanest illustration of the most important line in schema design, so it gets its own section next.


The three shapes of "many"

When one type relates to many of another, pick the shape by how much the link carries:

ShapeLooks likeUse when
Embedded subsectiona many: true section with full fieldsthe children are owned and local (a course's modules)
List of references{ ref: '@/course', many: true }you only need pointers, no per-link data (a course's prerequisites)
References with edge attributesa many: true section of { ref + sibling fields }the connection itself has data (a program's courses)

The question is always how much does the link carry — nothing (a bare list of refs), its own attributes (a many: true section of ref + fields), or it's really an owned child (embed it).


Content vs. the backend

The sharpest question in schema design isn't how to model something — it's whether it's content at all. A content schema models what your project is about. It does not model who uses your project, or what they've done.

Three things a course platform clearly needs, none of them content:

Not contentWhat it actually is
Studenta user account — an identity that logs in, whose credentials the backend holds
Enrollment / purchasea record the backend writes when someone buys or signs up — not authored
Progress, completion, quiz scoreswhat a person did — activity, not authored content (storing it is the app's job; nothing tracks it for you)

The tell is always the same: if a record is about a person who logs in, or about something they did, it isn't content. An instructor bio is content — you author it, it renders on the course page. The instructor's login is an account. Same human, two different things, in two different layers. Don't model the account as content, and don't try to author the enrollment.

This is where the optional backend comes in. A static uniweb site has no users and no database, so this layer simply doesn't exist — you ship content, visitors read it. Turn the project into an app-based solution and it gains a backend: user accounts and authentication, commerce (checkout, subscriptions), and a database that serves your data schemas dynamically instead of from files. Crucially, the backend doesn't ask you to redesign anything — the same @/course, @/lesson, @/program schemas you design here are what it stores and serves. What the backend adds is precisely the layer that isn't content: the accounts, the purchases, the access control, the per-user records. You design the content; the backend gives it users and memory.

So the boundary is a feature, not a limitation. Keep your schemas about the domain — courses, lessons, instructors, programs — and let the backend own identity and transactions. The two stay cleanly separated, which is exactly why the same content can move from a static brochure site to a full course platform without reshaping a single schema.


Who can see what — access lives on entities

Once your project has a backend, who may read or edit each record is decided at runtime, by grants — not declared in your schema. There is no private field, no "hidden from this user" flag, no field- or type-level read gate. Access is a property of an entity (a stored record): its owner can see it, plus anyone granted read / edit on it.

One consequence drives real modeling decisions, and it's about the brief. The brief is an entity's public card — it's exactly what you see when you pick an entity to link to (a reference dropdown, a search result). Choosing what to reference requires seeing that card, so the brief is visible to anyone who can reference the entity — it isn't gated, by design. The rest of the entity is: non-brief sections return only on a direct, authorized read of B itself. So a reference exposes B's brief (not its full record), and two rules follow: keep anything sensitive out of briefs — put it in a non-brief section — and orient the reference so the sensitive entity is the one doing the referencing, not the one referenced (reading A exposes B's brief, never the reverse).

So:

To give one part of your data its own access, make it its own entity. "This part must be seen by different people" is a first-class reason to split a type — right beside "this is its own object." You can't protect a field; you protect an entity.

The exam

A student submits answers; an instructor grades them. Two things need protecting, two different ways:

  • The submission belongs to the student — they can read it, but must not rewrite answers after the fact. That's integrity: an append_only section lets answers be added, never edited or deleted.
  • The grade must be invisible to the student until the instructor releases it. That's access — and only an entity carries access, so the grade is its own record the student has no grant on.
# submission — the student owns it; answers can't be rewritten once submitted
name: submission
sections:
  identity:
    brief: true
    fields:
      exam: { ref: '@/exam' }
  answers:
    many: true
    append_only: true          # add answers; never edit or delete them
    fields:
      question: { ref: '@/question' }
      response: { type: text }
# grade — its own record, the instructor's; the score stays OUT of the brief
name: grade
sections:
  identity:                       # the brief — leaks on any reference, so keep it harmless
    brief: true
    fields:
      submission: { ref: '@/submission' }   # what it grades
  result:                         # not the brief — returned only on an authorized read
    fields:
      score:    { type: number }
      comments: richtext

The grade references the submission, yet the student — who owns the submission — has no grant on the grade, so it's invisible to them. "Release the grade" is simply the instructor granting the student read on that record. Notice what's absent: no private flag, no released boolean gating sight. Access is the grant; the grade is a separate record so a grant has something to attach to. And two details keep it airtight: the grade references the submission (not the reverse), so a student reading their submission never hydrates the grade; and score sits in a non-brief section, so even a reference to the grade exposes only its harmless brief, never the score.

A grade field on the submission wouldn't work — anyone who can read the submission reads the grade with it, the student included, before release. The entity split is what makes the grade hide-able, and then releasable.

Isn't that what secret is for? No. There's a secret field kind, but it's a machine credential the system uses and no human ever reads back — a Stripe key, an API token, hidden from everyone, the instructor too. If a person is meant to read it, it isn't a secret; it's an entity with a grant.


Worked example: the course-platform content graph

Four content types and the references between them — and, set apart, the things the backend owns rather than your schemas:

flowchart LR
  subgraph content["Your content schemas — what you design"]
    instructor[instructor]
    course[course]
    program[program]
    course -->|instructor ref| instructor
    program -->|courses ref + edge attrs| course
  end
  subgraph backend["The backend's job — accounts & records, not content"]
    student((student<br/>account))
    purchase[purchase / enrollment]
    progress[progress]
  end
  student -.->|buys| course
  purchase -.->|grants access to| course
  progress -.->|tracks| course
  classDef notcontent fill:#f6f4ff,stroke-dasharray:5 5
  class backend,student,purchase,progress notcontent

Solid arrows are references between content types. course also embeds its modules and lessons (owned, so not a link) and carries a prerequisites list of course references. The dashed cluster is what an app-based solution's backend manages — accounts and per-user records — never authored as content.

# @/person — an independent type (the instructor's public profile; use @std/person in practice)
name: person
fields:
  name:  { type: string, required: true }
  email: { type: string, format: email }
  bio:   richtext
# @/course — namespaces its own fields, embeds what it owns, references what it doesn't
name: course
sections:
  identity:                                                                    # the brief
    brief: true
    fields:
      title:   { type: string, required: true }
      summary: richtext
      level:   { type: string, enum: [intro, intermediate, advanced] }
  details:                                                                     # a separate concern
    fields:
      credits:       { type: int }
      weeks:         { type: int }
      instructor:    { ref: '@/person' }                          # one reference
      prerequisites: { ref: '@/course', many: true }  # a list of references
  modules:                                                                     # embedded: owned by this course
    many: true
    fields:
      title: { type: string }
    sections:
      lessons:
        many: true
        fields:
          title: { type: string }
          body:  richtext
# @/program — references courses, with edge attributes on each link
name: program
sections:
  identity:
    brief: true
    fields:
      title: { type: string, required: true }
  courses:
    many: true
    fields:
      course:      { ref: '@/course' }
      requirement: { type: string, enum: [required, elective] }
      order:       { type: int }

The pattern: person is flat and independent; course namespaces its own fields into sections, embeds what it owns (modules, lessons), and references what it doesn't (instructor, prerequisite courses); program carries edge attributes on its links. Students, enrollments, and progress are nowhere in the schemas — they're the backend's, by design.


From schema to a live experience

A schema isn't the destination — it's the contract that lets a foundation (the React component library that renders your content) turn records into pages. The last step is wiring data to components, and it reads the same whether or not you have a backend:

  • Content-driven site. A page names query: courses; a CourseGrid whose component declares courses receives the query's records as content.data.courses; on a [slug]/ parametric page, a CoursePage or LessonViewer receives the one course the URL names as content.data.courses[0]. No fetch(), no cache code — see Working with Data.
  • App-based solution. The same components read the same content.data.courses — only now the backend serves it, gated by accounts and purchases. A well-built foundation lights up extra affordances when a backend is present (a price tag, an "enroll" button, a progress bar) and degrades gracefully to read-only content when it isn't.

The schema is the stable contract in the middle: design it once, and it drives validation, the editor's forms, runtime delivery, and — when you add a backend — dynamic serving and access control. Everything on either side can change; the content type stays put. (Data Schemas as Contracts is the deeper why.)


Rules of thumb

  • Default to a flat fields: schema. Reach for sections only when you have repeating groups, hierarchy, or genuinely distinct namespaces.
  • Embed what's owned; reference what's independent. "Does it exist on its own?" is the question.
  • If the link has data, it's an edge — a many: true section of { ref + fields }, or a standalone relationship type.
  • Mark the brief. One single-record section, brief: true — the type's at-a-glance summary; keep it lean.
  • Model what your project is about, not who uses it or what they've done. People who log in are accounts; what they bought or completed are backend records — neither is a content schema.
  • Access is per-entity, not per-field. There's no private / hidden field — to give one part its own access, split it into its own entity. A reference exposes the target's brief (its public card — the thing you pick it by), never the full record — so keep sensitive data in non-brief sections, and point the reference from the sensitive entity to the public one.
  • Model the domain, not the storage. The schema mirrors how you think about the things; let the framework (and the backend) handle the rest.

See also

  • Data Schemas — the field-by-field authoring reference: types, format, localized, and the @/ · @std · @org namespaces.
  • Working with Data — how a schema's records reach your components at runtime: queries, what a section receives, parametric pages.
  • Entity Content Structure — how a record is written: sections become keys, subsections become inline fields.
  • Data Schemas as Contracts — why one schema serves validation, editor forms, and delivery at once.