Documentation

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:

plugins/newsroom/src/api/builders.ts
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:

plugins/newsroom/src/content/article.ts
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"],
    },
  },
});
  • id is plugin.entity. The part after the dot, article, names the translations and the permissions.
  • tableName is the Postgres table.
  • The engine fills slug from the title when you leave it empty, and keeps it when the title changes later, so links keep working.
  • body is rich text. The AdminCP shows an editor, and the database stores a JSON document.
  • admin.path puts the screen at /admin/content/newsroom/articles. list.columns may name your fields and the system columns id, createdAt and updatedAt.

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:

plugins/newsroom/src/database/articles.ts
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:

plugins/newsroom/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:

plugins/newsroom/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:

plugins/newsroom/src/admin/nav.tsx
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:

plugins/newsroom/src/admin/content.tsx
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:

plugins/newsroom/src/locales/en.json
{
  "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"
}
  • title names the sidebar group. Under content.article, buttons such as Create Article use label, and title and desc head 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:

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

While 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:

apps/api/migrations/<timestamp>_<name>/migration.sql
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:

Create Article dialog with the title Welcome to the newsroom, an empty optional Slug input and a Body editor with one sentence

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

AdminCP Articles list under Newsroom with Title, Slug and Updated columns and two articles, Release notes for October and Welcome to the newsroom
  • 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.