Logo VitNode

AdminCP

Customize a Content Engine content type's screens in the VitNode AdminCP, including list columns, search, sorting, form sections, page mode and custom cells, fields and layouts.

Every content type registered in the plugin's src/admin/content.tsx gets an AdminCP list and a create and edit form without any UI code. Most changes are options in the definition's admin block. For anything the options cannot express, you swap one cell, one field or the whole form layout for your own component.

This page uses the example plugin's example.article. Its screen lives at /admin/content/example/articles.

Choose list columns, search and sorting

admin.list drives the list:

plugins/example/src/content/article.ts
admin: {
  path: "example/articles",
  titleField: "title",
  list: {
    columns: [
      "status",
      "title",
      "slug",
      "code",
      "category",
      "author",
      "animation",
      "publishedAt",
      "updatedAt",
    ],
    searchableFields: ["title", "code", "excerpt"],
    orderableFields: ["title", "code", "slug"],
    defaultOrderBy: "updatedAt",
    defaultOrder: "desc",
  },
},
The Articles list in the AdminCP with Status, Title, Slug, Reference code, Category, Author, Animation, Published at and Updated columns and a search box
OptionWhat it does
columnsTable columns, in order. The default is status (with publication), every shared single-value field, then updatedAt.
searchableFieldsFields the search box matches: text, textarea, slug or richText. A localized field matches in any language. The default is every shared text and textarea field. When the list ends up empty, the search box is hidden.
orderableFieldsYour own fields with a sortable header. id, createdAt and updatedAt are always sortable, and so are status, publishedAt (with publication) and version (with editorial), so leave them out. Localized and file fields cannot be sorted.
defaultOrderByInitial sort column. Must be a built-in column or one of orderableFields. Defaults to updatedAt.
defaultOrder"asc" or "desc". Defaults to "desc".
thumbnailFieldA single shared field.file() drawn at the start of the title cell. titleField must be one of the columns.

A to-many field.user() or field.relation() can be a column. People show with their role color and relations as badges, loaded once per page instead of once per row. A gallery (field.file({ multiple: true })), a group or a repeatable cannot. A file column shows a thumbnail and the file name, which is why animation above is a column but not sortable.

Every mistake here, such as an unknown field or a gallery in columns, throws a ContentEngineError when the plugin loads, so you find it before an editor does.

Other admin options

OptionDefaultWhat it does
pathThe id with dots as slashesURL under /admin/content/. Lowercase segments of letters, digits and dashes; cannot end in create or edit.
titleFieldFirst shared text or textarea fieldNames a record in toasts, dialogs and relation pickers. null means the type has no title.
colorFieldnoneA shared field with a color, shown as a swatch next to the title. The blog's categories use it.
navigationtrueSet it to false to keep the screen but drop its sidebar link.
permissionModuleThe id without the plugin partStaff permission module name, for example article for example.article.

Bulk actions

Ticking rows opens a floating bar with Publish, Unpublish and Delete. There is nothing to configure. Publish and Unpublish appear with publication and the can_publish permission, Delete with can_delete. Without any of them, the table has no checkboxes.

Articles list with one row ticked and a floating bar showing 1 selected, Publish, Unpublish and Delete

Each action asks for confirmation and then sends one request per record to the same route the row buttons use, so events, search indexing and revisions behave as if you clicked every row. When some records fail, for example because someone else saved them meanwhile, the rest still go through. A toast says how many were skipped, and those rows stay ticked.

Group form fields into sections

By default the form shows every field except field.blocks(), one after another, in a dialog:

The Create Article dialog with Title, Slug, Reference code, Excerpt and Views fields

To pick and order fields, set admin.form.fields. To group them into titled cards, use admin.form.sections instead:

plugins/example/src/content/article.ts
admin: {
  form: {
    sections: [
      { name: "main", fields: ["title", "slug", "excerpt", "category"] },
      { name: "media", fields: ["animation", "gallery"] },
      { name: "settings", fields: ["code", "author", "featured", "noIndex", "views"] },
    ],
  },
},
  • Sections are the field list, so a field you leave out is not in the form. Declaring both fields and sections throws.
  • Each field belongs to one section, and no section may be empty.
  • A section name is lowercase snake case, because it is part of a translation key.

The heading and an optional description come from the plugin's locale file, at content.<entity>.form.<name>. Without a message, the heading is the humanized name ("Main").

plugins/example/src/locales/en.json
{
  "@vitnode/example": {
    "content": {
      "article": {
        "form": {
          "main": { "title": "Article", "desc": "What readers see first." },
          "media": { "title": "Media" }
        }
      }
    }
  }
}

Open forms on their own page

Forms open in a dialog. A long form reads better on a page, which is what the blog does for its articles:

plugins/blog/src/content/post.ts
admin: {
  path: "blog/articles",
  create: { mode: "page" },
  edit: { mode: "page" },
},

The create form moves to /admin/content/blog/articles/create and the edit form to /admin/content/blog/articles/{id}/edit. You can set the two modes independently.

Replace a field or a cell

Wrap a registration in contentTypeAdmin to swap the component of one form field or one list column. It adds nothing at runtime; it only checks that the keys are real field names. The blog plugin does this for its category color:

plugins/blog/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 { BlogCategoryColorCell } from "@/views/admin/category/color-cell";
import { BlogCategoryColorField } from "@/views/admin/category/color-field";

import { blogCategoryNav } from "./nav";

export const adminContent = {
  pluginId: CONFIG_PLUGIN.pluginId,
  contentTypes: [
    contentTypeAdmin({
      ...blogCategoryNav,
      fields: {
        color: { component: BlogCategoryColorField },
      },
      columns: {
        color: { cell: BlogCategoryColorCell },
      },
    }),
  ],
} satisfies ContentFrontendPluginSource;

A cell receives the row, typed by the definition:

plugins/blog/src/views/admin/category/color-cell.tsx
import type { ContentCellProps } from "@vitnode/core/lib/plugin";

import { useTranslations } from "use-intl";

import type { blogCategoryContentType } from "@/content/category";

export const BlogCategoryColorCell = ({
  row,
}: ContentCellProps<typeof blogCategoryContentType>) => {
  const t = useTranslations("@vitnode/blog.admin.category");

  if (!row.color) {
    return <span className="text-muted-foreground">{t("color.none")}</span>;
  }

  return (
    <div className="flex items-center gap-2">
      <span
        aria-hidden
        className="size-4 shrink-0 rounded-full border"
        style={{ backgroundColor: row.color }}
      />
      <span className="text-muted-foreground truncate text-sm">
        {row.color}
      </span>
    </div>
  );
};

A field component receives ItemAutoFormComponentProps from @vitnode/core/components/form/auto-form and usually wraps an AutoForm field with your own label or control.

Add skeleton when the component is bigger than its field kind suggests, so the loading placeholder matches. The blog's content field renders a rich editor and sets skeleton: "editor". The options are "editor", "input", "list", "media", "switch" and "textarea".

Replace the whole form layout

For a layout the options cannot express, such as a main column with a sidebar, pass forms.layout to contentTypeAdmin. A custom layout is used only in page mode, so set create.mode and edit.mode to "page" first.

plugins/example/src/views/admin/article/form-layout.tsx
import type { ContentFormLayoutProps } from "@vitnode/core/lib/plugin";

import {
  ContentFormActions,
  ContentFormField,
  ContentFormHeader,
  ContentFormLayoutGrid,
  ContentFormMain,
  ContentFormRemainingFields,
  ContentFormSection,
  ContentFormSidebar,
  ContentFormStatus,
} from "@vitnode/core/content/admin-form";

const SIDEBAR_FIELDS = ["category", "author", "featured", "noIndex"];

export const ArticleFormLayout = ({ mode }: ContentFormLayoutProps) => (
  <>
    <ContentFormHeader>
      <ContentFormActions />
    </ContentFormHeader>

    <ContentFormLayoutGrid>
      <ContentFormMain>
        <ContentFormSection>
          <ContentFormRemainingFields exclude={SIDEBAR_FIELDS} />
        </ContentFormSection>
      </ContentFormMain>

      <ContentFormSidebar>
        {mode === "edit" ? <ContentFormStatus /> : null}
        {SIDEBAR_FIELDS.map(name => (
          <ContentFormField key={name} name={name} />
        ))}
      </ContentFormSidebar>
    </ContentFormLayoutGrid>
  </>
);
plugins/example/src/admin/content.tsx
contentTypeAdmin({
  ...exampleArticleNav,
  forms: { layout: ArticleFormLayout }, 
}),

forms.create.layout and forms.edit.layout override the shared one per action. ContentFormRemainingFields renders every form field you did not place yourself. In development, the browser console warns about fields the layout never rendered and about a missing ContentFormHeader, which holds the title and the back link.

Show a matching skeleton

A loading screen that looks nothing like the form is the visual version of a jump scare. The Content Engine avoids it by rendering your layout twice: first in skeleton mode while the record loads, then for real. In skeleton mode, useContentForm().skeleton is true and every <ContentFormField name="..." /> draws a placeholder instead of the field.

So a layout built only from ContentFormField, ContentFormRemainingFields and the other ContentForm* pieces already has a matching skeleton. You only need two extra steps when your form draws more than fields.

Size a field's placeholder

Each placeholder is sized from the field's kind. When your field component is bigger than its kind suggests, set skeleton next to component, as described in Replace a field or a cell:

plugins/blog/src/admin/content.tsx
fields: {
  content: { component: BlogArticleEditorField, skeleton: "editor" },
  excerpt: { component: ArticleExcerptField, skeleton: "textarea" },
},

Draw a skeleton for a custom layout

A layout with its own header, a large title or a cover image should draw a skeleton with the same wrappers, gaps and widths. Return it while skeleton is true, and use the same component as the Suspense fallback when the real editor is lazy-loaded. Then the reload goes skeleton → form with nothing in between.

plugins/blog/src/views/admin/article/form-layout.tsx
import type { ContentFormLayoutProps } from "@vitnode/core/lib/plugin";

import { Skeleton } from "@vitnode/core/components/ui/skeleton";
import { useContentForm } from "@vitnode/core/content/admin-form";
import React from "react";

const loadArticleEditor = async () => await import("./editor/article-editor");

const ArticleEditor = React.lazy(async () =>
  loadArticleEditor().then(module => ({ default: module.ArticleEditor })),
);

const ArticleEditorSkeleton = () => {
  const { fieldNames, markHeaderRendered, markRendered, mode, skeleton } =
    useContentForm();

  markHeaderRendered?.();
  if (!skeleton) for (const name of fieldNames) markRendered?.(name);

  React.useEffect(() => {
    void loadArticleEditor();
  }, []);

  return (
    <div aria-busy="true" className="flex flex-col">
      <div className="flex min-h-14 items-center gap-2 border-b px-4 py-2">
        <Skeleton className="h-8 w-36" />
        <div className="flex-1" />
        <Skeleton className="h-9 w-36" />
      </div>
      <div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-4 py-8">
        {mode === "edit" ? (
          <Skeleton className="aspect-2/1 w-full rounded-xl" />
        ) : (
          <Skeleton className="h-36 w-full rounded-xl" />
        )}
        <Skeleton className="h-10 w-3/4" />
        <Skeleton className="h-96 w-full rounded-lg" />
      </div>
    </div>
  );
};

export const BlogArticleFormLayout = ({
  contentTypeId,
  itemId,
}: ContentFormLayoutProps) => {
  const { skeleton } = useContentForm();

  if (skeleton) return <ArticleEditorSkeleton />;

  return (
    <React.Suspense fallback={<ArticleEditorSkeleton />}>
      <ArticleEditor contentTypeId={contentTypeId} itemId={itemId} />
    </React.Suspense>
  );
};

A few things that keep it smooth:

  • Copy the real wrappers. Same outer div, same padding, same max-w-*, same breakpoints. A skeleton that is 20 pixels shorter still makes the page jump.
  • Branch on mode. The create form often looks different, for example an empty upload area instead of a cover image and other buttons in the header.
  • Call markHeaderRendered() when the skeleton draws its own header, so the development warning about a missing ContentFormHeader stays quiet.
  • Mark the fields as rendered in the fallback. While the lazy editor loads, the real form is up, so the skeleton tells it that the layout will place every field. Without it, the console warns about missing fields for a moment.
  • Start loading the editor from the skeleton. The useEffect above fetches the editor's code while the record is still loading, so the fallback is rarely seen at all.
  • Use Skeleton from @vitnode/core/components/ui/skeleton and the Tailwind spacing scale, so placeholders match the theme in light and dark mode.

Check the result

Save the definition, restart pnpm dev and open AdminCP → Example → Articles. The API validates sorting and form input against the same definition, so the restart makes sure both sides read the new admin options.

Next, give the screens the permissions they need in Production checklist, or read every option in the reference.