Logo VitNode

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:

plugins/example/src/content/article.ts
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:

  • defineContentType checks one content type when its module is imported. Anything wrong in that object throws a ContentEngineError.
  • 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

NameTypeRequiredDefaultDescription
idstringYesplugin.entity, such as example.article. See Names
tableNamestringYesPostgres table name, such as example_articles
fieldsRecord<string, field>YesAt least one field built with a field.* helper
adminadminNo{}AdminCP path, list, form and title
publicationbooleanNooffDraft and published states
publicApipublicApiNooffRead-only public routes
deliverytrue | deliveryNooffPublic page URLs, SEO metadata, redirects and sitemap
editorialtrue | editorialNooffRevisions, preview links and scheduling
liveEditingbooleanNofalseLive editing in the AdminCP. Needs editorial
localizationlocalizationNooffA translation table for localized: true fields
searchsearchNooffSite search indexing
indexesindexesNo[]Extra database indexes

A feature block is on when it is present. publicApi, localization and search take an options object. publication and liveEditing are true, and editorial and delivery 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

NameRule
idMatches /^[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
tableNamesnake_case, starts with a letter, at most 63 characters
Field namescamelCase, start with a lowercase letter
Reserved fieldsid, createdAt and updatedAt always. status and publishedAt with publication. version with editorial
Reserved for the list routecursor, first, last, order, orderBy and search, because they are query parameters of the generated list route. Checked at plugin registration
Localized fieldsCannot 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.article becomes article. Strings live under {pluginId}.content.{entityKey}, for example @vitnode/example.content.article.
  • The permission module is the entity key with anything outside a-z0-9 turned into _, so example.localized-article becomes localized_article. Permissions are named @vitnode/example:article:can_view. Override it with admin.permissionModule.

Every content type gets can_view, can_create, can_edit and can_delete. publication adds can_publish and editorial adds can_restore.

admin

Controls the generated AdminCP screens. Guide: Customize the AdminCP.

NameTypeDefaultDescription
pathstringThe 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
permissionModulestringDerived from the id, see NamesMiddle part of the permission names. snake_case
titleFieldfield name | nullFirst shared text or textarea field, then first localized oneNames a record in headings, pickers and toasts. A localized field is shown in the reader's language. null means no title
colorFieldfield name | nullnullA 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
navigationbooleantruefalse hides the AdminCP sidebar entry
list.columnsfield namesstatus (with publication), every shared single-column field, updatedAtTable columns, in order. Accepts localized fields, system columns and to-many relation or user fields
list.searchableFieldsfield namesShared text and textarea fieldsFields the search box matches: text, textarea, richText or slug
list.orderableFieldsfield names[]Sortable shared fields. System columns are always sortable and need no entry. No localized or file fields
list.defaultOrderBycolumn name"updatedAt"A system column or one of list.orderableFields
list.defaultOrder"asc" | "desc""desc"
list.thumbnailFieldfield nameA shared single field.file() drawn in the title cell. titleField must be one of list.columns
form.fieldsfield namesEvery field except field.blocks(), in declaration orderFields 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.

plugins/blog/src/content/post.ts
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.

plugins/example/src/content/article.ts
publication: true,

publicApi

Generates read-only routes at /api/{pluginId}/content/{path} that return published records only. Guide: Publish content with a public API.

NameTypeRequiredDefaultDescription
pathstringYesOne URL segment matching /^[a-z][a-z0-9-]*$/, at most 64 characters, not admin
fieldsfield namesYesThe allowlist, with no wildcard. Must contain exactly one top-level slug field. No duplicates
searchableFieldsfield namesNo[]Matched by ?search=. Text, textarea, richText or slug fields. Rich text matches its words only
filterableFieldsfield namesNo[]Each becomes an equality query parameter. Boolean, enum, number, relation, slug or text fields
orderableFieldsfield namesNo[]Accepted by ?orderBy=. publishedAt is always added
defaultOrderByfield nameNo"publishedAt"Must be orderable
defaultOrder"asc" | "desc"No"desc"

What fields accepts:

  • Fields of kind text, textarea, richText, slug, number, boolean, enum, dateTime, file, relation and blocks. A file is returned as its public descriptor, never as the file id.
  • The columns id, createdAt, updatedAt and publishedAt.
  • 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 not field.user(), so an author is never published by accident.

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.

plugins/example/src/content/article.ts
publicApi: {
  path: "articles",
  fields: ["title", "slug", "excerpt", "featured", "category", "noIndex", "publishedAt"],
  searchableFields: ["title", "excerpt"],
  orderableFields: ["publishedAt", "title"],
  filterableFields: ["category", "featured"],
},

delivery

Gives each published record a public page: canonical URL, SEO metadata, redirects from old slugs and a sitemap. Guides: Public pages and SEO.

NameTypeDefaultDescription
pathstring/{publicApi.path}/:slugThe page route. Exactly one :slug, no other parameters or *, not under /admin or /api, at most 512 characters
redirectsbooleanoffOld slugs answer 308 with the current URL
seo.titleFieldtext fieldPage title
seo.fallbackTitleFieldtext fieldUsed when titleField is empty. Needs titleField
seo.descriptionFieldtext, textarea or richText fieldMeta description. Rich text becomes plain text, and the page trims it to 160 characters
seo.fallbackDescriptionFieldtext, textarea or richText fieldUsed when descriptionField is empty. Needs descriptionField
seo.noIndexFieldshared boolean fieldtrue marks the page noindex and leaves it out of the sitemap
seo.openGraph.titleFieldtext fieldog:title
seo.openGraph.descriptionFieldtext, textarea or richText fieldog:description
sitemaptrue | { changeFrequency, priority }offLists published URLs
sitemap.changeFrequency"always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never"
sitemap.prioritynumberFrom 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.

plugins/example/src/content/article.ts
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.

NameTypeDefaultDescription
revisions.retentionnumber50Newest revisions kept per record. A whole number from 1 to 500
previewtrue | { expiresInMinutes, pathTemplate }offSigned preview links for drafts
preview.expiresInMinutesnumber15Link lifetime. A whole number from 1 to 1440
preview.pathTemplatestringYour own preview URL. Starts with /, contains {token} exactly once and no other placeholder, at most 512 characters
schedulingbooleanoffPublish or unpublish at a chosen time
plugins/example/src/content/article.ts
editorial: {
  revisions: { retention: 20 },
  preview: { expiresInMinutes: 30 },
  scheduling: true,
},

localization

Creates a {tableName}_translations table for fields marked localized: true. Guide: Translate content.

NameTypeDefaultDescription
defaultLocalestringRequired. 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.

plugins/example/src/content/localized-article.ts
localization: {
  defaultLocale: "en",
  fallback: "default",
},

Keeps published records in the site search index. Drafts never show up in public results. Guide: Search.

NameTypeRequiredDescription
titleFieldfield nameYesThe result heading. A text field that is not nullable
contentFieldsfield namesYesThe indexed body, at least one, no duplicates. Text, textarea, richText or slug. Repeatable leaves such as "faq.answer" are allowed
pathTemplatestringYesThe result URL. Starts with /, contains {slug} exactly once, at most 512 characters
descriptionFieldfield nameNoShown in result excerpts. Text, textarea or richText
authorFieldfield nameNoA top-level field.user() credited in the index. It does not have to be public

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.

plugins/example/src/content/article.ts
search: {
  titleField: "title",
  descriptionField: "excerpt",
  contentFields: ["title", "excerpt"],
  pathTemplate: "/articles/{slug}",
  authorField: "author",
},

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.

NameTypeRequiredDefaultDescription
onstring[]YesAt least one column, no repeats
uniquebooleanNofalse
namestringNo{tableName}_{columns}_idx, or _key when uniquesnake_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.

plugins/example/src/content/advanced-article.ts
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.

plugins/example/src/content/article.ts
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).

OptionNeedsError
publicApipublicationpublicApi needs `publication: true`.
publicApi.fieldsExactly one top-level slug fieldpublicApi.fields must expose exactly one slug field
searchpublicationsearch needs `publication: true`.
searchpublicApisearch needs `publicApi: { path, fields }`.
search.*Field, search.contentFieldsThe field in publicApi.fieldssearch.titleField names "x", which is not in publicApi.fields.
deliverypublicApidelivery needs `publicApi: { path, fields }`.
delivery on a localized type"id" in publicApi.fieldsdelivery on a localized content type needs "id" in publicApi.fields.
liveEditingeditorialliveEditing needs `editorial`.
delivery.redirectseditorialdelivery.redirects needs `editorial`.
delivery.redirects on a localized typeA localized slug fielddelivery.redirects needs a localized slug field on a localized content type, but "slug" is shared.
delivery.hreflanglocalizationdelivery.hreflang needs `localization: { defaultLocale }`.
delivery.seo.*The field in publicApi.fieldsdelivery.seo.titleField names "x", which is not in publicApi.fields.
delivery.seo.fallbackTitleFielddelivery.seo.titleFielddelivery.seo.fallbackTitleField is set without `titleField`.
delivery.seo.fallbackDescriptionFielddelivery.seo.descriptionFielddelivery.seo.fallbackDescriptionField is set without `descriptionField`.
editorial.previewpublicApieditorial.preview needs `publicApi: { path, fields }`.
editorial.schedulingpublicationeditorial.scheduling needs `publication: true`.
A localized: true fieldlocalizationField "title" is `localized: true` but the content type has no `localization: { defaultLocale }` block
localizationAt least one localized: true fieldlocalization is enabled but no field is marked `localized: true`,
Any block with an enabled keyNothing: a block is on when presentpublicApi.enabled is not an option.
publication or liveEditing as an objecttrue or falsepublication is `true` or `false`.
admin.list.thumbnailFieldadmin.titleField in admin.list.columnsadmin.list.thumbnailField is drawn in the title column, but admin.titleField
admin.form.sectionsNo admin.form.fieldsadmin.form declares both `fields` and `sections`.
admin.list.defaultOrderByA system column or an admin.list.orderableFields entryadmin.list.defaultOrderBy is "x", which is not in admin.list.orderableFields.
publicApi.defaultOrderByAn orderable fieldpublicApi.defaultOrderBy is "x", which is not in publicApi.orderableFields.
publicApi.searchableFields, filterableFields, orderableFieldsThe field in publicApi.fieldspublicApi.searchableFields includes "x", which is not in publicApi.fields.

Unique across content types

Plugin registration checks these across every installed content type:

ValueUnique withinError starts with
idThe appDuplicate content type id
Table names, including generated onesThe databaseTable "x" is claimed by both
Index names, including generated onesThe databaseIndex name "x" is used by both
admin.pathThe appAdminCP path "x" is claimed by both
permissionModuleThe pluginPermission module "x" is derived by both
publicApi.pathThe pluginPublic path "x" is claimed by both
publicApi.path with deliveryThe app, among delivery typesDelivery path "x" is claimed by both