Options
Every defineContentType option in the VitNode Content Engine, with its type, default, validation rules and the options it depends on.
defineContentType from @vitnode/core/content takes one object that describes a content type and returns the resolved definition, with every default filled in. Use this page to look up an option. For a guided first build, follow Create your first content type.
Minimal example
This is example.article from the example plugin, trimmed to the options most content types start with:
import { defineContentType, field } from '@vitnode/core/content'
export const articleContentType = defineContentType({
id: 'example.article',
tableName: 'example_articles',
fields: {
title: field.text({ required: true, minLength: 3, maxLength: 200 }),
slug: field.slug({ source: 'title' }),
excerpt: field.textarea({ maxLength: 500, nullable: true }),
},
publication: true,
publicApi: {
path: 'articles',
fields: ['title', 'slug', 'excerpt', 'publishedAt'],
},
admin: {
path: 'example/articles',
titleField: 'title',
},
})When options are checked
Validation happens in two places, and both stop the app from starting:
defineContentTypechecks one content type when its module is imported. Anything wrong in that object throws aContentEngineError.- Plugin registration checks every installed content type together, for things only the whole set can show: two types sharing a table or an AdminCP path. See Unique across content types.
Every message starts with [Content Engine] <id>: and names the option at fault:
[Content Engine] example.article: publicApi needs `publication: true`. A public API without a draft state would put every row on the internet the moment it is created.Many of these rules are type errors too, so your editor often complains before the app does.
Top-level options
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | plugin.entity, such as example.article. See Names | |
tableName | string | Yes | Postgres table name, such as example_articles | |
fields | Record<string, field> | Yes | At least one field built with a field.* helper | |
idStrategy | "serial" | "uuid" | "bigint" | No | "serial" | Type of the primary key. See idStrategy |
admin | admin | No | {} | AdminCP path, list, form and title |
publication | boolean | No | off | Draft and published states |
publicApi | publicApi | No | off | Read-only public routes |
publicWrite | publicWrite | No | off | Create and edit routes for signed-in users. Needs publicApi |
delivery | true | delivery | No | off | Public page URLs, SEO metadata, redirects and sitemap |
editorial | true | editorial | No | off | Revisions, preview links and scheduling |
liveEditing | boolean | No | false | Live editing in the AdminCP. Needs editorial |
localization | localization | No | off | A translation table for localized: true fields |
search | search | No | off | Site search indexing |
visibility | boolean | No | off | Hide and unhide records. Needs publication |
duplication | true | duplication | No | off | A Duplicate action that copies a record into a new draft |
reports | true | reports | No | off | Readers can report published records to moderators. Needs publicApi |
thread | thread | No | off | Replies that answer each other under one root record. Needs publicApi |
indexes | indexes | No | [] | Extra database indexes |
A feature block is on when it is present. publicApi, publicWrite, localization and search take an options object. publication, liveEditing and visibility are true, and editorial, delivery and duplication take true or an options object. Leave a block out, or set it to false, to keep it off. Write true or the object directly rather than a boolean variable, because the generated types read the literal to decide which columns and routes exist.
Names
| Name | Rule |
|---|---|
id | Matches /^[a-z0-9]+(?:\.[a-z0-9-]+)+$/: lowercase, at least two dot-separated segments, dashes allowed after the first. At most 100 characters with editorial or search |
tableName | snake_case, starts with a letter, at most 63 characters |
| Field names | camelCase, start with a lowercase letter |
| Reserved fields | id, createdAt and updatedAt always. status and publishedAt with publication. version with editorial. hiddenAt, hiddenBy and visibility with visibility |
| Reserved for the list route | cursor, first, last, order, orderBy and search, because they are query parameters of the generated list route. Checked at plugin registration |
| Localized fields | Cannot be named itemId, languageId, version, createdAt or updatedAt (translation table columns). A localized type cannot expose a public field called locale |
Two names are derived from the id:
- The entity key is the id without its first segment, with further dots turned into
_.example.articlebecomesarticle. Strings live under{pluginId}.content.{entityKey}, for example@vitnode/example.content.article. - The permission module is the entity key with anything outside
a-z0-9turned into_, soexample.localized-articlebecomeslocalized_article. Permissions are named@vitnode/example:article:can_view. Override it withadmin.permissionModule.
Every content type gets can_view, can_create, can_edit and can_delete. publication adds can_publish, editorial adds can_restore and visibility adds can_hide. Duplicating needs can_create and can_view, so duplication adds no permission.
admin
Controls the generated AdminCP screens. Guide: Customize the AdminCP.
| Name | Type | Default | Description |
|---|---|---|---|
path | string | The id with dots as slashes (example/article) | Screen URL under /admin/content/. Lowercase segments of letters, digits and dashes, starting with a letter, joined by /. The last segment cannot be create or edit |
permissionModule | string | Derived from the id, see Names | Middle part of the permission names. snake_case |
titleField | field name | null | First shared text or textarea field, then first localized one | Names a record in headings, pickers and toasts. A localized field is shown in the reader's language. null means no title |
colorField | field name | null | null | A shared field holding a color, shown as a swatch beside the title |
create.mode | "dialog" | "page" | "dialog" | Open the create form in a dialog or on its own page |
edit.mode | "dialog" | "page" | "dialog" | Same, for the edit form |
navigation | boolean | true | false hides the AdminCP sidebar entry |
list.columns | field names | status (with publication), every shared single-column field, updatedAt | Table columns, in order. Accepts localized fields, system columns and to-many relation or user fields |
list.searchableFields | field names | Shared text and textarea fields | Fields the search box matches: text, textarea, richText or slug |
list.orderableFields | field names | [] | Sortable shared fields. System columns are always sortable and need no entry. No localized or file fields |
list.defaultOrderBy | column name | "updatedAt" | A system column or one of list.orderableFields |
list.defaultOrder | "asc" | "desc" | "desc" | |
list.thumbnailField | field name | A shared single field.file() drawn in the title cell. titleField must be one of list.columns | |
form.fields | field names | Every field except field.blocks(), in declaration order | Fields in the generated form |
form.sections | { name: string; fields: string[] }[] | [] | Groups the form into titled sections. Cannot be combined with form.fields |
A section name is lowercase a-z0-9_ and starts with a letter, because it is the key its heading is read from. Each field goes in one section only, and no section can be empty.
admin: {
permissionModule: "posts",
path: "blog/articles",
titleField: "title",
create: { mode: "page" },
edit: { mode: "page" },
list: {
columns: ["title", "authorId", "status", "publishedAt", "updatedAt"],
searchableFields: ["title"],
thumbnailField: "coverImage",
},
},publication
publication: true adds the status ("draft" or "published") and publishedAt columns, and the can_publish permission. It has no other options.
Publication on its own never makes anything public. Only publicApi does. Guide: Publish content with a public API.
publication: true,publicApi
Generates read-only routes at /api/{pluginId}/content/{path} that return published records only. Guide: Publish content with a public API.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | Yes | One URL segment matching /^[a-z][a-z0-9-]*$/, at most 64 characters, not admin | |
fields | field names | Yes | The allowlist, with no wildcard. Must contain exactly one top-level slug field. No duplicates | |
searchableFields | field names | No | [] | Matched by ?search=. Text, textarea, richText or slug fields. Rich text matches its words only |
filterableFields | field names | No | [] | Each becomes an equality query parameter. Boolean, container, enum, number, relation, slug or text fields. A container also takes includeDescendants=true |
orderableFields | field names | No | [] | Accepted by ?orderBy=. publishedAt is always added |
defaultOrderBy | field name | No | "publishedAt" | Must be orderable |
defaultOrder | "asc" | "desc" | No | "desc" | |
people | user field names | No | [] | Shared field.user() fields returned as public person summaries. Not listed in fields |
listFields | field names | No | fields | What each list row carries. A subset of fields, no duplicates. The detail route still returns every field in fields |
What fields accepts:
- Fields of kind text, textarea, richText, slug, number, boolean, enum, dateTime, file, relation, container and blocks. A file is returned as its public descriptor, never as the file id. A container is returned as its id.
- The columns
id,createdAt,updatedAtandpublishedAt. - Group and repeatable leaves one at a time, as
"seo.title". Naming the whole group throws, so a leaf added later stays private. - Not
status(every public row is published), and notfield.user(), so an author is never published by accident.
people is the explicit opt-in for a user. Each named field comes back under its own name as { id, name, nameCode, avatarColor, avatarUrl, role: { color, prefix } }, or null when nobody is set or the account was deleted. A multiple: true field comes back as a list of those summaries in the order it stores them, skipping deleted accounts - handy for co-authors. The summaries for a whole page are loaded in one query. people refuses a field that is not a field.user(), a localized user field, and a name listed twice.
listFields keeps a public list light: leave a long body or a gallery to the detail route, and the list neither selects nor loads them. The list route's OpenAPI schema and the ContentPublicListRow type carry exactly these fields, so a field you left out is a compile error rather than a surprise undefined. people and, on a localized content type, locale are always included. Public lists shows listFields on the blog.
searchableFields, filterableFields and orderableFields must all be in fields. Repeatable leaves fit none of them. orderableFields also refuses file fields, to-many relations and localized fields.
publicApi: {
path: "articles",
fields: ["title", "slug", "excerpt", "featured", "category", "noIndex", "publishedAt"],
searchableFields: ["title", "excerpt"],
orderableFields: ["publishedAt", "title"],
filterableFields: ["category", "featured"],
},publicWrite
Adds create and edit routes for signed-in users beside the publicApi routes, with an owner stamped by the server and a can_edit moderator permission. Guide: Public write. Every option, route and error: Public write API.
publicWrite: {
ownership: { field: "author" },
create: { fields: ["title", "body", "board"], status: "published" },
update: { fields: ["title", "body"] },
},A container field, such as board, may be in create.fields only. Moving a record to another container is a staff edit.
delivery
Gives each published record a public page: canonical URL, SEO metadata, redirects from old slugs and a sitemap. Guides: Public pages and SEO.
| Name | Type | Default | Description |
|---|---|---|---|
path | string | /{publicApi.path}/:slug | The page route. Exactly one :slug, no other parameters or *, not under /admin or /api, at most 512 characters |
redirects | boolean | off | Old slugs answer 308 with the current URL |
seo.titleField | text field | Page title | |
seo.fallbackTitleField | text field | Used when titleField is empty. Needs titleField | |
seo.descriptionField | text, textarea or richText field | Meta description. Rich text becomes plain text, and the page trims it to 160 characters | |
seo.fallbackDescriptionField | text, textarea or richText field | Used when descriptionField is empty. Needs descriptionField | |
seo.noIndexField | shared boolean field | true marks the page noindex and leaves it out of the sitemap | |
seo.openGraph.titleField | text field | og:title | |
seo.openGraph.descriptionField | text, textarea or richText field | og:description | |
sitemap | true | { changeFrequency, priority } | off | Lists published URLs |
sitemap.changeFrequency | "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never" | ||
sitemap.priority | number | From 0 to 1 inclusive | |
hreflang.xDefault | "defaultLocale" | Adds an x-default alternate pointing at the default language |
Every SEO field must be in publicApi.fields, may be a group leaf such as "seo.title", and cannot be a repeatable leaf. noIndexField must be shared, not localized.
delivery: {
redirects: true,
seo: {
titleField: "title",
descriptionField: "excerpt",
noIndexField: "noIndex",
openGraph: { titleField: "title", descriptionField: "excerpt" },
},
sitemap: { changeFrequency: "weekly", priority: 0.7 },
},editorial
Adds a version column, revision history and the can_restore permission. liveEditing builds on it. Guide: Revisions, previews and scheduling.
| Name | Type | Default | Description |
|---|---|---|---|
revisions.retention | number | 50 | Newest revisions kept per record. A whole number from 1 to 500 |
preview | true | { expiresInMinutes, pathTemplate } | off | Signed preview links for drafts |
preview.expiresInMinutes | number | 15 | Link lifetime. A whole number from 1 to 1440 |
preview.pathTemplate | string | Your own preview URL. Starts with /, contains {token} exactly once and no other placeholder, at most 512 characters | |
scheduling | boolean | off | Publish or unpublish at a chosen time |
editorial: {
revisions: { retention: 20 },
preview: { expiresInMinutes: 30 },
scheduling: true,
},localization
Creates a {tableName}_translations table for fields marked localized: true. Guide: Translate content.
| Name | Type | Default | Description |
|---|---|---|---|
defaultLocale | string | Required. The language every record is created in, such as "en" or "pt-BR". At most 32 characters | |
fallback | "none" | "default" | "none" | "default" serves the default language when a translation is missing |
Only text, textarea, richText, slug, blocks and group fields can be localized. A localized slug needs a localized source field, and a shared slug needs a shared one.
localization: {
defaultLocale: "en",
fallback: "default",
},search
Keeps published records in the site search index. Drafts never show up in public results. Guide: Search.
| Name | Type | Required | Description |
|---|---|---|---|
titleField | field name | Yes | The result heading. A text field that is not nullable, or a to-one, non-nullable relation to another searchable content type that lends its title. Guide |
contentFields | field names | Yes | The indexed body, at least one, no duplicates. Text, textarea, richText or slug. Repeatable leaves such as "faq.answer" are allowed |
pathTemplate | string | Yes | The result URL. Starts with /, contains {slug} exactly once, at most 512 characters |
descriptionField | field name | No | Shown in result excerpts. Text, textarea or richText |
authorField | field name | No | A top-level field.user() credited in the index. It does not have to be public |
imageField | field name | No | A shared single field.file() shown as the result image. Must be in publicApi.fields |
context | { field, pathTemplate? } | No | What the record belongs to, for feed sentences. A field.container() with a pathTemplate, or a to-one relation to a searchable content type. Defaults to the titleField relation. Guide |
Every field except authorField must be in publicApi.fields, or a result snippet would leak a private value. Leave the language out of pathTemplate, because your i18n settings add the locale prefix.
search: {
titleField: "title",
descriptionField: "excerpt",
contentFields: ["title", "excerpt"],
pathTemplate: "/articles/{slug}",
authorField: "author",
},visibility
visibility: true adds the nullable hiddenAt and hiddenBy columns, the can_hide permission, the POST /{id}/hide and POST /{id}/unhide staff routes and a Visibility filter in the AdminCP list. A hidden record is left out of every public read while it keeps its status and publishedAt. It has no other options. Guide: Hide content.
publication: true,
visibility: true,reports
reports: true lets signed-in readers report a published record. It registers a report target, adds can_view_reports and can_manage_reports to the moderator catalog, plus can_view_hidden and can_hide with visibility, and needs no migration. Pass { titleField, snapshotFields } to choose what the report's snapshot keeps. Render the public button with ReportButton and contentTarget(manifest.id, definition, record.id). Guide: Enable reports. Options and components: Moderation reference.
publicApi: { path: "blog", fields: ["id", "title", "friendlyUrl", "content"] },
reports: true,thread
Makes records of one content type answer each other under a root record, like forum comments under a topic. It adds the public GET /{path}/thread route and checks every placement on the server. Guide: Threaded replies.
thread: { parent: "parent", root: "topic", maxDepth: 6 },| Name | Type | Required | Default | Description |
|---|---|---|---|---|
parent | field name | Yes | A to-one, nullable field.relation({ self: true }): the record being answered | |
root | field name | Yes | A required, to-one field.relation({ target }) to another content type | |
maxDepth | number | No | 8 | Deepest level a record may sit at, from 1 (a flat list) to 32 |
Both fields must be in publicApi.fields and neither may be in publicWrite.update.fields. thread is not available on a localized content type yet.
| Cause | Message starts with |
|---|---|
parent is not a to-one self relation | thread.parent "x" must be a to-one |
parent is not nullable | thread.parent "x" must be nullable |
root is not a required to-one relation | thread.root "x" must be a required, to-one |
maxDepth out of range | thread.maxDepth must be a whole number from 1 to 32. |
No publicApi | thread needs `publicApi`: the thread route |
parent or root not in publicApi.fields | thread uses "x", so list it in |
parent or root in update.fields | publicWrite.update.fields includes "x". |
| A localized content type | thread is not available on a localized content type yet |
duplication
Adds a Duplicate action and the POST /{id}/duplicate staff route, which copy a record and its translations into a new draft. Pass true, or an object to change the title suffix. Guide: Duplicate content.
| Name | Type | Default | Description |
|---|---|---|---|
titleSuffix | boolean | On when admin.titleField is a field.text() | Adds a localized "(Copy)" to the copy's title. true with any other title field throws |
duplication: true,idStrategy
The type of the primary key. Every foreign key, junction column and translation itemId that points at the record takes the same type. Guide: Record ID strategies.
| Value | Postgres key | In JSON |
|---|---|---|
"serial" (default) | serial | A number |
"uuid" | uuid, DEFAULT gen_random_uuid() | A lowercase string |
"bigint" | bigint GENERATED BY DEFAULT AS IDENTITY | A decimal string |
Any other value throws. Changing the strategy of a content type that already has records needs a hand-written data migration.
idStrategy: "bigint",indexes
Extra database indexes on the base table. The engine already indexes slugs, unique: true text fields, foreign keys, createdAt, updatedAt and (status, publishedAt). A declared index on the same columns replaces the generated one instead of adding a second. Guide: Database and migrations.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
on | string[] | Yes | At least one column, no repeats | |
unique | boolean | No | false | |
name | string | No | {tableName}_{columns}_idx, or _key when unique | snake_case, at most 63 characters |
on accepts shared fields, group leaves such as "syndication.priority", and system columns (id, createdAt, updatedAt, plus status, publishedAt and version when their feature is on). It refuses localized fields, field.blocks(), repeatable leaves, to-many relations and file collections, and whole groups. Two indexes on the same columns throw.
indexes: [{ on: ["syndication.priority"] }],liveEditing
liveEditing: true lets several editors work on one record at once in the AdminCP edit form: field locks, an autosaved shared draft, presence and rich text co-editing. It needs editorial, because locks and drafts are measured against the record version. It adds the /{id}/locks and /{id}/draft staff routes. Guide: Live editing.
editorial: { revisions: { retention: 20 } },
liveEditing: true,AI suggestions are not a defineContentType option. They are set per field with the ai option of field.text() and field.textarea(). See Fields and AI actions.
Which options need which
Each row is a rule defineContentType enforces, with the start of the message it throws (after the [Content Engine] <id>: prefix).
| Option | Needs | Error |
|---|---|---|
publicApi | publication | publicApi needs `publication: true`. |
publicApi.fields | Exactly one top-level slug field | publicApi.fields must expose exactly one slug field |
publicWrite | publicApi | publicWrite needs `publicApi`. |
publicWrite | No localized fields | publicWrite is not available on a localized content type yet. |
search | publication | search needs `publication: true`. |
search | publicApi | search needs `publicApi: { path, fields }`. |
search.*Field, search.contentFields | The field in publicApi.fields | search.titleField names "x", which is not in publicApi.fields. |
delivery | publicApi | delivery needs `publicApi: { path, fields }`. |
delivery on a localized type | "id" in publicApi.fields | delivery on a localized content type needs "id" in publicApi.fields. |
liveEditing | editorial | liveEditing needs `editorial`. |
delivery.redirects | editorial | delivery.redirects needs `editorial`. |
delivery.redirects on a localized type | A localized slug field | delivery.redirects needs a localized slug field on a localized content type, but "slug" is shared. |
delivery.hreflang | localization | delivery.hreflang needs `localization: { defaultLocale }`. |
delivery.seo.* | The field in publicApi.fields | delivery.seo.titleField names "x", which is not in publicApi.fields. |
delivery.seo.fallbackTitleField | delivery.seo.titleField | delivery.seo.fallbackTitleField is set without `titleField`. |
delivery.seo.fallbackDescriptionField | delivery.seo.descriptionField | delivery.seo.fallbackDescriptionField is set without `descriptionField`. |
editorial.preview | publicApi | editorial.preview needs `publicApi: { path, fields }`. |
editorial.scheduling | publication | editorial.scheduling needs `publication: true`. |
visibility | publication | visibility needs `publication: true`. |
duplication.titleSuffix: true | admin.titleField pointing at a field.text() | duplication.titleSuffix needs `admin.titleField` to be a text field |
A localized: true field | localization | Field "title" is `localized: true` but the content type has no `localization: { defaultLocale }` block |
localization | At least one localized: true field | localization is enabled but no field is marked `localized: true`, |
Any block with an enabled key | Nothing: a block is on when present | publicApi.enabled is not an option. |
publication or liveEditing as an object | true or false | publication is `true` or `false`. |
visibility as an object | true or false | visibility is `true` or `false`. |
admin.list.thumbnailField | admin.titleField in admin.list.columns | admin.list.thumbnailField is drawn in the title column, but admin.titleField |
admin.form.sections | No admin.form.fields | admin.form declares both `fields` and `sections`. |
admin.list.defaultOrderBy | A system column or an admin.list.orderableFields entry | admin.list.defaultOrderBy is "x", which is not in admin.list.orderableFields. |
publicApi.defaultOrderBy | An orderable field | publicApi.defaultOrderBy is "x", which is not in publicApi.orderableFields. |
publicApi.searchableFields, filterableFields, orderableFields | The field in publicApi.fields | publicApi.searchableFields includes "x", which is not in publicApi.fields. |
publicApi.people | A shared field.user() | publicApi.people includes "x", which is a "text" field. |
thread | publicApi, with parent and root in its fields | thread needs `publicApi`: the thread route |
Unique across content types
Plugin registration checks these across every installed content type:
| Value | Unique within | Error starts with |
|---|---|---|
id | The app | Duplicate content type id |
| Table names, including generated ones | The database | Table "x" is claimed by both |
| Index names, including generated ones | The database | Index name "x" is used by both |
admin.path | The app | AdminCP path "x" is claimed by both |
permissionModule | The plugin | Permission module "x" is derived by both |
publicApi.path | The plugin | Public path "x" is claimed by both |
publicApi.path with delivery | The app, among delivery types | Delivery path "x" is claimed by both |