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.
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:
import { createContentModel } from '@vitnode/core/content/server'
import { articleContentType } from '@/content/article'
export const articleContent = createContentModel(articleContentType)
export const example_articles = articleContent.tableExporting 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:
buildContentAdminModulegenerates staff CRUD routes (can_view,can_create,can_edit,can_delete,can_publish).buildContentPublicModulegenerates the public read-only endpoint.
Add the admin module under your plugin's adminModule:
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:
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:
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 AdminNavPluginSource5. 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:
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 ContentFrontendPluginSource6. Configure Package Exports
Ensure plugins/example/package.json exposes the admin navigation and content modules so the Vite plugin can discover them:
{
"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).
{
"@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:
bun run build:plugins && bun run db:migrate9. Open the AdminCP Screen
Start your development server and navigate to:
http://localhost:3000/admin/content/example/articlesThe 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:
- Admin CRUD: Requires staff authentication and permissions.
GET /api/@vitnode/example/admin/content/article - Public API: Returns published records without authentication.
GET /api/@vitnode/example/articles
defineContentType Options
Prop
Type
Learn More
Content Engine Overview
Declare a content type once in TypeScript and get a Postgres table, Zod schemas, CRUD routes, staff permissions, and AdminCP screens.
Field Reference
Complete guide to adding and configuring field types in Content Engine, including Postgres mappings, Zod validation, and AdminCP controls.