First content type
Build your first VitNode Content Engine content type, a news article with a title, slug and body, and create and edit articles in the AdminCP.
This tutorial starts a small newsroom with one article content type, which has a title, a slug and a body. By the end, staff create and edit articles in the AdminCP, and you have the table, the staff API and the staff permissions without writing any of them by hand.
The next tutorials keep building the same article: Drafts & publishing, Public API, Content list and Content page.
Before you begin
You need a plugin your app already loads. This tutorial uses one named newsroom, made with Create a plugin, so its plugin id is newsroom and its files live in plugins/newsroom/. Use your own plugin's name wherever you see newsroom. You also need a running PostgreSQL database and an AdminCP account.
Bind the Content Engine builders
Edit src/api/builders.ts. The generated file binds the API builders. Add the Content Engine ones next to them, so no file in your plugin has to spell out its plugin id:
import { bindApiPlugin } from "@vitnode/core/api/lib/bind-api-plugin";
import { bindContentPlugin } from "@vitnode/core/content/server";
import { manifest } from "@/manifest.gen";
export const { buildModule, buildRoute } = bindApiPlugin(manifest);
export const { buildContentAdminModule, buildContentPublicModule } =
bindContentPlugin(manifest);vitnode dev and vitnode build write src/manifest.gen.ts from your package.json, and manifest.id is the plugin id.
Define the article
Create src/content/article.ts. This is the complete file:
import { defineContentType, field } from "@vitnode/core/content";
export const articleContentType = defineContentType({
id: "newsroom.article",
tableName: "newsroom_articles",
fields: {
title: field.text({ required: true, minLength: 3, maxLength: 200 }),
slug: field.slug({ source: "title" }),
body: field.richText({ required: true }),
},
admin: {
path: "newsroom/articles",
titleField: "title",
list: {
columns: ["title", "slug", "updatedAt"],
orderableFields: ["title"],
},
},
});idisplugin.entity. The part after the dot,article, names the translations and the permissions.tableNameis the Postgres table.- The engine fills
slugfrom the title when you leave it empty, and keeps it when the title changes later, so links keep working. bodyis rich text. The AdminCP shows an editor, and the database stores a JSON document.admin.pathputs the screen at/admin/content/newsroom/articles.list.columnsmay name your fields and the system columnsid,createdAtandupdatedAt.
Fields lists every field helper.
Create the model
Create src/database/articles.ts. createContentModel turns the definition into a Drizzle table, validation schemas and a service:
import { createContentModel } from "@vitnode/core/content/server";
import { articleContentType } from "@/content/article";
export const articleContent = createContentModel(articleContentType);
export const newsroom_articles = articleContent.table;Export the table. Migrations find tables through the exports of your plugin's built src/database/*.js files, so a table nobody exports never gets created.
Register the staff API routes
Create src/api/modules/admin/admin.module.ts:
import { buildContentAdminModule, buildModule } from "@/api/builders";
import { articleContent } from "@/database/articles";
export const adminModule = buildModule({
name: "admin",
routes: [],
modules: [buildContentAdminModule({ contentTypes: [articleContent] })],
});Then add it to the plugin's API. This is an excerpt of src/config.api.ts:
import { adminModule } from "./api/modules/admin/admin.module";
import { helloModule } from "./api/modules/hello/hello.module";
export const newsroomApiPlugin = () =>
buildApiPlugin({
manifest,
modules: [helloModule, adminModule],
});The routes live under /api/newsroom/admin/content/article and answer only staff signed in to the AdminCP. Keep the module named admin, because the API checks the AdminCP session on paths that contain /admin/.
Add the AdminCP screen
Create src/admin/nav.tsx with the sidebar link:
import { NewspaperIcon } from "lucide-react";
import { articleContentType } from "@/content/article";
import { manifest } from "@/manifest.gen";
export const adminNav = {
pluginId: manifest.id,
contentTypes: [{ definition: articleContentType, icon: <NewspaperIcon /> }],
};Create src/admin/content.tsx and reuse the same list for the screen:
export { adminNav as adminContent } from "./nav";VitNode finds both files by their names and exports. They are separate because the sidebar loads on every AdminCP page, while the screen and its forms load only when someone opens it. AdminCP shows how to customize the list and the form later.
Add the labels
Edit src/locales/en.json. The home keys came with the generated plugin. Add everything else:
{
"newsroom": {
"home": {
"api": "Your plugin's API answered:",
"desc": "This page ships inside the plugin and is served by the app that installed it.",
"title": "Hello from your plugin"
},
"title": "Newsroom",
"content": {
"article": {
"label": "{count, plural, one {Article} other {Articles}}",
"title": "Articles",
"desc": "News and updates for your community.",
"fields": {
"title": "Title",
"slug": "Slug",
"body": "Body",
"updatedAt": "Updated"
}
}
}
},
"newsroom:article": "Articles",
"newsroom:article:can_view": "View articles",
"newsroom:article:can_create": "Create articles",
"newsroom:article:can_edit": "Edit articles",
"newsroom:article:can_delete": "Delete articles"
}titlenames the sidebar group. Undercontent.article, buttons such as Create Article uselabel, andtitleanddeschead the list.- The
newsroom:article:*keys label the four staff permissions the Content Engine generated. A missing key shows the raw permission id.
Build and migrate
Run these from the workspace root. db:migrate reads your plugin's built files, so build first:
bun run build:plugins
bun run db:migrateWhile pnpm dev runs, it rebuilds the plugin on every save, so db:migrate alone is enough. The command writes a migration to apps/api/migrations/ and applies it:
CREATE TABLE "newsroom_articles" (
"id" serial PRIMARY KEY,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"title" varchar(200) NOT NULL,
"slug" varchar(160) NOT NULL,
"body" jsonb NOT NULL
);
CREATE UNIQUE INDEX "newsroom_articles_slug_key" ON "newsroom_articles" ("slug");You declared three fields, and the engine added the id and the timestamps. Commit the migration with your code. Migrations covers what else it generates.
Create an article
Start the app with pnpm dev and open AdminCP ā Newsroom ā Articles. Click Create Article, enter the title Welcome to the newsroom, leave Slug empty and write a sentence in Body:

Click Create. A toast confirms it and the list shows the new row with the slug welcome-to-the-newsroom. Create a second article, Release notes for October. The next tutorials use both.
Check the result

- The list shows both articles with the columns from
list.columns. Click Title to sort, or type in the search box. - Click the pencil on a row, change the body and save. The edit opens the same generated form.
- In AdminCP ā Staff ā Administrators, a restricted administrator now has a Newsroom ā Articles group with your four permission labels. Every generated route checks them.
Next, keep unfinished articles private with Drafts & publishing.