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 collection | Content type | |
|---|---|---|
| Declared with | defineContentContainer | defineContentType |
| Storage | One shared core table, core_content_containers. No migration of its own | Its own table, with a migration |
| Shape | A tree with parents, sibling order and a depth limit | Flat records |
| Fields | A few scalars (text, textarea, number, boolean, enum) | Every field kind |
| Drafts, revisions | No. A container exists or it does not | Optional publication, editorial and friends |
| Linked from records | field.container(), at most one per content type | field.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:
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.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | plugin.collection, lowercase and dot separated. Every row stores it, so never rename it | |
fields | field.*() map | No | {} | 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[] } | No | off | Read-only public routes at /api/{pluginId}/content/{path} |
admin | { path?; permissionModule?; navigation? } | No | see below | The AdminCP screen and its staff permissions |
policies | { defaults?; contentTypes? } | false | No | off | Per-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:
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:
import { deskContainers } from "@/content/desks";
buildContentAdminModule({
containers: [deskContainers],
contentTypes: [storyContent, letterContent],
}),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:
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:
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:
{
"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:
bun run db:migrateThe collection itself needs no table, because every definition shares core_content_containers. The migration only adds the desk column to newsroom_letters.
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:
| Cause | Message starts with |
|---|---|
An id that is not plugin.collection | Container definition id "x" must look like "plugin.collection" |
A field named id, name, slug, depth, metadata or another column | Field "x" is reserved. |
| A field kind other than text, textarea, number, boolean or enum | Field "x" is a "relation" field. |
A field with localized, unique or ai | Field "x" is followed by the option and why containers refuse it |
maxDepth below 2 or above 10 | hierarchy.maxDepth must be a whole number from 2 to 10. |
publicApi.fields naming an unknown or built-in field | publicApi.fields references unknown field "x". |
Two collections with one id or one admin.path | Duplicate container definition id / AdminCP path "x" is claimed by both |
| A collection and a content type sharing a public path or permission module | Public path "x" is claimed by both / Permission module "x" is used by both |
Two field.container() in one content type | Content type declares 2 container fields |
field.container() pointing at an unregistered collection | Field "x" uses the container collection "y", which no plugin registers. |
A container field in publicWrite.update.fields | publicWrite.update.fields includes the container field "x". |
Related
- Hierarchy and ordering: depth, moves, safe deletion and the server-side service.
- Container pages: a desk tree, a desk page and a create form with a desk picker.
- Publishing rules: who may post in each desk, whether posts wait for approval, and how long authors may edit.
Read permissions per container, such as desks hidden from guests, are not available yet. Today every reader sees every container in the public tree.