Logo VitNode

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 example plugin: a example.boards collection, and discussions (example.discussion) 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?board=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 example.discussion from Public write.

Define the collection

Create src/content/boards.ts. The definition describes the collection; each board is a row an admin creates later:

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

export const boardContainers = defineContentContainer({
  id: "example.boards",

  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: "boards",
    fields: ["description", "color", "icon"],
  },

  admin: { path: "example/boards" },
});

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

admin.path defaults to the id with dots turned into slashes (example/boards), so the screen opens at /admin/containers/example/boards. admin.permissionModule defaults to the snake-cased id (example_boards), 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/example/src/content/discussion.ts
import { boardContainers } from "@/content/boards"; 

export const discussionContentType = defineContentType({
  id: "example.discussion",
  tableName: "example_discussions",

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

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

  publicWrite: {
    ownership: { field: "author" },
    create: { fields: ["title", "body", "board"], 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 (board, publishedAt). Every write checks that the id belongs to example.boards, not just that it exists. All options are in Fields.

Register the collection on the API

Pass the definition to both content modules. The admin module adds the staff routes and the example_boards permissions; the public module adds the read routes:

plugins/example/src/api/modules/admin/admin.module.ts
import { boardContainers } from "@/content/boards"; 

buildContentAdminModule({
  pluginId: CONFIG_PLUGIN.pluginId,
  containers: [boardContainers], 
  contentTypes: [articleContent, discussionContent],
}),
plugins/example/src/config.api.ts
buildContentPublicModule({
  pluginId: CONFIG_PLUGIN.pluginId,
  containers: [boardContainers], 
  contentTypes: [articleContent, discussionContent],
}),

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/example/src/admin/nav.tsx
import { FolderTreeIcon } from "lucide-react"; 

import { boardContainers } from "@/content/boards"; 

export const exampleBoardsNav = {
  definition: boardContainers,
  icon: <FolderTreeIcon />,
};

export const adminNav = {
  pluginId: CONFIG_PLUGIN.pluginId,
  containers: [exampleBoardsNav], 
  contentTypes: [exampleArticleNav, exampleDiscussionNav],
} satisfies AdminNavPluginSource;
plugins/example/src/admin/content.tsx
export const adminContent = {
  pluginId: CONFIG_PLUGIN.pluginId,
  containers: [exampleBoardsNav], 
  contentTypes: [exampleArticleNav, exampleDiscussionNav],
} satisfies ContentFrontendPluginSource;

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/example/src/locales/en.json
{
  "@vitnode/example": {
    "containers": {
      "boards": {
        "label": "{count, plural, one {Board} other {Boards}}",
        "title": "Boards",
        "desc": "The board tree members file their discussions into.",
        "fields": {
          "description": "Description",
          "color": "Color",
          "icon": "Icon",
          "locked": "Locked"
        }
      }
    },
    "content": {
      "discussion": { "fields": { "board": "Board" } }
    }
  },
  "@vitnode/example:example_boards": "Boards",
  "@vitnode/example:example_boards:can_view": "View boards",
  "@vitnode/example:example_boards:can_create": "Create boards",
  "@vitnode/example:example_boards:can_edit": "Edit and move boards",
  "@vitnode/example:example_boards:can_delete": "Delete boards"
}

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

Build, migrate and restart

Run this from the workspace root, then restart pnpm dev:

Build plugins and migrate
bun run build:plugins && bun run db:migrate

The collection itself needs no table: every definition shares core_content_containers. The migration only adds the board column to example_discussions.

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, as the example's migration does with a "General" board. Database covers editing migrations.

Manage containers in the AdminCP

Open AdminCP → Example → Boards (/admin/containers/example/boards). 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 board with its children nested below it. Drag a board to reorder it among its siblings or to drop it inside another board.
  • 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 board. A board with child boards or with discussions 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 discussion into a board or move it to another one.

A drag that breaks a rule, such as nesting a board 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 "Frontend" board with a "React" board inside it, then read the public tree:

curl http://localhost:3000/api/@vitnode/example/content/boards
{
  "items": [
    {
      "id": 1,
      "parentId": null,
      "name": "Frontend",
      "slug": "frontend",
      "position": 0,
      "depth": 1,
      "metadata": {
        "description": "Everything in the browser",
        "color": "#2563eb",
        "icon": null
      }
    },
    {
      "id": 2,
      "parentId": 1,
      "name": "React",
      "slug": "react",
      "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. Containers on the frontend 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".

What's next

Not available yet

Per-container policies are planned, not shipped. Today every container behaves the same way.

Containers are where per-place rules will live: a publishing mode per board (go live or wait as a draft), moderator approval, who may post or read in a board, and boards hidden from guests. The groundwork is in place. A public create already resolves the container on the server and hands it to resolveContentPublicationPolicy, so these policies can arrive without changing your routes or forms.