Skip to content
Get started

Announcement

Your content has subtypes. Your CMS should too.

Shapio 0.5.9 adds polymorphic content types. A content type can extend another, and its kinds inherit the parent's fields, live in one list with it, and can be pointed at, granted, and changed as a family.

An events table in the Shapio admin: a plain event, a workshop, two keynotes, and two concerts in one list sorted by start time, with a Kind column

Every calendar has kinds

Take an events calendar. A concert has a lineup. A workshop has a number of seats and a note about what to bring. A talk has a speaker, and a keynote is a talk on the main stage. All of them have a title, a start time, a city, and a price.

Most CMSs give you two ways to model that, and both hurt.

The first is one Event type with every field on it. Concerts carry an empty seats field, workshops carry an empty lineup, and you can't make the lineup required for concerts only, because the CMS doesn't know which events are concerts. Your editors see fields that don't apply, and your API returns them.

The second is four separate types. Now "what's on in Orlando next week" is four requests merged in your code. Page 2 of that merged list is worse: to know which five events come sixth to tenth, you have to fetch ten from every type, merge, and throw most of them away, and the page count is a guess. A ticket that points at "the event" has to pick one type, or point at several types that have nothing in common as far as the CMS knows. And when you add wheelchair access to events, you add it four times and hope the four stay the same.

The data is a family: events, with kinds. The model should say so. That's what polymorphic content types are: one type with several kinds, which the API treats as one thing wherever it can and as separate things wherever it has to.

Polymorphic content types in Shapio

Since 0.5.9, a collection can extend another one. Event holds the shared fields. Concert, Workshop, and Talk extend Event, and Keynote extends Talk. Pick the parent under Extends when you create a type, or set extends in its schema file.

A kind inherits every field of its parent. In the builder the inherited fields sit in a locked block above the kind's own fields, with a link to the parent, because the parent owns them. Kinds can have kinds, up to eight levels. In the sidebar they nest under the type they extend.

One list, every kind

A kind is read under its parent:

GET /api/content/events                       every event, of every kind
GET /api/content/events/talks                 talks, and the keynotes under them
GET /api/content/events/talks/keynotes        keynotes

Here is the Orlando calendar, sorted by start time, three to a page. This is a real response from a Shapio instance, trimmed to the interesting parts:

GET /api/content/events?filters[city][$eq]=Orlando&sort=startsAt:asc&pageSize=3
{
  "data": [
    { "__model": "event", "__kinds": ["event"],
      "title": "Members social", "startsAt": "2026-11-01T22:00:00.000Z", "city": "Orlando", "price": "0.00" },
    { "__model": "workshop", "__kinds": ["event", "workshop"],
      "title": "Intro to letterpress", "startsAt": "2026-11-03T15:00:00.000Z", "city": "Orlando", "price": "60.00",
      "seats": 12, "bring": "An apron" },
    { "__model": "keynote", "__kinds": ["event", "talk", "keynote"],
      "title": "The next ten years of the web", "startsAt": "2026-11-05T14:00:00.000Z", "city": "Orlando", "price": "0.00",
      "speaker": "Sam Okafor", "stage": "Main hall" }
  ],
  "meta": {
    "pagination": { "page": 1, "pageSize": 3, "total": 5, "pageCount": 2 },
    "kinds": [
      { "apiKey": "event", "label": "Event", "parent": null, "routeKey": "events" },
      { "apiKey": "concert", "label": "Concert", "parent": "event", "routeKey": "events/concerts" },
      { "apiKey": "workshop", "label": "Workshop", "parent": "event", "routeKey": "events/workshops" },
      { "apiKey": "talk", "label": "Talk", "parent": "event", "routeKey": "events/talks" },
      { "apiKey": "keynote", "label": "Keynote", "parent": "talk", "routeKey": "events/talks/keynotes" }
    ]
  }
}

Page 2 holds the talk and the Night Owls concert. One request per page, one sort across every kind, a real total, and the database does the work: each kind keeps its own indexes, and Shapio reads them together.

A few things in there are doing quiet work:

  • Each row comes in its own shape. The workshop has seats, the keynote has a speaker and a stage, and the plain event has neither.

  • __model says which kind a row is. __kinds says everything it is, root first, so a keynote passes a check for talks with __kinds.includes('talk').

  • meta.kinds is the family tree, once per response, so a site can build a filter or group a list without asking for the schema.

  • Filters and sorts take the fields every kind in the list has. Ask a mixed list for fields=lineup and you get a 400, because most events don't have one. To filter on a lineup, read /events/concerts.

  • filters[__model][$eq]=event narrows the list to plain events, with the right total.

A ticket can point at any event

A relation to Event accepts any kind of event. This ticket points at the keynote, and populate returns it in its own shape:

{ "__model": "ticket", "holder": "Jordan Lee",
  "event": { "__model": "keynote", "__kinds": ["event", "talk", "keynote"],
             "title": "The next ten years of the web", "speaker": "Sam Okafor", "stage": "Main hall" } }

It still can't point at a blog post or an author, because those aren't events. That's the difference between a family and a relation that accepts a list of unrelated types: Shapio knows every target has a title, a start time, and a price.

Add a field once

Say the calendar should show whether each venue is wheelchair accessible. I added one required field to Event, Wheelchair accessible, a boolean that defaults to true. The review said it applied to Event and all four of its kinds, backfilled the default into every existing event of every kind, and built the filter index for each kind. Then it went live. No migration file, no restart, no redeploy.

Every event had it a second later, whatever its kind:

GET /api/content/events?fields=title,wheelchairAccessible&sort=startsAt:asc
[ { "__model": "event",    "title": "Members social",                "wheelchairAccessible": true },
  { "__model": "workshop", "title": "Intro to letterpress",          "wheelchairAccessible": true },
  { "__model": "keynote",  "title": "The next ten years of the web", "wheelchairAccessible": true },
  { "__model": "talk",     "title": "Content models are data",       "wheelchairAccessible": true },
  { "__model": "concert",  "title": "Night Owls",                    "wheelchairAccessible": true },
  { "__model": "concert",  "title": "Quiet Rooms",                   "wheelchairAccessible": true } ]

The field exists once, on Event. There is nothing to keep in step.

A talk becomes a keynote

Plans change. Ana's talk moved to the main stage, so it needed to become a keynote. In the entry's settings there's Change kind. Keynote extends Talk, so nothing is lost, and the entry moved:

{ "__model": "keynote", "__kinds": ["event", "talk", "keynote"],
  "title": "Content models are data", "stage": null }

Going the other way drops something. Turning a workshop into a concert would remove its seats and its note about what to bring, and Shapio names both fields and asks you to confirm before it does it. History keeps the old values, and a site pinned to an older snapshot keeps seeing the workshop it built against.

GraphQL gets an interface

A type with kinds gets an interface, and every kind implements it. Ask for the shared fields once and for each kind's own with a fragment. ... on TalkInterface matches keynotes too, because a keynote is a talk:

{
  events(sort: [{ startsAt: ASC }], pageSize: 4) {
    nodes {
      __typename
      title
      startsAt
      ... on Workshop { seats }
      ... on Concert { lineup }
      ... on TalkInterface { speaker }
    }
  }
}

A type without kinds keeps exactly the schema it had.

One grant covers the family

A role that may read Event may read every kind of event, including kinds you add next year. A kind can get grants of its own, which only ever add access. So a "Venue partner" role can read every event and also edit workshops, and nobody has to remember to tick a box when Talk gets a new kind.

How other CMSs handle it

I checked the current docs for Strapi 5, Payload 3, Sanity, Contentful, Directus 11, Hygraph, and WordPress with ACF. None of them has polymorphic content types: no content type can extend another. Several have some of the pieces:

  • Sanity can list several types in one GROQ query, sorted and sliced together, and a reference can point at several types. WordPress does the same with a list of post types in WP_Query.

  • Payload, Contentful, Directus, and Hygraph let a relation point at more than one type.

  • Contentful's delivery API can list entries of every type at once, but filtering or sorting on a field needs a single content type. In a contentful.js issue, a developer couldn't filter two types on a field they share, and a maintainer confirmed the API works that way.

  • Strapi generates its endpoints per content type, and a request on its own feedback board, posted in 2022, asks for relations that can point at more than one type.

  • Shared field sets exist everywhere: Strapi's components, ACF field groups, and the field arrays you can reuse in Payload or Sanity code. They save you typing. They don't make the types a family the API can list, sort, filter, and point at as one thing.

What I didn't find anywhere was the whole polymorphic picture together: kinds that inherit from a parent, read as one paged list with each row in its own shape, a relation and a grant that cover the family, and a field added once that reaches every kind while the site is running.

Where it stops

A kind shares its parent's Localized and Draft & publish settings and lives where its parent does, shared or on one site. Changing those settings on a parent with kinds goes through a change set, so the whole family moves at once. Filters on a kind's own field need that kind's route. And a type extends one parent, not several.

Shapio's own blog is next. It's one Post type with fields only guides use. It's about to become Post, with Guide and Opinion as kinds, without a migration.