Documentation

Containers

Organize Content Engine records into a tree of containers, such as forum boards, blog sections or knowledge-base categories, with defineContentContainer and field.container.

A container is a place a record lives in: one forum board, one blog section, one knowledge-base category. You declare a collection of containers once in code, admins build its tree in the AdminCP, and each record of a content type points at exactly one container through field.container().

This guide follows the newsroom plugin, with a newsroom.desks collection and letters to the editor (newsroom.letter) filed into it.

Containers or a content type?

A category could also be a content type with a relation. Pick a container when the thing organizes content rather than being content itself:

Container collectionContent type
Declared withdefineContentContainerdefineContentType
StorageOne shared core table, core_content_containers. No migration of its ownIts own table, with a migration
ShapeA tree with parents, sibling order and a depth limitFlat records
FieldsA few scalars (text, textarea, number, boolean, enum)Every field kind
Drafts, revisionsNo. A container exists or it does notOptional publication, editorial and friends
Linked from recordsfield.container(), at most one per content typefield.relation(), as many as you need
Public filter?desk=4, and includeDescendants=true for a whole subtree?category=4, exact match only

A record belongs to one container. For anything else it points at, such as tags, keep using relations.

Add a container collection

You need a content type to file into containers, like newsroom.letter from Member writing.

Define the collection

Create src/content/desks.ts. The definition describes the collection, and each desk is a row an admin creates later:

plugins/newsroom/src/content/desks.ts
import { defineContentContainer, field } from "@vitnode/core/content";

export const deskContainers = defineContentContainer({
  id: "newsroom.desks",

  fields: {
    description: field.textarea({ maxLength: 500, nullable: true }),
    color: field.text({ maxLength: 7, nullable: true }),
    icon: field.text({ maxLength: 64, nullable: true }),
    locked: field.boolean({ defaultValue: false }),
  },

  hierarchy: { maxDepth: 3 },
  ordering: { mode: "manual" },

  publicApi: {
    path: "desks",
    fields: ["description", "color", "icon"],
  },

  admin: { path: "newsroom/desks" },
});

Every container already has id, parentId, name, slug, position and depth. fields adds your own values, stored in a metadata column and validated against the definition on every write. locked is not in publicApi.fields, so it never leaves the server.

NameTypeRequiredDefaultDescription
idstringYesplugin.collection, lowercase and dot separated. Every row stores it, so never rename it
fieldsfield.*() mapNo{}Extra values per container. text, textarea, number, boolean and enum only
hierarchy{ enabled?: boolean; maxDepth?: number }No{ enabled: true, maxDepth: 5 }maxDepth counts levels, roots included, from 2 to 10. enabled: false makes a flat list
ordering{ mode?: "manual" | "alphabetical" }No{ mode: "manual" }How siblings are sorted. See Hierarchy and ordering
publicApi{ path: string; fields?: string[] }NooffRead-only public routes at /api/{pluginId}/content/{path}
admin{ path?; permissionModule?; navigation? }Nosee belowThe AdminCP screen and its staff permissions
policies{ defaults?; contentTypes? } | falseNooffPer-container publishing rules. See Publishing rules

admin.path defaults to the id with dots turned into slashes (newsroom/desks), so the screen opens at /admin/containers/newsroom/desks. admin.permissionModule defaults to the snake-cased id (newsroom_desks), and admin.navigation: false hides the sidebar entry.

Point the content type at it

Add one field.container() to the content type. List it in publicApi.fields to return it, in filterableFields to filter by it, and in publicWrite.create.fields to let members pick it:

plugins/newsroom/src/content/letter.ts (excerpt)
import { deskContainers } from "@/content/desks"; 

export const letterContentType = defineContentType({
  id: "newsroom.letter",
  tableName: "newsroom_letters",

  fields: {
    title: field.text({ required: true, minLength: 3, maxLength: 200 }),
    slug: field.slug({ source: "title" }),
    body: field.richText({ required: true }),
    author: field.user(),
    desk: field.container({ definition: deskContainers, required: true }), 
  },

  publicApi: {
    path: "letters",
    fields: ["id", "title", "slug", "body", "desk", "publishedAt"], 
    filterableFields: ["desk"], 
  },

  publicWrite: {
    ownership: { field: "author" },
    create: { fields: ["title", "body", "desk"], status: "published" }, 
    update: { fields: ["title", "body"] },
  },
});

The column is an integer foreign key to core_content_containers.id with ON DELETE RESTRICT, plus an index on (desk, publishedAt). Every write checks that the id belongs to newsroom.desks, not just that it exists. All options are in Fields.

A content type can also live in the container of a record it points at, such as a reply in its topic's forum, with a required field.relation({ target, container: true }) instead of a field.container(). See Comments.

Register the collection on the API

Pass the definition to both content modules. The admin module adds the staff routes and the newsroom_desks permissions, and the public module adds the read routes. The newsroom registers more content types than shown here:

plugins/newsroom/src/api/modules/admin/admin.module.ts (excerpt)
import { deskContainers } from "@/content/desks"; 

buildContentAdminModule({
  containers: [deskContainers], 
  contentTypes: [storyContent, letterContent],
}),
plugins/newsroom/src/config.api.ts (excerpt)
buildContentPublicModule({
  containers: [deskContainers], 
  contentTypes: [storyContent, letterContent],
}),

A content type whose field.container() names a collection no plugin registers stops the API at startup. Registering the same definition object in both modules is fine.

Register the AdminCP screen

Add the collection to containers in both AdminCP files, next to your content types. icon is optional and defaults to a folder icon:

plugins/newsroom/src/admin/nav.tsx (excerpt)
import { FolderTreeIcon, MailIcon, NotebookPenIcon } from "lucide-react"; 

import { deskContainers } from "@/content/desks"; 

export const adminNav = {
  pluginId: manifest.id,
  containers: [{ definition: deskContainers, icon: <FolderTreeIcon /> }], 
  contentTypes: [
    { definition: storyContentType, icon: <NotebookPenIcon /> },
    { definition: letterContentType, icon: <MailIcon /> },
  ],
} satisfies AdminNavPluginSource;

The newsroom's AdminCP sources hold nothing server-only, so its admin/content.tsx reuses the same object:

plugins/newsroom/src/admin/content.tsx
export { adminNav as adminContent } from "./nav";

Plugin files explains why the two files are separate.

Add the labels

The AdminCP reads the collection's names from {pluginId}.containers.{entity}, where the entity is the id without its plugin prefix. The permission labels follow the permission module:

plugins/newsroom/src/locales/en.json (excerpt)
{
  "newsroom": {
    "containers": {
      "desks": {
        "label": "{count, plural, one {Desk} other {Desks}}",
        "title": "Desks",
        "desc": "The desks readers send their letters to. Drag to reorder or nest desks.",
        "fields": {
          "description": "Description",
          "color": "Color",
          "icon": "Icon",
          "locked": "Locked"
        }
      }
    },
    "content": {
      "letter": { "fields": { "desk": "Desk" } }
    }
  },
  "newsroom:newsroom_desks": "Desks",
  "newsroom:newsroom_desks:can_view": "View desks",
  "newsroom:newsroom_desks:can_create": "Create desks",
  "newsroom:newsroom_desks:can_edit": "Edit and move desks",
  "newsroom:newsroom_desks:can_delete": "Delete desks"
}

Without labels, the screen falls back to a name derived from the id, such as "Desks".

Migrate

Run this from the workspace root while pnpm dev is running:

Migrate the database
bun run db:migrate

The collection itself needs no table, because every definition shares core_content_containers. The migration only adds the desk column to newsroom_letters.

Existing records need a container

A required container field becomes a NOT NULL column. If the table already has rows, edit the generated migration to create a container and assign it before the SET NOT NULL. Migrations covers editing migrations.

Manage containers in the AdminCP

Open AdminCP → Newsroom → Desks (/admin/containers/newsroom/desks). Staff need the collection's can_view permission to see it, and can_create, can_edit or can_delete for each action.

  • The tree shows every desk with its children nested below it. Drag a desk to reorder it among its siblings or to drop it inside another desk.
  • Create and edit open a sheet with the name, the slug and the collection's own fields. A container created without a slug gets one derived from its name.
  • The move menu on each row does the same as dragging from the keyboard: Move up, Move down, Move to top level and Move inside….
  • Delete works only on an empty desk. A desk with child desks or with letters is refused with a message that says which one, and nothing changes.

In a content type's generated form, a container field becomes a picker that lists the collection's tree, so staff can file a letter into a desk or move it to another one.

A drag that breaks a rule, such as nesting a desk deeper than maxDepth or dropping it inside its own child, is refused the same way. Hierarchy and ordering lists every rule.

Check the result

Create a "Politics" desk with an "Elections" desk inside it, then read the public tree:

curl http://localhost:3000/api/newsroom/content/desks
{
  "items": [
    {
      "id": 1,
      "parentId": null,
      "name": "Politics",
      "slug": "politics",
      "position": 0,
      "depth": 1,
      "metadata": {
        "description": "Parliament, parties and policy",
        "color": "#2563eb",
        "icon": null
      }
    },
    {
      "id": 2,
      "parentId": 1,
      "name": "Elections",
      "slug": "elections",
      "position": 0,
      "depth": 2,
      "metadata": { "description": null, "color": null, "icon": null }
    }
  ]
}

Items come in tree order: each parent, then its subtree. metadata holds only publicApi.fields, and a value nobody set is null. Container pages turns this into pages.

Definition errors

defineContentContainer and the boot checks throw a ContentEngineError before the app serves a request:

CauseMessage starts with
An id that is not plugin.collectionContainer definition id "x" must look like "plugin.collection"
A field named id, name, slug, depth, metadata or another columnField "x" is reserved.
A field kind other than text, textarea, number, boolean or enumField "x" is a "relation" field.
A field with localized, unique or aiField "x" is followed by the option and why containers refuse it
maxDepth below 2 or above 10hierarchy.maxDepth must be a whole number from 2 to 10.
publicApi.fields naming an unknown or built-in fieldpublicApi.fields references unknown field "x".
Two collections with one id or one admin.pathDuplicate container definition id / AdminCP path "x" is claimed by both
A collection and a content type sharing a public path or permission modulePublic path "x" is claimed by both / Permission module "x" is used by both
Two field.container() in one content typeContent type declares 2 container fields
field.container() pointing at an unregistered collectionField "x" uses the container collection "y", which no plugin registers.
A container field in publicWrite.update.fieldspublicWrite.update.fields includes the container field "x".

Read permissions per container, such as desks hidden from guests, are not available yet. Today every reader sees every container in the public tree.