Content Engine

Defining a Content Type

Declare a Content Engine content type once to get a Postgres table, typed CRUD routes, and interactive AdminCP screens.

Content Engine eliminates repetitive boilerplate for structured data. From a single TypeScript declaration, it produces a PostgreSQL table, Zod validation schemas, API endpoints, staff permissions, and AdminCP management screens.

This tutorial guides you through building a complete, working content type end-to-end using an article entity in a plugin named @vitnode/example.


Tutorial: Building a Complete Content Type

1. Define the Content Type

Declare your content type in src/content/article.ts using defineContentType and field descriptor helpers.

plugins/example/src/content/article.ts
import { defineContentType, field } from '@vitnode/core/content'

export const articleContentType = defineContentType({
  id: 'example.article',
  tableName: 'example_articles',
  publication: { enabled: true },
  fields: {
    title: field.text({ required: true, minLength: 3, maxLength: 200 }),
    slug: field.slug({ source: 'title' }),
    content: field.textarea({ required: true }),
    views: field.number({ integer: true, defaultValue: 0, min: 0 }),
  },
  admin: {
    path: 'example/articles',
    titleField: 'title',
    form: {
      sections: [
        {
          name: 'main',
          fields: ['title', 'slug', 'content'],
        },
        {
          name: 'settings',
          fields: ['views'],
        },
      ],
    },
    list: {
      columns: ['status', 'title', 'slug', 'views', 'publishedAt', 'updatedAt'],
      searchableFields: ['title', 'slug'],
      orderableFields: ['title', 'views'],
      defaultOrderBy: 'updatedAt',
      defaultOrder: 'desc',
    },
  },
  publicApi: {
    enabled: true,
    path: 'articles',
    fields: ['title', 'slug', 'content', 'views', 'publishedAt'],
    searchableFields: ['title', 'content'],
    orderableFields: ['publishedAt', 'views'],
    defaultOrderBy: 'publishedAt',
    defaultOrder: 'desc',
  },
})

slug uses source: 'title' to automatically generate clean URLs from the article title. admin.form.sections groups fields into titled form cards, while admin.list configures the AdminCP data table.

2. Create the Database Model and Export Tables

Compile the definition into a Drizzle model with createContentModel in src/database/articles.ts. Drizzle Kit scans the built plugin exports to generate migrations, so you must export the generated table:

plugins/example/src/database/articles.ts
import { createContentModel } from '@vitnode/core/content/server'
import { articleContentType } from '@/content/article'

export const articleContent = createContentModel(articleContentType)

export const example_articles = articleContent.table

Exporting Generated Tables

Always export the compiled Drizzle table (articleContent.table). When localization is enabled, also export articleContent.translationTable. For to-many relations or repeatable fields, export the junction or child tables from articleContent.advancedTables.

3. Register Content Modules in the Plugin API

VitNode provides two helper functions to wire Content Engine endpoints into your Hono API:

  • buildContentAdminModule generates staff CRUD routes (can_view, can_create, can_edit, can_delete, can_publish).
  • buildContentPublicModule generates the public read-only endpoint.

Add the admin module under your plugin's adminModule:

plugins/example/src/api/modules/admin/admin.module.ts
import { buildModule } from '@vitnode/core/api/lib/module'
import { buildContentAdminModule } from '@vitnode/core/content/server'
import { CONFIG_PLUGIN } from '@/const'
import { articleContent } from '@/database/articles'

export const adminModule = buildModule({
  pluginId: CONFIG_PLUGIN.pluginId,
  name: 'admin',
  routes: [],
  modules: [
    buildContentAdminModule({
      pluginId: CONFIG_PLUGIN.pluginId,
      contentTypes: [articleContent],
    }),
  ],
})

Next, add buildContentPublicModule to src/config.api.ts:

plugins/example/src/config.api.ts
import { buildApiPlugin } from '@vitnode/core/api/lib/plugin'
import { buildContentPublicModule } from '@vitnode/core/content/server'
import { adminModule } from '@/api/modules/admin/admin.module'
import { CONFIG_PLUGIN } from '@/const'
import { articleContent } from '@/database/articles'

export const exampleApiPlugin = () =>
  buildApiPlugin({
    pluginId: CONFIG_PLUGIN.pluginId,
    modules: [
      adminModule,
      buildContentPublicModule({
        pluginId: CONFIG_PLUGIN.pluginId,
        contentTypes: [articleContent],
      }),
    ],
  })

4. Register AdminCP Navigation

Create src/admin/nav.tsx to register the sidebar navigation item. This file is kept lightweight so navigation loads without pulling in editing components:

plugins/example/src/admin/nav.tsx
import type { AdminNavPluginSource } from '@vitnode/core/lib/plugin'
import { FileTextIcon } from 'lucide-react'
import { CONFIG_PLUGIN } from '@/const'
import { articleContentType } from '@/content/article'

export const articleNav = {
  definition: articleContentType,
  icon: <FileTextIcon />,
}

export const adminNav = {
  pluginId: CONFIG_PLUGIN.pluginId,
  contentTypes: [articleNav],
} satisfies AdminNavPluginSource

5. Register the AdminCP Content Module

Create src/admin/content.tsx using contentTypeAdmin. The build imports this module lazily when an administrator navigates to the content screen:

plugins/example/src/admin/content.tsx
import type { ContentFrontendPluginSource } from '@vitnode/core/lib/plugin'
import { contentTypeAdmin } from '@vitnode/core/lib/plugin'
import { CONFIG_PLUGIN } from '@/const'
import { articleNav } from './nav'

export const adminContent = {
  pluginId: CONFIG_PLUGIN.pluginId,
  contentTypes: [
    contentTypeAdmin({
      ...articleNav,
    }),
  ],
} satisfies ContentFrontendPluginSource

6. Configure Package Exports

Ensure plugins/example/package.json exposes the admin navigation and content modules so the Vite plugin can discover them:

plugins/example/package.json
{
  "name": "@vitnode/example",
  "exports": {
    "./locales/*.json": "./src/locales/*.json",
    "./*": {
      "import": "./dist/src/*.js",
      "types": "./dist/src/*.d.ts",
      "default": "./dist/src/*.js"
    }
  }
}

7. Add Plugin Translations

Content Engine resolves names, field labels, form sections, and permissions from the plugin's locale file. Entity keys follow the content type ID without the plugin segment (example.article -> article).

plugins/example/src/locales/en.json
{
  "@vitnode/example": {
    "content": {
      "article": {
        "label": "{count, plural, one {Article} other {Articles}}",
        "title": "Articles",
        "desc": "Manage your knowledge base articles.",
        "fields": {
          "title": "Title",
          "slug": "Slug",
          "content": "Content",
          "views": "Views",
          "status": "Status",
          "publishedAt": "Published",
          "updatedAt": "Updated"
        },
        "form": {
          "main": {
            "title": "Article Content",
            "desc": "Primary information and body text."
          },
          "settings": {
            "title": "Settings",
            "desc": "Article statistics and settings."
          }
        }
      }
    }
  },
  "@vitnode/example:article": "Articles",
  "@vitnode/example:article:can_view": "View articles",
  "@vitnode/example:article:can_create": "Create articles",
  "@vitnode/example:article:can_edit": "Edit articles",
  "@vitnode/example:article:can_delete": "Delete articles",
  "@vitnode/example:article:can_publish": "Publish and unpublish articles"
}

8. Build Plugins & Run Migrations

Compile your TypeScript definitions and apply the generated migration to PostgreSQL:

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

9. Open the AdminCP Screen

Start your development server and navigate to:

http://localhost:3000/admin/content/example/articles

The AdminCP displays a full data table with column headers, sorting, search inputs, and status badges. Click Create article, fill in the title and content, and click Save. The new article appears in the list, and a toast notification confirms the change.

10. Verify the Generated API

Test the generated routes:

  1. Admin CRUD: Requires staff authentication and permissions.
    GET /api/@vitnode/example/admin/content/article
  2. Public API: Returns published records without authentication.
    GET /api/@vitnode/example/articles

defineContentType Options

Prop

Type

Learn More