# Introduction (/docs/brand)
The Brand section collects what makes VitNode look like VitNode, so your
community, plugin or blog post can use it without guessing. Everything here
ships in `@vitnode/core` or downloads straight from the page.
## What's here [#whats-here]
- **Tabby, the pet.** A striped hexagon cat with 14 states, from `hello` to
`dizzy`. Its body is the logo's hexagon and its colors are the logo's blues,
so it never looks off-brand next to the mark.
- **The palette.** Tabby and the logo use the same blues as the UI's primary
color, documented with every other token on the Colors page.
## Related [#related]
# Pet (/docs/brand/pet)
Tabby is VitNode's pet: a striped hexagon cat that ships as one React component
with 14 states. Drop it into an empty state, a loading screen or an error page
and the page instantly feels less like a stack trace.
## Use the pet [#use-the-pet]
Import `Pet` from core and pick a `state`. The pet scales to its box, so size it
with Tailwind classes. Keep roughly a 7:8 width-to-height ratio.
```tsx title="welcome.tsx"
import { Pet } from '@vitnode/core/components/pet/pet'
export const Welcome = () =>
```
## States [#states]
Every card has three buttons:
- **Copy** puts the SVG markup on your clipboard, ready to paste into code or a
design tool.
- **SVG** downloads an animated file. The animation styles travel inside it, so
Tabby keeps moving wherever you open it.
- **PNG** downloads a still, 1024 px wide image with a transparent background.
| State | Use it for |
| ----------- | ------------------------------ |
| `idle` | Default pose |
| `hello` | Onboarding and welcome screens |
| `coding` | Docs and developer pages |
| `coffee` | Breaks and quiet days |
| `love` | Thank-you screens |
| `music` | Media and playlists |
| `sleeping` | Idle or maintenance mode |
| `thinking` | Loading states |
| `celebrate` | Success and first publish |
| `searching` | Empty search results |
| `offline` | Lost connections |
| `oops` | 404 pages |
| `dizzy` | 500 errors |
| `building` | Maintenance and coming soon |
## Error pages [#error-pages]
`ErrorContent` draws the status code in huge digits and lets Tabby stand in for
the middle one: a 404 shows `oops` between the two 4s, and a 500 shows `dizzy`
between the 5 and the 0. The built-in pages need no extra code. Other codes, such
as 403, keep all three digits and skip the pet. Screen readers hear the code and
title together, not the decorative digits.
## Props [#props]
## Accessibility [#accessibility]
- The pet is decorative by default (`aria-hidden`). When it is the only thing
that explains the screen, pass a `label` and it becomes an image with that
name.
- Only parts move: tail, ears, blinks, paws and props. The body never floats.
- All animation stops when the visitor turns on reduced motion.
## Related [#related]
# AdminCP (/docs/content-engine/admincp)
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 [#choose-list-columns-search-and-sorting]
`admin.list` drives the list:
```ts title="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",
},
},
```
| Option | What it does |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `columns` | Table columns, in order. The default is `status` (with publication), every shared single-value field, then `updatedAt`. |
| `searchableFields` | Fields 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. |
| `orderableFields` | Your 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. |
| `defaultOrderBy` | Initial sort column. Must be a built-in column or one of `orderableFields`. Defaults to `updatedAt`. |
| `defaultOrder` | `"asc"` or `"desc"`. Defaults to `"desc"`. |
| `thumbnailField` | A 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 [#other-admin-options]
| Option | Default | What it does |
| ------------------ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `path` | The id with dots as slashes | URL under `/admin/content/`. Lowercase segments of letters, digits and dashes; cannot end in `create` or `edit`. |
| `titleField` | First shared text or textarea field | Names a record in toasts, dialogs and relation pickers. `null` means the type has no title. |
| `colorField` | none | A shared field with a color, shown as a swatch next to the title. The blog's categories use it. |
| `navigation` | `true` | Set it to `false` to keep the screen but drop its sidebar link. |
| `permissionModule` | The id without the plugin part | Staff permission module name, for example `article` for `example.article`. |
## Bulk actions [#bulk-actions]
Ticking rows opens a floating bar with two menus and a **Delete** button. **Status** holds Publish and Unpublish, **Visibility** holds Hide and Unhide. Each option says how many of the ticked rows it applies to, such as **2 of 3**, and only those rows change: hiding two visible articles and one already hidden sends two requests, not three. An option that fits none of them is greyed out.
There is nothing to configure. **Status** appears with `publication` and the `can_publish` permission, **Visibility** with [`visibility`](/docs/content-engine/content-visibility) and `can_hide`, **Delete** with `can_delete`. Without any of them, the table has no checkboxes. On a phone the bar shrinks to icons.
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.
## Duplicate and hide records [#duplicate-and-hide-records]
The **More actions** menu of each row collects the less frequent actions. Two of them come from options in the definition:
- **Duplicate** appears with [`duplication`](/docs/content-engine/duplicate-content), for staff with `can_create` and `can_view`. It makes a draft copy and opens it.
- **Hide** or **Unhide** appears with [`visibility`](/docs/content-engine/content-visibility), for staff with `can_hide`. A hidden record gets an eye-off icon before its title, with a tooltip such as **Article hidden**, and the toolbar gets a **Visibility** filter with **All**, **Visible** and **Hidden**.
Each action asks for confirmation, refreshes the list and shows a toast. On the edit page, `ContentFormActions` adds a **Hide** or **Unhide** button next to **Save changes**, and `ContentFormStatus` shows the same **Hidden** badge with a short explanation.
## Group form fields into sections [#group-form-fields-into-sections]
By default the form shows every field except `field.blocks()`, one after another, in a dialog:
To pick and order fields, set `admin.form.fields`. To group them into titled cards, use `admin.form.sections` instead:
```ts title="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..form.`. Without a message, the heading is the humanized name ("Main").
```json title="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 [#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:
```ts title="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 [#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:
```tsx title="plugins/blog/src/admin/content.tsx"
import type { ContentFrontendPluginSource } from "@vitnode/core/lib/plugin";
import { contentTypeAdmin } from "@vitnode/core/lib/plugin";
import { manifest } from "@/manifest.gen";
import { BlogCategoryColorCell } from "@/views/admin/category/color-cell";
import { BlogCategoryColorField } from "@/views/admin/category/color-field";
import { blogCategoryNav } from "./nav";
export const adminContent = {
pluginId: manifest.id,
contentTypes: [
contentTypeAdmin({
...blogCategoryNav,
fields: {
color: { component: BlogCategoryColorField },
},
columns: {
color: { cell: BlogCategoryColorCell },
},
}),
],
} satisfies ContentFrontendPluginSource;
```
A cell receives the row, typed by the definition:
```tsx title="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) => {
const t = useTranslations("@vitnode/blog.admin.category");
if (!row.color) {
return {t("color.none")};
}
return (
{row.color}
);
};
```
A field component receives `ItemAutoFormComponentProps` from `@vitnode/core/components/form/auto-form` and usually wraps an [AutoForm](/docs/ui/auto-form) 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 [#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.
```tsx title="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) => (
<>
{mode === "edit" ? : null}
{SIDEBAR_FIELDS.map(name => (
))}
>
);
```
```tsx title="plugins/example/src/admin/content.tsx"
contentTypeAdmin({
...exampleArticleNav,
forms: { layout: ArticleFormLayout }, // [!code ++]
}),
```
`forms.create.layout` and `forms.edit.layout` override the shared one per action. For a content type with [`visibility`](/docs/content-engine/content-visibility), `ContentFormHiddenIcon`, `ContentFormHiddenNotice` and `ContentFormVisibilityToggle` from the same import place the icon, the explanation and the button yourself. The blog's editor puts `ContentFormHiddenIcon` before the title and `ContentFormHiddenNotice` under its header. For a one-row header, `ContentFormPublicationLabel` shows **Draft** or **Published** as plain text (hover it for the publish date), and `ContentFormSaveMenu` is **Save changes** with a ▾ menu holding **Publish** or **Unpublish** and **Hide** or **Unhide**, each behind the usual confirmation. `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 [#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 ``
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 [#size-a-fields-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](#replace-a-field-or-a-cell):
```tsx title="plugins/blog/src/admin/content.tsx"
fields: {
content: { component: BlogArticleEditorField, skeleton: "editor" },
excerpt: { component: ArticleExcerptField, skeleton: "textarea" },
},
```
### Draw a skeleton for a custom layout [#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.
```tsx title="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 (
{mode === "edit" ? (
) : (
)}
);
};
export const BlogArticleFormLayout = ({
contentTypeId,
itemId,
}: ContentFormLayoutProps) => {
const { skeleton } = useContentForm();
if (skeleton) return ;
return (
}>
);
};
```
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 [#check-the-result]
Save the definition and open **AdminCP → Example → Articles**. `pnpm dev` reloads both the screens and the API, which validates sorting and form input against the same definition.
Next, give the screens the permissions they need in [Production checklist](/docs/content-engine/production), or read every option in the [reference](/docs/content-engine/reference).
# Approvals (/docs/content-engine/approvals)
When a container's [publishing policy](/docs/content-engine/container-policies) says `publish: "approve"` or `edits: "approve"`, a member's post does not go live straight away. The server stores a **submission**, a moderator decides, and only an approval changes what readers see. This page explains what waits where, what the author sees, and what a decision does. The moderator's side is in [Review approvals](/docs/dev/moderation/review-approvals).
## What waits, and where [#what-waits-and-where]
The server decides from the effective policy of the record's container. The client never sends a status.
| The author | What is stored |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
| Creates a topic in an `approve` forum | A `draft` row plus a `create` submission. Nothing public can reach a draft |
| Edits a published topic, with `edits: "approve"` | An `edit` submission holding the proposed values. The live row and its `version` do not change |
| Edits again while that proposal is pending | A new proposal. The old one becomes `superseded`, so a record never has more than one pending submission |
| Edits a draft whose create is pending | The draft is updated in place, and the pending submission follows it |
| Edits a draft whose create was rejected | The draft is updated, and a new `create` submission opens: a resubmission |
A proposal is validated like a normal edit before it is stored, so a moderator never reviews something that could not be saved.
Some people skip the queue. A moderator holding `can_approve` in the container publishes and edits their own posts immediately, and so does a root role. A moderator editing through `can_edit` always applies the edit at once, even to someone else's record. Those permissions are [scoped to containers](/docs/dev/moderation/scoped-permissions#container-scoped-permissions).
AdminCP staff work outside this flow: their edits apply directly, as always.
## What the author sees [#what-the-author-sees]
Every public write response carries `approval`. It is `null` when nothing waits, and otherwise describes the submission:
```json title="PATCH /api/@vitnode/forum/content/topics/42 → 200"
{
"approval": {
"id": 12,
"kind": "edit",
"reason": null,
"status": "pending",
"submittedAt": "2026-10-13T10:02:11.004Z",
"values": { "title": "The editor forgets my draft on Safari", "body": { "type": "doc", "content": [] } }
},
"id": 42,
"slug": "the-editor-forgets-my-draft",
"status": "published",
"publishedAt": "2026-10-13T09:40:02.118Z",
"version": 3,
"values": { "title": "The editor forgets my draft", "body": { "type": "doc", "content": [] } }
}
```
For a proposal, `values` is still the live record and `approval.values` holds what the author asked for, so an edit page can show both.
| null' },
}}
/>
`GET /{id}/edit` returns the record's latest submission when it is pending, or rejected and not replaced yet, so the author's edit page can say "waiting for a moderator" or show the reason. The shape is `zodContentApprovalState` from `@vitnode/core/content`.
To list everything an author sent, call the core moderation module. It returns pending, approved and rejected submissions, newest first, for the signed-in member. Pass `pluginId` to keep only your plugin's submissions. The filter runs before the page is cut, so every page is full and `total` counts only yours:
```ts
import { fetcher } from "@vitnode/core/tanstack/fetcher";
export const fetchMySubmissions = async (page: number) => {
const res = await fetcher({
plugin: "@vitnode/core",
method: "get",
module: "moderation",
path: "/approvals/mine",
args: { query: { page, pluginId: "@vitnode/forum" } },
});
if (!res.ok) throw new Error("Could not load your submissions.");
return await res.json();
};
```
Each item has `kind`, `status`, `title`, `contentTypeId`, `itemId`, the container, `reason` and the dates. A guest gets `401`. The Forum plugin's `pending-page.tsx` renders this list at `/forum/me/pending`.
## Show it on your pages [#show-it-on-your-pages]
Readers never see a pending record, so the author needs to hear what happened. Three places cover it, all from the `approval` object:
1. **After posting.** When `record.approval?.status === "pending"`, send the author to the record's edit page instead of its public page, which answers `404` until approval. See [Public create form](/docs/content-engine/public-write-frontend#when-a-post-waits-for-a-moderator).
2. **On the edit page.** `GET /{id}/edit` returns the pending or rejected submission. Show a notice above the form. The Forum plugin's `topic-edit-page.tsx` does it like this:
```tsx title="plugins/forum/src/pages/topic-edit-page.tsx"
const ApprovalNotice = ({ approval, status }: { approval: ContentApprovalState | null; status: string }) => {
const t = useTranslations("@vitnode/forum.topics.approval");
if (approval?.status === "rejected") {
return (
{t("rejected.title")}
{approval.reason ? t("rejected.reason", { reason: approval.reason }) : t("rejected.desc")}
);
}
if (approval?.status === "pending" && approval.kind === "edit") {
return {t("pending_edit.title")};
}
if (status === "draft" && approval?.status === "pending") {
return {t("pending_create.title")};
}
return null;
};
```
3. **In a "my submissions" list.** `GET /moderation/approvals/mine`, shown above, lists everything the author sent. The forum renders it at `/forum/me/pending`.
For a pending edit, open the form on `approval.values`, the proposal, instead of the live text, so the author keeps editing what they sent.
## What a decision does [#what-a-decision-does]
A moderator approves or rejects from the ModCP or through the [approval routes](/docs/dev/moderation/reference#approval-routes).
- **Approving a create** publishes the draft, which emits the content type's `published` event once. A record a moderator hid stays hidden.
- **Approving an edit** applies the proposal as an ordinary update against the version it was made from. That emits one `updated` event and, on an [editorial](/docs/content-engine/editorial) content type, writes one revision.
- **Rejecting** changes nothing on the record. A new topic stays a draft, a published one keeps its live version, and the optional reason is stored for the author.
Each decision locks the submission first, so two moderators clicking at once get one success. It then locks the record and resolves its container again from the row, so a topic moved to another forum since it was submitted is checked against where it lives now. If the decision cannot go through, the route answers with one of these codes:
| Code | Status | What happened |
| ----------- | ------ | ------------------------------------------------------------------------------------------------- |
| `decided` | `409` | The submission is no longer pending. Someone else decided first, or the author replaced it |
| `stale` | `409` | The record changed after the proposal was made, or the proposal no longer validates. Reject it, and the author can resubmit |
| `gone` | `409` | The record was deleted. The submission is marked `cancelled` |
| `forbidden` | `403` | The record now lives in a container where this moderator has no `can_approve` |
| `notFound` | `404` | No such submission, or one outside the moderator's scope |
Deleting a record cancels its pending submission, from the AdminCP or anywhere else. If staff publish a pending draft from the AdminCP first, approving it later only closes the submission. Changing a container's policy never touches submissions that are already waiting.
## Events [#events]
Opening a submission emits `content.approval.submitted`, and a decision emits `content.approval.approved` or `content.approval.rejected`. They carry the submission id, the content type, the record id, the container, the author and, for decisions, the reviewer. The record's own events are not duplicated: a pending create emits `created` but not `published` until it is approved. Payloads are in [Built-in events](/docs/dev/events/built-in-events#content-approval-events).
A listener on `content.approval.rejected` is a good place to tell the author why their post did not make it, gently.
## Check the result [#check-the-result]
In the AdminCP, set a forum's policy to **Approve** for new topics, as in [Publishing policies](/docs/content-engine/container-policies). Sign in as a member and start a topic there. You land on the topic's edit page with the waiting notice, and the topic stays out of the forum until a moderator approves it in the [ModCP](/docs/dev/moderation/review-approvals).
# Caching (/docs/content-engine/caching)
Without Redis, the Content Engine does not cache public reads. Every request to a [public API](/docs/content-engine/public-api) route queries Postgres, and the response carries no `Cache-Control` header:
```bash
curl -si "http://localhost:3000/api/@vitnode/example/content/articles?first=1" | head -4
```
```text
HTTP/1.1 200
access-control-allow-credentials: true
content-type: application/json
cross-origin-opener-policy: same-origin
```
So a published article shows up on the next request, with nothing to expire.
With [Redis](/docs/dev/cache/api) (`REDIS_URL`), the server also keeps the public list and detail responses for up to 60 seconds. Each entry is keyed by the parsed query, the resolved locale and a generation number per content type. Every content write bumps that generation, so a write still shows up on the next request and a stale entry is never read. A record whose future `publishedAt` comes due without a write can take up to that minute to appear. The language list and the container tree behind `includeDescendants` are cached the same way. There is nothing to configure.
Only responses that look the same to every reader are cached. A [hidden](/docs/content-engine/content-visibility) record shown to its author, editor or moderator (on its page or in a list), a preview link and the delivery routes always go to Postgres.
Add caching where it pays off:
| Where the content is read | Cache with | Expire it with |
| --------------------------------------- | --------------------------------------- | ----------------------------------------------------- |
| A page of your VitNode app | TanStack Query, warmed in the loader | Its `staleTime`, or `invalidateQueries` after a write |
| The HTML of a public page in your app | [Page cache](/docs/dev/cache/pages) | Automatic: every content write invalidates it |
| A separate front end with its own cache | That front end's tag cache | `content.revalidateOrigins` and the cache tags below |
| Your own Hono route | [`c.get("cache")`](/docs/dev/cache/api) | `c.get("cache").delete(key)` after a write |
## Cache a public list in the app [#cache-a-public-list-in-the-app]
Wrap the fetcher call in `queryOptions` and warm it in the route loader, exactly as in [Cache app data](/docs/dev/cache/app). Use `defineRoute` from `@vitnode/core/tanstack/plugin-routes`, because its loader context has the `queryClient`:
```tsx title="latest-articles.tsx"
import { queryOptions } from '@tanstack/react-query'
import { fetcher } from '@vitnode/core/tanstack/fetcher'
import { defineRoute } from '@vitnode/core/tanstack/plugin-routes'
export const latestArticlesQuery = () =>
queryOptions({
queryKey: ['@vitnode/example', 'articles', 'latest'],
queryFn: async () => {
const response = await fetcher({
plugin: '@vitnode/example',
method: 'get',
module: 'content/articles',
path: '/',
args: { query: { first: 5 } },
})
if (response.status !== 200) {
throw new Error(`Loading articles answered ${response.status}.`)
}
const { edges } = await response.json()
return edges
},
})
export const route = defineRoute({
load: async ({ context }) => {
await context.queryClient.query({
...latestArticlesQuery(),
staleTime: 60_000,
})
},
})
```
Each server render starts with an empty query client, so a full page load is always fresh. The `staleTime` only decides how long a visitor's open tab reuses the list during client-side navigation. `staleTime: "static"` keeps it until the tab reloads.
Publishing in the AdminCP refreshes the AdminCP's own list, item and search queries. It cannot reach a visitor's browser, so pick a `staleTime` you are happy to wait out. For your own write routes, call `queryClient.invalidateQueries({ queryKey: ["@vitnode/example", "articles"] })` after the write.
Public routes also send an `x-vitnode-cache-tags` header naming the record they read, such as `content:blog.post:item:3`, or the content type for a list. The page cache stores a page with these tags and invalidates it after every write, hard for a hide, delete, unpublish or slug change. [How cached pages stay fresh](/docs/dev/cache/pages-invalidation#content-engine-invalidation) explains the rules.
## Cache tags [#cache-tags]
When a front end caches public reads under tags, it needs to know which tags a write affects. `@vitnode/core/content` exports pure string builders for that. They read nothing and need no server:
| Builder | Example for `example.article` |
| --------------------------------------------------- | ------------------------------------------------------- |
| `contentPublicListTag(typeId, locale?)` | `content:example.article:list` |
| `contentPublicItemTag(typeId, id, locale?)` | `content:example.article:item:3` |
| `contentPublicSlugTag(typeId, slug, locale?)` | `content:example.article:slug:hello-content-engine` |
| `contentDeliveryTag(typeId, id, locale?)` | `content:example.article:delivery:3` |
| `contentDeliveryRedirectTag(typeId, slug, locale?)` | `content:example.article:redirect:hello-content-engine` |
| `contentDeliverySitemapTag(typeId, locale?)` | `content:example.article:sitemap` |
A locale goes after the kind, so a localized type's Polish list is `content:example.localized-article:list:pl`. An id goes in as text, so a [uuid or bigint](/docs/content-engine/id-strategy) record's item tag carries its exact UUID or decimal string.
`contentInvalidationTags` turns a description of one write into the exact tags to expire. Publishing article `3` returns all six tags above:
```ts title="revalidate.ts"
import { contentInvalidationTags } from '@vitnode/core/content'
const tags = contentInvalidationTags({
contentTypeId: 'example.article',
id: 3,
slugs: ['hello-content-engine'],
wasPublic: false,
isPublic: true,
delivery: { sitemap: { contentChanged: true, indexChanged: true } },
})
```
A write to a record that was a draft before and after returns `[]`, because no public reader ever saw it. On a slug change, pass both slugs so the old URL stops resolving and the new one starts.
## Notify a separate front end [#notify-a-separate-front-end]
If another app renders your content and caches it, list its origin in the API config:
```ts title="apps/api/src/vitnode.api.config.ts"
export const vitNodeApiConfig = buildApiConfig({
content: {
revalidateOrigins: ['https://www.example.com'], // [!code ++]
},
})
```
VitNode then sends a `POST` to `{origin}/api/vitnode/content/revalidate` in these cases:
| Change | Notifies origins |
| ------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| A [scheduled](/docs/content-engine/editorial#schedule-publishing) publish or unpublish runs | Yes |
| A record is [hidden or unhidden](/docs/content-engine/content-visibility) | Yes, when that changes what the public can see |
| A publish, unpublish, edit or delete from the AdminCP or the API | No |
Hiding a draft notifies nobody, because nothing public changed. For the changes that send nothing, a front end that caches must expire entries on its own timer, or from a listener for the [content events](/docs/content-engine/events).
The request carries:
- `Authorization: Bearer `, the same secret that runs cron jobs.
- `x-vitnode-timestamp`, the send time in milliseconds.
- A JSON body with the `contentInvalidationTags` input plus `mode`, which is always `"immediate"` for a scheduled run, a hide and an unhide.
The secret travels with the request, so outside development each origin must use `https`. Plain `http` is allowed only for `localhost` and `127.0.0.1`. VitNode sends nothing while `CRON_SECRET` is unset or still the default. It tries each origin twice and does not retry a `403`, which it reads as a wrong secret.
### Build the revalidate endpoint [#build-the-revalidate-endpoint]
The endpoint lives in your front end, not in VitNode. Before you expire anything, check the bearer in constant time and refuse a timestamp more than 5 minutes off, so a captured request cannot be replayed later:
```ts title="is-trusted-revalidation.ts"
import { createHash, timingSafeEqual } from 'node:crypto'
const MAX_SKEW_MS = 5 * 60 * 1000
const digest = (value: string) => createHash('sha256').update(value).digest()
export const isTrustedRevalidation = (request: Request): boolean => {
const secret = process.env.CRON_SECRET
if (!secret) return false
const bearer = /^Bearer (.+)$/.exec(
request.headers.get('authorization') ?? '',
)?.[1]
if (!bearer || !timingSafeEqual(digest(bearer), digest(secret))) return false
const sentAt = Number(request.headers.get('x-vitnode-timestamp'))
return Number.isFinite(sentAt) && Math.abs(Date.now() - sentAt) <= MAX_SKEW_MS
}
```
Hashing both values first gives `timingSafeEqual` two buffers of the same length. Answer `403` when the check fails. Otherwise pass the body to `contentInvalidationTags`, expire those tags in your cache and answer with any `2xx`.
# Containers on the frontend (/docs/content-engine/container-frontend)
This guide builds the member-facing side of the example plugin's boards: a `/boards` page with the nested board tree, a `/boards/:slug` page that lists the discussions of a board and every board below it, and a board picker on the "Start a discussion" form.
The finished pages are in `plugins/example/src/pages/`, in the `board-*` and `discussion-*` files. The code below is the same, with some styling trimmed.
## Before you begin [#before-you-begin]
You need a container collection with `publicApi`, and a content type whose `field.container()` is public, filterable and writable on create, as set up in [Containers](/docs/content-engine/containers#add-a-container-collection). The example's collection has `publicApi.path: "boards"`, so the fetcher's module is `content/boards`.
The collection's public routes are read-only and need no session:
| Route | Returns |
| ----------------- | ---------------------------------------------------------------------------------------------- |
| `GET /` | `{ items }`: every container in tree order. `?parent=root` or `?parent={id}` returns one level |
| `GET /{idOrSlug}` | `{ container, ancestors, children }`: ancestors root first, direct children in order |
Each item is `{ id, parentId, name, slug, position, depth, metadata }`, where `metadata` holds only `publicApi.fields`. An unknown container or `parent` answers `404`. A numeric segment is read as an id, anything else as a slug.
### Add the routes [#add-the-routes]
Register the two board pages in `src/routes.ts`, and give the create page a `search` parser so it can read `?board=`. A `pendingComponent` is drawn while the loader runs, so import it directly rather than through `lazy()`:
```ts title="plugins/example/src/routes.ts"
// [!code ++:4]
import {
FeedPendingSkeleton,
RoutePendingSkeleton,
} from "@vitnode/core/tanstack/pending";
import { boardSearch } from "./pages/board-search"; // [!code ++]
import { discussionNewSearch } from "./pages/discussion-search"; // [!code ++]
export const routes = definePluginRoutes([
// [!code ++:13]
page("/boards", {
component: lazy(() => import("./pages/boards-page")),
messages: ["@vitnode/example.boards"],
pendingComponent: RoutePendingSkeleton,
}),
page("/boards/:slug", {
component: lazy(() => import("./pages/board-page")),
messages: ["@vitnode/example.boards"],
pendingComponent: FeedPendingSkeleton,
search: boardSearch,
}),
page("/discussions/new", {
component: lazy(() => import("./pages/discussion-new-page")),
messages: ["@vitnode/example.discussions"],
requires: "authenticated",
search: discussionNewSearch, // [!code ++]
}),
]);
```
`boardSearch` keeps a valid `cursor` for paging through discussions, and `discussionNewSearch` keeps a positive `board` id. Both are small functions in `board-search.ts` and `discussion-search.ts`. The strings live under `@vitnode/example.boards` in `src/locales/en.json`.
### Load the tree [#load-the-tree]
Create a helper that reads every board. The list comes back in tree order, flat, so one request loads the whole tree:
```ts title="plugins/example/src/pages/boards-data.ts"
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { manifest } from "@/manifest.gen";
export const fetchBoards = async () => {
const res = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/boards",
path: "/",
args: { query: {} },
});
if (res.status !== 200) throw new Error("Loading the boards failed.");
const { items } = await res.json();
return items;
};
export type Board = Awaited>[number];
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
export const boardColor = (board: Pick): null | string =>
typeof board.metadata.color === "string" &&
HEX_COLOR.test(board.metadata.color)
? board.metadata.color
: null;
```
`Board` is inferred from the API, so `board.metadata.color` is typed as `string | null` from the definition's fields, and `board.metadata.locked` is a compile error because `locked` is not public. `boardColor` accepts only a hex value before it goes into a `style` attribute, because an admin typed it.
### Render the tree [#render-the-tree]
`contentContainerTree` from `@vitnode/core/content` nests a tree-ordered list: each node gets a `children` array. Render it recursively as nested lists inside a labelled `nav`, so screen readers announce the structure:
```tsx title="plugins/example/src/pages/board-tree.tsx"
import type { ContentContainerTreeNode } from "@vitnode/core/content";
import type { Board } from "./boards-data";
import { boardHref } from "./board-search";
import { boardColor } from "./boards-data";
export const BoardColorDot = ({ board }: { board: Board }) => {
const color = boardColor(board);
return (
);
};
const BoardTreeItems = ({
nodes,
}: {
nodes: ContentContainerTreeNode[];
}) => (
);
export const BoardTree = ({
label,
nodes,
}: {
label: string;
nodes: ContentContainerTreeNode[];
}) => (
);
```
`contentContainerTree` keeps the server's order, so siblings appear in the order staff arranged them. It works on any rows with `id` and `parentId`, including the AdminCP records.
### Add the boards page [#add-the-boards-page]
The route's `load` fetches and nests the tree, and the page shows it, or an empty state when no board exists yet:
```tsx title="plugins/example/src/pages/boards-page.tsx"
import { contentContainerTree } from "@vitnode/core/content";
import {
definePluginRoute,
type PluginRoutePageProps,
} from "@vitnode/core/routing";
import { useTranslations } from "use-intl";
import { BoardTree } from "./board-tree";
import { fetchBoards } from "./boards-data";
const loadBoards = async () => ({
tree: contentContainerTree(await fetchBoards()),
});
type BoardsPageData = Awaited>;
export const route = definePluginRoute({
load: loadBoards,
head: ({ t }) => ({
description: t("@vitnode/example.boards.desc"),
title: t("@vitnode/example.boards.title"),
}),
});
const BoardsPage = ({ loaderData }: PluginRoutePageProps) => {
const t = useTranslations("@vitnode/example.boards");
return (
{t("title")}
{loaderData.tree.length === 0 ? (
{t("empty.desc")}
) : (
)}
);
};
export default BoardsPage;
```
The example's page also has a breadcrumb, a **New discussion** button and an `Empty` state with an icon.
### Add the board page [#add-the-board-page]
`/boards/:slug` makes two requests. The detail route returns the board, its ancestors for the breadcrumb and its child boards. The discussions list is filtered by the board, and `includeDescendants: "true"` widens the filter to every board below it, so **Frontend** also lists what was posted in **React**:
```tsx title="plugins/example/src/pages/board-page.tsx"
import { contentPageItem } from "@vitnode/core/tanstack/content";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { z } from "zod";
import { manifest } from "@/manifest.gen";
import type { BoardSearch } from "./board-search";
const zodDiscussionRow = z.object({
id: z.number(),
publishedAt: z.string().nullable(),
slug: z.string().nullable(),
title: z.string(),
});
const loadBoard = async (idOrSlug: string, { cursor }: BoardSearch) => {
const detail = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/boards",
path: "/{idOrSlug}",
args: { params: { idOrSlug: encodeURIComponent(idOrSlug) } },
});
if (detail.status !== 200 && detail.status !== 404) {
throw new Error("Loading the board failed.");
}
// A 404 becomes the route's not-found page.
const { ancestors, children, container } = contentPageItem(
detail.status === 200 ? await detail.json() : null,
);
const filters = {
board: String(container.id),
includeDescendants: "true",
};
const list = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/discussions",
path: "/",
args: {
query: { ...filters, first: "20", ...(cursor ? { cursor } : {}) },
},
});
if (list.status !== 200) throw new Error("Loading the discussions failed.");
const { edges, pageInfo } = await list.json();
return {
ancestors,
children,
container,
discussions: z.array(zodDiscussionRow).parse(edges),
isFirstPage: cursor === undefined,
nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null,
};
};
```
- The filter parameter is named after the field, `board`, and works only because `board` is in `publicApi.filterableFields`. Without `includeDescendants`, it matches that one board.
- Filter parameters are not part of the inferred query type yet, so they are spread in from a small object instead of written as literal keys.
- An unknown board id in the filter answers `404`, not an empty list, so a stale link never looks like an empty board.
- `nextCursor` goes into `?cursor=` for an **Older discussions** link. Pass it back unchanged.
Declare the trail as a breadcrumb group, one crumb per ancestor, and give the route a `notFound` component for the 404:
```tsx title="plugins/example/src/pages/board-page.tsx"
export const route = definePluginRoute({
load: async ({ params, search }) => await loadBoard(params.slug, search),
head: ({ loaderData }) => ({
description: loaderData?.container.metadata.description ?? undefined,
title: loaderData?.container.name,
}),
breadcrumb: {
group: ({ loaderData }) => (
<>
{loaderData.ancestors.map(ancestor => (
{ancestor.name}
))}
{loaderData.container.name}
>
),
},
notFound: BoardNotFound,
});
```
The page renders the children with the same `BoardTree`, the discussions as a list of links, and a **New discussion** link to `/discussions/new?board={container.id}`. See the full component in `board-page.tsx`.
### Let members pick a board [#let-members-pick-a-board]
Load the same tree for the create page and turn it into select options, indented by depth so the select reads like the tree:
```tsx title="plugins/example/src/pages/discussion-new-page.tsx"
const loadBoardOptions = async () =>
(await fetchBoards()).map(board => ({
label: `${"— ".repeat(board.depth - 1)}${board.name}`,
value: String(board.id),
}));
export const route = definePluginRoute({
load: loadBoardOptions,
});
```
Add the board to the form schema. A select works with strings, so the ids become a `z.enum`, preselected from `?board=` when it names a real board:
```tsx title="plugins/example/src/pages/discussion-form.tsx"
const createDiscussionCreateFormSchema = (
t: (
key: "board.required" | "body.required" | "title.max" | "title.min",
) => string,
ids: [string, ...string[]],
selected: string | undefined,
) => {
const board = z.enum(ids, { error: t("board.required") });
return createDiscussionFormSchema(t, {
body: emptyRichTextDocument(),
title: "",
}).extend({
board:
selected !== undefined && ids.includes(selected)
? board.default(selected)
: board,
});
};
```
Render it with `AutoFormSelect` before the title and body fields:
```tsx title="plugins/example/src/pages/discussion-form.tsx"
{
id: "board",
component: props => (
),
},
```
On submit, send the board as a number inside `values`, like every other writable field:
```tsx title="plugins/example/src/pages/discussion-new-page.tsx"
const write = useContentPublicWrite({
fields: ["title", "body", "board"],
uniqueField: "title",
});
const onSubmit: AutoFormOnSubmit = async (
{ board, ...values },
form,
) => {
const record = await write.submit(form, async () =>
await fetcher({
plugin: manifest.id,
method: "post",
module: "content/discussions",
path: "/",
args: { body: { values: { ...values, board: Number(board) } } },
}),
);
// Redirect to the new discussion, as in Public create form.
};
```
The server never trusts the id. It reads the board back inside the write's transaction and checks that it belongs to `example.boards`. A board that was deleted while the form was open, or one from another collection, answers `400` with an issue on the `board` field:
```json
{
"code": "CONTENT_INVALID_INPUT",
"contentTypeId": "example.discussion",
"issues": [
{
"code": "unavailable",
"field": "board",
"message": "The selected container does not exist."
}
]
}
```
Because `"board"` is in `fields`, [`useContentPublicWrite`](/docs/content-engine/public-write-frontend) shows that issue under the board select.
The edit form stays as it was. A container field cannot be in `publicWrite.update.fields`, so moving a discussion to another board is a staff edit in the AdminCP.
## Check the result [#check-the-result]
1. Create **Frontend** with **React** inside it in the AdminCP, then open `/boards`. Both boards show, **React** nested under **Frontend**.
2. Open **React** and click **New discussion**. The form opens with **— React** preselected. Post it.
3. Open `/boards/frontend`. The discussion is listed there too, because of `includeDescendants`, and the breadcrumb ends with **Boards** and **Frontend**.
4. Open `/boards/nope`. The not-found page shows.
## Errors to handle [#errors-to-handle]
| Request | Answer | What the page does |
| ------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------ |
| `GET content/boards/{idOrSlug}` for an unknown board | `404` | `contentPageItem(null)` renders the route's `notFound` |
| `GET content/boards?parent={id}` for an unknown parent | `404` | Treat it like a missing board |
| `GET content/discussions?board={id}` for an unknown board | `404` | Treat it like a missing board |
| `POST content/discussions` with a missing, deleted or foreign board | `400`, issue on `board` | Show the message under the select |
| `POST content/discussions` with no `board` at all | `400`, issue on `board` | The form's own `required` check catches it first |
The [public write status codes](/docs/content-engine/public-write-api#status-codes) cover sign-in, permission and rate-limit answers, which work the same with or without containers.
# Container hierarchy (/docs/content-engine/container-hierarchy)
A container collection is a tree. This page explains the rules every change to that tree follows, whether it comes from the AdminCP or from your own code, and shows how to use the container service on the server. It uses the `example.boards` collection from [Containers](/docs/content-engine/containers).
## Depth [#depth]
`depth` is 1 for a root, 2 for its children, and so on. `hierarchy.maxDepth` caps it and counts roots, so the example's `maxDepth: 3` allows **Frontend → React → Hooks** and nothing below **Hooks**.
- The default is 5, the ceiling is 10.
- `hierarchy: { enabled: false }` keeps every container at the root: a flat, ordered list. Any `parentId` is refused.
- A move checks the whole subtree it carries. Moving a board that has children under a deep parent is refused if the deepest child would cross the limit.
## Ordering [#ordering]
Siblings are ordered by `ordering.mode`:
| Mode | Order | Reordering |
| -------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| `manual` | `position`, the order staff drag them into | Allowed. `position` is zero-based among siblings and kept as `0..n-1` |
| `alphabetical` | `name`, case-insensitive | Ignored. A new or moved container goes last by position, but reads sort by name. Moving to another parent still works |
Both modes break ties by `id`, so two reads of the same tree always agree.
## Moves and reorders [#moves-and-reorders]
One operation covers both: `move(id, { parentId, position })`. `parentId: null` moves to the root. `position` is the index among the new siblings **after** the container has left its old place. So moving the first of three siblings to `position: 2` puts it last.
A move is refused, and changes nothing, when:
| Code | Status | When |
| ----------------- | ------ | --------------------------------------------------------------------------------------- |
| `cycle` | 400 | The new parent is the container itself or one of its descendants |
| `parentNotFound` | 400 | The new parent does not exist in this collection |
| `maxDepth` | 400 | The container or its deepest descendant would be too deep, or the hierarchy is disabled |
| `invalidPosition` | 400 | `position` is negative or past the end of the sibling list |
| `notFound` | 404 | The container does not exist |
The old and the new sibling lists are both renumbered, so positions never have gaps.
## Safe deletion [#safe-deletion]
Deleting never cascades. A container is deleted only when it is empty:
- `hasChildren` (409): it still has child containers. Move or delete them first.
- `hasContent` (409): a record of any content type that uses this collection still points at it. Move or delete those records first.
The database enforces the same rules. Both `parentId` and every `field.container()` column are foreign keys with `ON DELETE RESTRICT`, so a delete can never orphan a board or a discussion, even if another request slips in between the check and the delete. The remaining siblings are renumbered to close the gap.
## Slugs [#slugs]
A slug addresses one container of one collection, so `frontend` can exist once in `example.boards` and once in another collection. A slug you pass is normalized (lowercase ASCII, dashes) and must be free, or the write fails with `slugTaken`. A missing slug is derived from the name, with `-2`, `-3` and so on added until it is free. A slug that normalizes to nothing, such as `"!!!"`, fails with `slugEmpty`.
## Concurrent edits [#concurrent-edits]
Two admins dragging boards at the same moment cannot corrupt the tree. Every write to a collection:
1. Takes a transaction-scoped Postgres advisory lock for that collection, so writes to one collection run one after another. Different collections never wait for each other.
2. Reads the whole tree inside the transaction, so it plans against what the previous write committed.
3. Checks every rule before changing a single row. A refused write leaves the tree exactly as it was.
A collection holds at most 5,000 containers (`limitReached`, 409). The whole tree is read in one query, so a read costs the same at any depth and never runs one query per level.
## Use the service on the server [#use-the-service-on-the-server]
`createContentContainerService` is the one way to read and change a collection. The AdminCP routes and the public routes both use it, so your code gets the same rules, cache expiry and events. Call it inside any Hono handler with the request context:
```ts title="plugins/example/src/api/modules/boards/archive.route.ts"
import { createContentContainerService } from "@vitnode/core/content/server";
import { HTTPException } from "hono/http-exception";
import { z } from "zod";
import { buildRoute } from "@/api/builders";
import { boardContainers } from "@/content/boards";
import { manifest } from "@/manifest.gen";
export const archiveBoardRoute = buildRoute({
adminStaffPermission: { module: "example_boards", permission: "can_edit" },
route: {
method: "post",
path: "/{id}/archive",
description: "Move a board under the Archive board and lock it",
request: { params: z.object({ id: z.coerce.number().int().positive() }) },
responses: {
200: { description: "Board archived" },
404: { description: "Board or Archive not found" },
},
},
handler: async c => {
const { id } = c.req.valid("param");
const boards = createContentContainerService(c, {
definition: boardContainers,
pluginId: manifest.id,
});
const archive = await boards.findBySlug("archive");
if (!archive) {
throw new HTTPException(404, {
message: "Create an Archive board first.",
});
}
const siblings = await boards.children(archive.id);
await boards.move(id, { parentId: archive.id, position: siblings.length });
await boards.update(id, { metadata: { locked: true } });
return c.json({ message: "Board archived." }, 200);
},
});
```
Refused writes throw a `ContentContainerError` with `code`, `status` (400, 404 or 409) and a readable `detail`. The generated AdminCP routes turn it into a response body of `{ code, message }`, typed by `zodContentContainerError`. In your own route, catch it or let it surface as a 500.
| Method | Returns | Behavior |
| -------------------- | --------------------------- | ---------------------------------------------------------------------------------------------- |
| `list()` | `Promise` | Every container, in tree order: each parent, then its subtree |
| `children(parentId)` | `Promise` | Direct children in sibling order. `null` returns the roots |
| `ancestors(id)` | `Promise` | Every container above `id`, root first. Empty for a root or an unknown id |
| `descendantIds(id)` | `Promise` | `id` plus every id below it, or `null` when `id` does not exist |
| `findById(id)` | `Promise` | One container |
| `findBySlug(slug)` | `Promise` | One container by its exact slug |
| `create(input)` | `Promise` | `{ name, slug?, parentId?, position?, metadata? }`. Goes last when `position` is left out |
| `update(id, input)` | `Promise` | `{ name?, slug?, metadata? }`. `metadata` is a partial patch, validated against the definition |
| `move(id, input)` | `Promise` | `{ parentId, position }`, as described in [Moves and reorders](#moves-and-reorders) |
| `delete(id)` | `Promise` | Deletes an empty container, as described in [Safe deletion](#safe-deletion) |
| `toPublic(record)` | `PublicRecord` | Drops every metadata field not in `publicApi.fields` |
A record is `{ id, parentId, name, slug, position, depth, metadata, createdAt, updatedAt }`, with `metadata` typed from the definition's fields. `ContentContainerRecord` and `ContentContainerPublicRecord` from `@vitnode/core/content` name those types.
The tree helpers the service uses are pure and browser-safe, exported from `@vitnode/core/content`: `contentContainerTree` nests a list, `orderContentContainers` sorts rows into tree order with their depth, and `contentContainerAncestors` and `contentContainerSubtreeIds` walk it.
## Events [#events]
Each successful write emits one event after its transaction commits. A write that is refused emits nothing, and an update that changes nothing emits nothing.
| Event | Payload |
| --------------------------- | --------------------------------------------------------------------------- |
| `content.container.created` | `{ definitionId, pluginId, containerId, parentId }` |
| `content.container.updated` | `{ definitionId, pluginId, containerId, parentId, changedFields }` |
| `content.container.moved` | `{ definitionId, pluginId, containerId, parentId, fromParentId, position }` |
| `content.container.deleted` | `{ definitionId, pluginId, containerId, parentId }` |
The names are shared by every collection, so filter on `definitionId`:
```ts title="plugins/example/src/api/lib/events.ts"
import { buildEventListener } from "@vitnode/core/api/lib/events";
export const boardMovedListener = buildEventListener({
event: "content.container.moved",
name: "log-board-moves",
handler: async (c, payload) => {
if (payload.definitionId !== "example.boards") return;
await c
.get("log")
.debug(
`Board ${payload.containerId} moved from ${payload.fromParentId ?? "the root"} to ${payload.parentId ?? "the root"}`,
);
},
});
```
`changedFields` names `name`, `slug` and the metadata fields whose value actually changed, or `policies` when the container's [publishing policies](/docs/content-engine/container-policies) were saved. A reorder among siblings is a `moved` event with the same `fromParentId` and `parentId`. Register listeners as described in [Events](/docs/dev/events). [Built-in events](/docs/dev/events/built-in-events#content-container-events) has the field-by-field payloads.
Every write also expires the [page cache](/docs/content-engine/caching) entries that read the collection, including pages that list content filtered by one of its containers, so a renamed board shows its new name without a manual purge.
Next, build pages on top of the tree in [Containers on the frontend](/docs/content-engine/container-frontend).
# Publishing policies reference (/docs/content-engine/container-policies-api)
Exact names, values and behavior of container publishing policies. For a guided setup, follow [Publishing policies](/docs/content-engine/container-policies). Examples use the Forum plugin's `forum.forums` collection.
## `policies` option [#policies-option]
`defineContentContainer({ policies })` from `@vitnode/core/content`:
```ts title="plugins/forum/src/content/forums.ts"
policies: {
defaults: { publish: "immediate", edits: "immediate", authorEdit: "always" },
contentTypes: {
"forum.comment": { authorEdit: "window", authorEditMinutes: 30 },
},
},
```
| Name | Type | Required | Default | Description |
| -------------- | -------------------------------------- | -------- | ------- | ------------------------------------------------------------------- |
| `defaults` | policy | No | `{}` | Applies to every content type filed in the collection |
| `contentTypes` | `Record` | No | `{}` | Per content type, such as `"forum.comment"`. Wins over `defaults` |
Omit `policies`, or set it to `false`, and the collection does not take part: its content types use `publicWrite.create.status`, owners edit for as long as `update.own` allows, `can_edit` stays unscoped and no `can_approve` exists. `policies: {}` turns the feature on with every key on its fallback.
## Policy keys [#policy-keys]
A policy is a partial object of these keys. Each one is resolved on its own.
| Key | Values | Fallback | Description |
| ------------------- | --------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `create` | `"members"` \| `"moderators"` | `"members"` | Who may create. `"moderators"` means `can_edit` holders in the container |
| `publish` | `"immediate"` \| `"approve"` | From `publicWrite.create.status`: `"published"` → `"immediate"`, `"draft"` → `"approve"` | Whether a new record goes live or waits as a draft with a submission |
| `edits` | `"immediate"` \| `"approve"` | `"immediate"` | Whether an author's edit of a published record applies or becomes a proposal |
| `authorEdit` | `"always"` \| `"never"` \| `"window"` | `"always"` | How long the owner may edit |
| `authorEditMinutes` | Whole number from 1 to 525600 (a year) | `60` | The window for `"window"`, counted from the record's server `createdAt` |
The constants are exported from `@vitnode/core/content`: `CONTENT_CONTAINER_POLICY_KEYS`, `CONTENT_CONTAINER_POLICY_CREATE`, `CONTENT_CONTAINER_POLICY_MODES`, `CONTENT_CONTAINER_POLICY_AUTHOR_EDIT` and `CONTENT_CONTAINER_POLICY_MAX_MINUTES`.
## Resolution order [#resolution-order]
For each key, the first layer that sets it wins:
1. the record's container's override for the content type;
2. the nearest ancestor's override, walking `parentId`;
3. `policies.contentTypes[contentTypeId]`;
4. `policies.defaults`;
5. the fallback in the table above.
A missing key inherits. An explicit value, including `"never"` or a value equal to the parent's, is an override and stops the walk. The engine never stores `undefined`, and empty per-type objects are dropped on save.
The record's container is always resolved on the server: from its `field.container()`, or for a `field.relation({ container: true })`, from the related record's container. A comment follows its topic, whatever the payload claims.
## Who passes which rule [#who-passes-which-rule]
| Check | Passes |
| ------------------------- | ----------------------------------------------------------------------------------------- |
| Create with `"moderators"` | `can_edit` for the container, a root role |
| Skip `publish` / `edits` approval | `can_approve` for the container, a root role |
| Edit any record | `can_edit` for the container, a root role. Independent of `authorEdit` |
| Edit own record | The owner, while `update.own` is on, the record is not hidden and `authorEdit` allows it |
Container scopes are explained in [Container-scoped permissions](/docs/dev/moderation/scoped-permissions#container-scoped-permissions).
## Refusals on the public write routes [#refusals-on-the-public-write-routes]
Both answer `403` with `"code": "CONTENT_FORBIDDEN"` and one issue whose `field` is `null`:
| Issue code | Constant | When |
| -------------------- | ------------------------------------------------- | -------------------------------------------------------------------------- |
| `edit_window_closed` | `CONTENT_PUBLIC_WRITE_ISSUE_CODES.editWindowClosed` | The owner edits a public record after `authorEdit` stopped allowing it |
| `moderators_only` | `CONTENT_PUBLIC_WRITE_ISSUE_CODES.moderatorsOnly` | A create in a container with `create: "moderators"` by a non-moderator |
```json title="PATCH /api/@vitnode/forum/content/comments/18 → 403"
{
"code": "CONTENT_FORBIDDEN",
"contentTypeId": "forum.comment",
"issues": [
{ "code": "edit_window_closed", "field": null, "message": "Public write denied: forbidden." }
]
}
```
An owner editing a record the public cannot read, such as a hidden one, still gets `404` with no issue, so the answer never confirms it exists. A submission opened by `publish` or `edits` is described in [Approvals](/docs/content-engine/approvals).
## AdminCP routes [#admincp-routes]
Mounted only for a collection with policies, in the admin module, under `/api/{pluginId}/admin/content/{admin.permissionModule}`. For the forum that is `/api/@vitnode/forum/admin/content/forum_forums`.
| Method and path | Admin permission | Body | Success |
| --------------------- | ---------------- | --------------- | -------------------------- |
| `GET /{id}/policies` | `can_view` | | `200` with the report |
| `PUT /{id}/policies` | `can_edit` | `{ overrides }` | `200` with the new report |
`PUT` replaces the container's whole overrides object. `overrides` maps a content type id to a policy, and only content types filed in the collection with `publicWrite` are accepted. A saved change expires the collection's page cache and emits `content.container.updated` with `changedFields: ["policies"]`. Saving the same overrides again changes nothing and emits nothing.
```json title="PUT /api/@vitnode/forum/admin/content/forum_forums/3/policies"
{ "overrides": { "forum.topic": { "publish": "approve" } } }
```
### Policy report [#policy-report]
| Name | Type | Description |
| ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------- |
| `id` | `number` | The container |
| `overrides` | `Record` | What this container sets itself |
| `contentTypes[].contentTypeId` | `string` | A content type filed in the collection with `publicWrite` |
| `contentTypes[].pluginId` | `string` | Its plugin |
| `contentTypes[].effective` | full policy | What applies in this container |
| `contentTypes[].inherited` | full policy | What would apply without this container's own overrides |
| `contentTypes[].sources` | `Record` | Where each effective value comes from |
A source is `{ kind: "container", containerId }`, `{ kind: "contentType" }`, `{ kind: "default" }` or `{ kind: "fallback" }`.
### Errors [#errors]
Errors answer `{ code, message }`:
| Status | `code` | When |
| ------ | --------------- | ------------------------------------------------------------------------------------- |
| `400` | `invalidInput` | The body breaks the schema: an unknown key, a value outside the enum, minutes out of range |
| `400` | `invalidPolicy` | `overrides` names a content type that is not filed in the collection |
| `403` | | Missing the admin permission |
| `404` | `notFound` | No such container in this collection |
## Definition errors [#definition-errors]
`defineContentContainer` throws a `ContentEngineError` when the module loads:
| Cause | Message starts with |
| --------------------------------------------- | --------------------------------------------------------------------------- |
| `policies` is not an object or `false` | ``policies is an object with `defaults` and `contentTypes`, or `false`.`` |
| An unknown key beside `defaults` and `contentTypes` | `policies.x is not an option.` |
| A `contentTypes` key that is not `plugin.entity` | `policies.contentTypes has the key "x", which is not a content type id` |
| An unknown policy key | `policies.defaults.x is not a policy.` |
| A value outside the allowed ones | `policies.defaults.publish is not valid:` |
## Server helpers [#server-helpers]
From `@vitnode/core/content` (browser-safe):
| Function | Returns | Behavior |
| --------------------------------------------------------------- | ---------------------------------------- | -------------------------------------------------------------- |
| `resolveContentContainerPolicy({ chain, contentTypeId, fallback, policies })` | `{ policy, sources }` | The pure resolution above. `chain` is the container, then its ancestors, nearest first |
| `contentContainerPolicyFallback(definition)` | `ContentContainerPolicy` | The fallback for one content type |
| `contentAuthorMayEdit(policy, createdAt, now?)` | `boolean` | Whether the owner may still edit |
From `@vitnode/core/content/server`:
| Function | Returns | Behavior |
| ------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `resolveContentRowContainer(c, db, definition, row)` | `ContentRecordContainer \| null` | The container an existing row is filed in, with its ancestor chain |
| `resolveContentWriteContainer(c, db, definition, values)` | `ContentRecordContainer \| null` | The container a new record would be filed in |
| `effectiveContentPolicy(definition, container)` | `EffectiveContentContainerPolicy \| null` | `null` when the collection has no policies |
| `resolveContentPublicationPolicy({ actor, c, container, definition, operation: "create", pluginId })` | `{ approval, policy, status }` | How a member's new record is published. Throws `moderators_only` when creating is not allowed |
| `canModerateContent(c, { container, definition, permission, pluginId })` | `Promise` | Checks a moderator permission against the container's scope |
The container service from `createContentContainerService` has `policies(id)` and `updatePolicies(id, overrides)`, the same calls the routes make.
# Publishing policies (/docs/content-engine/container-policies)
Publishing policies let each container decide how members post in it: who may start, whether new records go live or wait for a moderator, whether edits to live records wait too, and how long authors may edit. One forum can be a free-for-all while the one next to it reviews every post.
This guide uses the Forum plugin: the `forum.forums` container collection, with topics (`forum.topic`) filed into forums and comments (`forum.comment`) living in their topic's forum.
## Before you begin [#before-you-begin]
You need a [container collection](/docs/content-engine/containers) and at least one content type with [public write](/docs/content-engine/public-write) filed into it, through `field.container()` or a [`field.relation({ container: true })`](/docs/content-engine/fields#relation). Policies only govern the public write routes. Staff working in the AdminCP are not affected.
### Turn on policies for the collection [#turn-on-policies-for-the-collection]
Add `policies` to the collection's definition. `defaults` applies to every content type in it, and `contentTypes` overrides it for one content type:
```ts title="plugins/forum/src/content/forums.ts"
import { defineContentContainer, field } from "@vitnode/core/content";
export const forumContainers = defineContentContainer({
id: "forum.forums",
fields: {
description: field.textarea({ maxLength: 500, nullable: true }),
},
hierarchy: { maxDepth: 4 },
// [!code ++:10]
policies: {
defaults: {
publish: "immediate",
edits: "immediate",
authorEdit: "always",
},
contentTypes: {
"forum.comment": { authorEdit: "window", authorEditMinutes: 30 },
},
},
publicApi: { path: "forums", fields: ["description"] },
admin: { path: "forum/forums" },
});
```
Topics go live straight away and stay editable by their authors. Comments go live too, but their authors can only edit them for 30 minutes.
`policies: {}` is enough to switch the feature on and leave every decision to the AdminCP. Without `policies`, the collection does not take part: its content types keep using `publicWrite.create.status`, owners edit forever, and `can_edit` stays unscoped.
### Save [#save]
Policies are stored in a column core already has, so there is no migration. `pnpm dev` picks up the change when you save.
Turning policies on also gives each content type in the collection container-scoped `can_edit` and `can_approve` moderator permissions. Grant and label them as described in [Container-scoped permissions](/docs/dev/moderation/scoped-permissions#container-scoped-permissions).
### Override one container in the AdminCP [#override-one-container-in-the-admincp]
Open the collection's screen, such as `/admin/containers/forum/forums`. In a forum's row menu, choose **Publishing**. The **Publishing rules** sheet has one section per content type, with these settings:
| Setting | Key | Choices |
| ---------------------------- | ------------------- | ----------------------------------------------------------- |
| **Who can start** | `create` | **Members**, **Moderators only** |
| **New posts** | `publish` | **Publish immediately**, **Wait for approval** |
| **Edits to published posts** | `edits` | **Apply immediately**, **Wait for approval** |
| **Author editing** | `authorEdit` | **Always**, **Never**, **For a limited time** |
| **Editing window** | `authorEditMinutes` | Minutes, used by **For a limited time** |
{/* Image prompt: AdminCP Publishing rules sheet for the forum "Help", with a Topics section where New posts is set to Wait for approval and the other settings show "Inherit (currently: ...)" with a line under each saying where the value comes from, and a Save rules button in the footer */}
Every setting starts on **Inherit (currently: …)**, and the line below it says where the current value comes from: a parent forum, the collection's defaults or the content type's own default. For the "Help" forum, set **New posts** to **Wait for approval** for topics and choose **Save rules**.
The sheet needs the collection's `can_view` admin permission to open and `can_edit` to save. Saving emits `content.container.updated` with `changedFields: ["policies"]`.
## How a value is chosen [#how-a-value-is-chosen]
Each of the five keys is decided on its own. The engine takes the first layer that sets it:
1. the record's container, for that content type;
2. the nearest ancestor that sets it, walking up the parents;
3. `policies.contentTypes[contentTypeId]` in the definition;
4. `policies.defaults` in the definition;
5. the fallback: `create: "members"`, `publish` from `publicWrite.create.status` (`"published"` becomes `"immediate"`, `"draft"` becomes `"approve"`), `edits: "immediate"`, `authorEdit: "always"`, `authorEditMinutes: 60`.
With "Help" set to **Wait for approval** for topics, a topic in its child forum "Bug reports" waits for approval too, while comments there still publish immediately: rules are per content type, and nobody changed the comment rules.
A key that is absent inherits. An explicit value is an override, even when it matches the parent today. If someone later relaxes "Help", a child that explicitly says **Wait for approval** keeps waiting. Choose **Inherit** to remove an override.
## What members notice [#what-members-notice]
| Rule | What happens on the public write routes |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `publish: "approve"` | A new record is stored as a draft, a submission opens, and the response carries `approval`. See [Approvals](/docs/content-engine/approvals) |
| `edits: "approve"` | An author's edit of a published record becomes a proposal. The live record does not change until a moderator approves |
| `authorEdit: "never"` | Only moderators edit. The author gets `403` with the `edit_window_closed` issue |
| `authorEdit: "window"` | The author edits until `authorEditMinutes` after the record's server `createdAt`, then gets `403` with `edit_window_closed` |
| `create: "moderators"` | Only moderators holding `can_edit` in that container may create. Everyone else gets `403` with the `moderators_only` issue |
Moderators are never held back by these rules. A `can_edit` holder edits any record in the container at any age, and a `can_approve` holder skips approval for their own posts. Root roles pass every check. The issue codes come in the usual [rejection](/docs/content-engine/public-write-api#rejection) body, so translate them like any other issue.
Changing a policy never touches submissions that are already pending.
## Check the result [#check-the-result]
Sign in as a regular member and create a topic in "Help". The response is a draft waiting for approval:
```json title="POST /api/@vitnode/forum/content/topics → 201"
{
"approval": {
"id": 7,
"kind": "create",
"reason": null,
"status": "pending",
"submittedAt": "2026-10-13T09:12:44.512Z",
"values": null
},
"id": 42,
"slug": "the-editor-forgets-my-draft",
"status": "draft",
"publishedAt": null,
"version": 1,
"values": { "title": "The editor forgets my draft", "body": { "type": "doc", "content": [] }, "forum": 3 }
}
```
The topic is not on the public site yet. It appears in the [ModCP approval queue](/docs/dev/moderation/review-approvals) for moderators who hold `can_approve` in "Help". A topic in a forum without overrides still answers with `"status": "published"` and `"approval": null`.
Every option, route and error is in the [Publishing policies reference](/docs/content-engine/container-policies-api).
# Containers (/docs/content-engine/containers)
A container is a place a record lives in: one forum board, one blog section, one knowledge-base category. You declare a **collection** of containers once in code, admins build its tree in the AdminCP, and each record of a content type points at exactly one container through `field.container()`.
This guide follows the example plugin: a `example.boards` collection, and discussions (`example.discussion`) filed into it.
## Containers or a content type? [#containers-or-a-content-type]
A category could also be a content type with a [relation](/docs/content-engine/relations). Pick a container when the thing organizes content rather than being content itself:
| | Container collection | Content type |
| ------------------- | ------------------------------------------------------------------------- | ----------------------------------------------- |
| Declared with | `defineContentContainer` | `defineContentType` |
| Storage | One shared core table, `core_content_containers`. No migration of its own | Its own table, with a migration |
| Shape | A tree with parents, sibling order and a depth limit | Flat records |
| Fields | A few scalars (`text`, `textarea`, `number`, `boolean`, `enum`) | Every field kind |
| Drafts, revisions | No. A container exists or it does not | Optional `publication`, `editorial` and friends |
| Linked from records | `field.container()`, at most one per content type | `field.relation()`, as many as you need |
| Public filter | `?board=4`, and `includeDescendants=true` for a whole subtree | `?category=4`, exact match only |
A record belongs to one container. For anything else it points at, such as tags, keep using relations.
## Add a container collection [#add-a-container-collection]
You need a content type to file into containers, like `example.discussion` from [Public write](/docs/content-engine/public-write#enable-public-writing).
### Define the collection [#define-the-collection]
Create `src/content/boards.ts`. The definition describes the collection; each board is a row an admin creates later:
```ts title="plugins/example/src/content/boards.ts"
import { defineContentContainer, field } from "@vitnode/core/content";
export const boardContainers = defineContentContainer({
id: "example.boards",
fields: {
description: field.textarea({ maxLength: 500, nullable: true }),
color: field.text({ maxLength: 7, nullable: true }),
icon: field.text({ maxLength: 64, nullable: true }),
locked: field.boolean({ defaultValue: false }),
},
hierarchy: { maxDepth: 3 },
ordering: { mode: "manual" },
publicApi: {
path: "boards",
fields: ["description", "color", "icon"],
},
admin: { path: "example/boards" },
});
```
Every container already has `id`, `parentId`, `name`, `slug`, `position` and `depth`. `fields` adds your own values, stored in a `metadata` column and validated against the definition on every write. `locked` is not in `publicApi.fields`, so it never leaves the server.
| Name | Type | Required | Default | Description |
| ----------- | ------------------------------------------- | -------- | -------------------------------- | ----------------------------------------------------------------------------------------------- |
| `id` | `string` | Yes | | `plugin.collection`, lowercase and dot separated. Every row stores it, so never rename it |
| `fields` | `field.*()` map | No | `{}` | Extra values per container. `text`, `textarea`, `number`, `boolean` and `enum` only |
| `hierarchy` | `{ enabled?: boolean; maxDepth?: number }` | No | `{ enabled: true, maxDepth: 5 }` | `maxDepth` counts levels, roots included, from 2 to 10. `enabled: false` makes a flat list |
| `ordering` | `{ mode?: "manual" \| "alphabetical" }` | No | `{ mode: "manual" }` | How siblings are sorted. See [Hierarchy and ordering](/docs/content-engine/container-hierarchy) |
| `publicApi` | `{ path: string; fields?: string[] }` | No | off | Read-only public routes at `/api/{pluginId}/content/{path}` |
| `admin` | `{ path?; permissionModule?; navigation? }` | No | see below | The AdminCP screen and its staff permissions |
| `policies` | `{ defaults?; contentTypes? }` \| `false` | No | off | Per-container publishing rules. See [Publishing policies](/docs/content-engine/container-policies) |
`admin.path` defaults to the id with dots turned into slashes (`example/boards`), so the screen opens at `/admin/containers/example/boards`. `admin.permissionModule` defaults to the snake-cased id (`example_boards`), and `admin.navigation: false` hides the sidebar entry.
### Point the content type at it [#point-the-content-type-at-it]
Add one `field.container()` to the content type. List it in `publicApi.fields` to return it, in `filterableFields` to filter by it, and in `publicWrite.create.fields` to let members pick it:
```ts title="plugins/example/src/content/discussion.ts"
import { boardContainers } from "@/content/boards"; // [!code ++]
export const discussionContentType = defineContentType({
id: "example.discussion",
tableName: "example_discussions",
fields: {
title: field.text({ required: true, minLength: 3, maxLength: 200 }),
slug: field.slug({ source: "title" }),
body: field.richText({ required: true }),
author: field.user(),
board: field.container({ definition: boardContainers, required: true }), // [!code ++]
},
publicApi: {
path: "discussions",
fields: ["id", "title", "slug", "body", "board", "publishedAt"], // [!code highlight]
filterableFields: ["board"], // [!code ++]
},
publicWrite: {
ownership: { field: "author" },
create: { fields: ["title", "body", "board"], status: "published" }, // [!code highlight]
update: { fields: ["title", "body"] },
},
});
```
The column is an `integer` foreign key to `core_content_containers.id` with `ON DELETE RESTRICT`, plus an index on `(board, publishedAt)`. Every write checks that the id belongs to `example.boards`, not just that it exists. All options are in [Fields](/docs/content-engine/fields#container).
A content type can also live in the container of a record it points at, such as a reply in its topic's board, with a required `field.relation({ target, container: true })` instead of a `field.container()`. See [Threaded replies](/docs/content-engine/threads).
### Register the collection on the API [#register-the-collection-on-the-api]
Pass the definition to both content modules. The admin module adds the staff routes and the `example_boards` permissions; the public module adds the read routes:
```ts title="plugins/example/src/api/modules/admin/admin.module.ts"
import { boardContainers } from "@/content/boards"; // [!code ++]
buildContentAdminModule({
containers: [boardContainers], // [!code ++]
contentTypes: [articleContent, discussionContent],
}),
```
```ts title="plugins/example/src/config.api.ts"
buildContentPublicModule({
containers: [boardContainers], // [!code ++]
contentTypes: [articleContent, discussionContent],
}),
```
A content type whose `field.container()` names a collection no plugin registers stops the API at startup. Registering the same definition object in both modules is fine.
### Register the AdminCP screen [#register-the-admincp-screen]
Add the collection to `containers` in both AdminCP files, next to your content types. `icon` is optional and defaults to a folder icon:
```tsx title="plugins/example/src/admin/nav.tsx"
import { FolderTreeIcon } from "lucide-react"; // [!code ++]
import { boardContainers } from "@/content/boards"; // [!code ++]
// [!code ++:4]
export const exampleBoardsNav = {
definition: boardContainers,
icon: ,
};
export const adminNav = {
pluginId: manifest.id,
containers: [exampleBoardsNav], // [!code ++]
contentTypes: [exampleArticleNav, exampleDiscussionNav],
} satisfies AdminNavPluginSource;
```
```tsx title="plugins/example/src/admin/content.tsx"
export const adminContent = {
pluginId: manifest.id,
containers: [exampleBoardsNav], // [!code ++]
contentTypes: [exampleArticleNav, exampleDiscussionNav],
} satisfies ContentFrontendPluginSource;
```
[Plugin files](/docs/content-engine/plugin-files) explains why the two files are separate.
### Add the labels [#add-the-labels]
The AdminCP reads the collection's names from `{pluginId}.containers.{entity}`, where the entity is the id without its plugin prefix. The permission labels follow the permission module:
```json title="plugins/example/src/locales/en.json"
{
"@vitnode/example": {
"containers": {
"boards": {
"label": "{count, plural, one {Board} other {Boards}}",
"title": "Boards",
"desc": "The board tree members file their discussions into.",
"fields": {
"description": "Description",
"color": "Color",
"icon": "Icon",
"locked": "Locked"
}
}
},
"content": {
"discussion": { "fields": { "board": "Board" } }
}
},
"@vitnode/example:example_boards": "Boards",
"@vitnode/example:example_boards:can_view": "View boards",
"@vitnode/example:example_boards:can_create": "Create boards",
"@vitnode/example:example_boards:can_edit": "Edit and move boards",
"@vitnode/example:example_boards:can_delete": "Delete boards"
}
```
Without labels, the screen falls back to a name derived from the id, such as "Boards".
### Migrate [#migrate]
Run this from the workspace root while `pnpm dev` is running:
```bash tab="bun"
bun run db:migrate
```
```bash tab="pnpm"
pnpm db:migrate
```
```bash tab="npm"
npm run db:migrate
```
The collection itself needs no table: every definition shares `core_content_containers`. The migration only adds the `board` column to `example_discussions`.
A required container field becomes a `NOT NULL` column. If the table already
has rows, edit the generated migration to create a container and assign it
before the `SET NOT NULL`, as the example's migration does with a "General"
board. [Database](/docs/content-engine/database) covers editing migrations.
## Manage containers in the AdminCP [#manage-containers-in-the-admincp]
Open **AdminCP → Example → Boards** (`/admin/containers/example/boards`). Staff need the collection's `can_view` permission to see it, and `can_create`, `can_edit` or `can_delete` for each action.
- **The tree** shows every board with its children nested below it. Drag a board to reorder it among its siblings or to drop it inside another board.
- **Create and edit** open a sheet with the name, the slug and the collection's own fields. A container created without a slug gets one derived from its name.
- **The move menu** on each row does the same as dragging from the keyboard: **Move up**, **Move down**, **Move to top level** and **Move inside…**.
- **Delete** works only on an empty board. A board with child boards or with discussions is refused with a message that says which one, and nothing changes.
In a content type's generated form, a container field becomes a **picker** that lists the collection's tree, so staff can file a discussion into a board or move it to another one.
A drag that breaks a rule, such as nesting a board deeper than `maxDepth` or dropping it inside its own child, is refused the same way. [Hierarchy and ordering](/docs/content-engine/container-hierarchy) lists every rule.
## Check the result [#check-the-result]
Create a "Frontend" board with a "React" board inside it, then read the public tree:
```bash
curl http://localhost:3000/api/@vitnode/example/content/boards
```
```json
{
"items": [
{
"id": 1,
"parentId": null,
"name": "Frontend",
"slug": "frontend",
"position": 0,
"depth": 1,
"metadata": {
"description": "Everything in the browser",
"color": "#2563eb",
"icon": null
}
},
{
"id": 2,
"parentId": 1,
"name": "React",
"slug": "react",
"position": 0,
"depth": 2,
"metadata": { "description": null, "color": null, "icon": null }
}
]
}
```
Items come in tree order: each parent, then its subtree. `metadata` holds only `publicApi.fields`, and a value nobody set is `null`. [Containers on the frontend](/docs/content-engine/container-frontend) turns this into pages.
## Definition errors [#definition-errors]
`defineContentContainer` and the boot checks throw a `ContentEngineError` before the app serves a request:
| Cause | Message starts with |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| An id that is not `plugin.collection` | `Container definition id "x" must look like "plugin.collection"` |
| A field named `id`, `name`, `slug`, `depth`, `metadata` or another column | `Field "x" is reserved.` |
| A field kind other than text, textarea, number, boolean or enum | `Field "x" is a "relation" field.` |
| A field with `localized`, `unique` or `ai` | `Field "x" is` followed by the option and why containers refuse it |
| `maxDepth` below 2 or above 10 | `hierarchy.maxDepth must be a whole number from 2 to 10.` |
| `publicApi.fields` naming an unknown or built-in field | `publicApi.fields references unknown field "x".` |
| Two collections with one id or one `admin.path` | `Duplicate container definition id` / `AdminCP path "x" is claimed by both` |
| A collection and a content type sharing a public path or permission module | `Public path "x" is claimed by both` / `Permission module "x" is used by both` |
| Two `field.container()` in one content type | `Content type declares 2 container fields` |
| `field.container()` pointing at an unregistered collection | `Field "x" uses the container collection "y", which no plugin registers.` |
| A container field in `publicWrite.update.fields` | `publicWrite.update.fields includes the container field "x".` |
## What's next [#whats-next]
- [Hierarchy and ordering](/docs/content-engine/container-hierarchy): depth, moves, safe deletion and the server-side service.
- [Containers on the frontend](/docs/content-engine/container-frontend): a board tree, a board page and a create form with a board picker.
- [Publishing policies](/docs/content-engine/container-policies): who may post in each board, whether posts wait for [approval](/docs/content-engine/approvals), and how long authors may edit.
Read permissions per container, such as boards hidden from guests, are not available yet. Today every reader sees every container in the public tree.
# Hide content (/docs/content-engine/content-visibility)
Hiding takes a record off the public site right away while it stays published. It disappears from the public API, its page, search and the sitemap, and unhiding brings it back with its original URL and publish date. Reach for it when a post has to go now and the questions can wait. This guide follows the blog plugin, whose articles can be hidden.
| You want to | Use |
| ---------------------------------------------------------------------- | --------------------------------------------------------------- |
| Take a record down for now and put it back as it was | Hide |
| Turn a record back into a draft to rework it before it goes live again | [Unpublish](/docs/content-engine/publication) |
| Remove it for good | Delete |
## Before you begin [#before-you-begin]
Hiding needs [`publication`](/docs/content-engine/public-api), because a record that is never public has nothing to hide from.
### Turn on visibility [#turn-on-visibility]
The blog's article content type turns it on next to `publication`:
```ts title="plugins/blog/src/content/post.ts"
export const blogPostContentType = defineContentType({
id: "blog.post",
tableName: "blog_posts",
publication: true,
visibility: true, // [!code ++]
// ...
});
```
`visibility` takes only `true` or `false`. Without `publication` it throws when the content type is defined. With it, `hiddenAt`, `hiddenBy` and `visibility` are reserved and cannot be field names.
### Build and migrate [#build-and-migrate]
Run `pnpm db:migrate`. The migration adds two nullable columns to the main table. For the blog, `apps/api/migrations/20261011093549_content_visibility_id_strategies` contains:
```sql title="migration.sql (excerpt)"
ALTER TABLE "blog_posts" ADD COLUMN "hiddenAt" timestamp;
ALTER TABLE "blog_posts" ADD COLUMN "hiddenBy" integer;
CREATE INDEX "blog_posts_hidden_by_idx" ON "blog_posts" ("hiddenBy");
ALTER TABLE "blog_posts" ADD CONSTRAINT "blog_posts_hiddenBy_core_users_id_fkey" FOREIGN KEY ("hiddenBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
```
Existing records stay visible. `hiddenAt` is when the record was hidden and `hiddenBy` is the staff member who hid it.
### Label the permission [#label-the-permission]
Visibility adds a `can_hide` permission, separate from `can_publish`, so a moderator can take records down without being able to publish. The blog labels it next to its other article permissions:
```json title="plugins/blog/src/locales/en.json"
{
"@vitnode/blog:posts:can_hide": "Hide and unhide articles"
}
```
Grant it to the staff who should moderate in **AdminCP → Staff**.
### Hide an article [#hide-an-article]
Open **AdminCP → Blog → Articles**, open the **More actions** menu of a published article and choose **Hide**. Confirm with **Yes, hide it**:
A toast says the article is now hidden. The row keeps its **Published** badge, and its title gets an eye-off icon in front with an **Article hidden** tooltip. The **Visibility** filter in the list toolbar shows only hidden or only visible records, and it combines with the **Status** filter:
The edit page shows the same icon before the title, an **Unhide** action (in the blog's editor, it lives in the ▾ menu next to **Save changes**) and a note that publishing a hidden record does not make it public:
Choose **Unhide** in the same menu, or on the edit page, to bring the article back. In the database, hiding sets `blog_posts."hiddenAt"` and `"hiddenBy"`, and unhiding clears both.
Cleaning up after a spam wave? Tick several articles and choose **Visibility → Hide** in the floating bar. The menu counts how many of them are still visible, such as **Hide 2 of 3**, and leaves the rest alone. **Unhide** works the same way. See [Bulk actions](/docs/content-engine/admincp#bulk-actions).
## What hiding does [#what-hiding-does]
A record is public only when it is published, its `publishedAt` is not in the future, and it is not hidden. While a record is hidden:
- The public API list leaves it out for everybody but the [people who can still see it](#who-can-still-see-it), and its detail route answers `404`, in every language, including a language that falls back to the default one.
- Its [page](/docs/content-engine/public-pages) answers `404`, and so do its old slugs instead of redirecting.
- The [sitemap](/docs/content-engine/seo#sitemap) and other pages' `hreflang` alternates leave it out, and [site search](/docs/content-engine/search) marks its document private.
- Signed [preview links](/docs/content-engine/editorial#preview-links) still work, and the AdminCP still shows it.
## Who can still see it [#who-can-still-see-it]
Hidden doesn't mean gone for the people working on it. Signed in, a record's **authors**, its **editors** and its **moderators** still reach it:
- **Authors** are the users in the content type's [`search.authorField`](/docs/content-engine/search), such as the blog's **Author** field. Without one, the [public write](/docs/content-engine/public-write) owner is the author.
- **Editors** are staff with `can_edit` on the content type, such as `posts:can_edit` for the blog.
- **Moderators** with the moderator permission `can_view_hidden` on a [reportable](/docs/dev/moderation/enable-reports) content type, in the record's scope when it has one. They need no AdminCP permission.
The route tells whose record it is by its `id`, so `id` must be in `publicApi.fields`. For them:
- The page opens with an eye-off icon before the title and a **This article is hidden** note, so nobody mistakes it for a live page. The response sends `noindex` and `Cache-Control: private, no-store`, so search engines and caches never keep it.
- The detail route answers `200`, and `GET .../delivery/resolve/{slug}` returns `hidden: true` plus the record's own published translations in `alternates`, so the language switcher opens the same article in another language at its own slug.
- The public list, such as `/blog`, shows it among the published records with the eye-off icon before its title. API rows carry `hidden: true`, and the response is `Cache-Control: private, no-store`.
- [Site search](/docs/content-engine/search) and the member timeline list it with the eye-off icon before its title and a tooltip like **Article hidden**.
On a list, moderators see every hidden record only when the content type has no moderation scope. With a scope, the list shows them what they authored or can edit, and the page itself still opens for them.
Everyone else, signed in or not, still gets a `404`. Old slugs never redirect to a hidden record, and the list, sitemap and other pages' `hreflang` alternates leave it out for everybody.
Search learns that a record is hidden when it is hidden or unhidden. Records hidden before you updated VitNode show up for their authors and editors only after a reindex: **Debug Panel → Search** (`/admin/core/debug/search`), then **Reindex** on the collection, such as `blog.post`. See [Search](/docs/content-engine/search).
Hiding changes nothing else. `status` and `publishedAt` stay as they are, so unhiding a published record puts it back with its original date. Publishing, unpublishing and scheduled publishes never change visibility, so publishing a hidden record keeps it off the site until someone unhides it. Restoring a revision never changes visibility either.
With [`editorial`](/docs/content-engine/editorial), hiding and unhiding raise the record's `version` and store a revision, shown as **Hidden** or **Unhidden** in the history. Hiding a record that is already hidden changes nothing: no write, no revision and no event.
Hiding is per record. To take one language off the site, unpublish that translation instead.
## Hide from the API [#hide-from-the-api]
The admin module has `POST /{id}/hide` and `POST /{id}/unhide`, such as `POST /api/@vitnode/blog/admin/content/posts/703/hide` for the blog. Both need `can_hide` and accept an optional body:
```json
{ "expectedVersion": 4 }
```
With `expectedVersion`, the call fails with `409` and `CONTENT_VERSION_CONFLICT` when someone saved the record meanwhile, like any [editorial write](/docs/content-engine/editorial#safe-concurrent-edits). Both routes answer `200` with `{ changed, row }`, where `changed` is `false` when the record was already in that state. A malformed id is `400` and a missing record is `404`. With `delivery.redirects`, unhiding can also fail with `409` when another record took the URL while this one was hidden.
The admin list route accepts `?visibility=hidden` or `?visibility=visible`, and it combines with `?status=`.
From your own code, `hide(id, options)` and `unhide(id, options)` exist on the services when visibility is on. See [Services](/docs/content-engine/services#save-like-the-admincp).
## Show it on your pages [#show-it-on-your-pages]
The people who still see a hidden record should know readers don't. Tell them on the page:
- **A page with [delivery](/docs/content-engine/public-pages)** reads `metadata.hidden` from `contentDeliveryPage`. The blog shows an eye-off icon before the title and a notice, and drops the Report button.
- **A page without delivery** reads the `X-Content-Hidden` header of `GET /{slug}`, which is `"true"` only for a hidden record:
```tsx title="plugins/example/src/pages/discussion-page.tsx"
import { CONTENT_HIDDEN_HEADER } from "@vitnode/core/content";
const detail = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/discussions",
path: "/{slug}",
args: { params: { slug: encodeURIComponent(slug) } },
});
const hidden = detail.headers.get(CONTENT_HIDDEN_HEADER) === "true";
{hidden ? (
} variant="warning">
{t("hidden.title")}{t("hidden.desc")}
) : null}
```
[`useContentPermissions`](/docs/content-engine/reports#ask-who-may-report-what) already answers `canReport: false` for a hidden record, and `canEdit: false` for its author, so the page needs no extra checks for its buttons. A moderator can still edit it.
## Delete, hide, unpublish or reject [#delete-hide-unpublish-or-reject]
The public site creates and edits; it never deletes or hides. These actions belong to staff:
| Action | Readers afterwards | Who, and where |
| --------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Hide | `404`; authors and moderators still see it | Staff with `can_hide` in the AdminCP, or moderators acting on a [report](/docs/dev/moderation/process-reports) |
| Unpublish | `404`; the record is a draft again | Staff with `can_publish`, in the AdminCP |
| Reject approval | A new record stays a draft; a proposed edit never applies | Moderators with `can_approve`, in the [approvals queue](/docs/dev/moderation/review-approvals) |
| Delete | Gone for good, with its URL | Staff with `can_delete`, in the [AdminCP](/docs/content-engine/admincp#bulk-actions) |
Hiding is the reversible one. The record keeps its revisions, its reports and its place in a thread. There is no public delete route, so don't put a Delete button on a public page.
## Events and caches [#events-and-caches]
A hide emits `content.blog.post.hidden` with `{ actorUserId, contentId, hiddenAt }`, and an unhide emits `unhidden` with `{ actorUserId, contentId }`. Both update the search index and the delivery data. See [Events](/docs/content-engine/events).
Front ends listed in [`content.revalidateOrigins`](/docs/content-engine/caching#notify-a-separate-front-end) are notified at once when a hide or unhide changes what the public can see. Hiding a draft notifies nobody, because nothing public changed.
## Check the result [#check-the-result]
Hide the published "Second post", then request its page and its API entry:
```bash
curl -o /dev/null -w "%{http_code}\n" "http://localhost:3000/blog/article/second-post"
curl -o /dev/null -w "%{http_code}\n" "http://localhost:3000/api/@vitnode/blog/content/blog/article/second-post"
```
Both answer `404`, like a draft, and the article drops out of the blog list and the sitemap. In the AdminCP it is still **Published**. Unhide it, and the same requests answer `200` with the original `publishedAt`. Open `/blog` and the page while signed in as one of its authors, and the list shows it with the eye-off icon and the page loads with the hidden note.
The example plugin's discussions show the same note through the header. Ben Ortiz opens his own hidden discussion, and only he and the moderators can:
# First content type (/docs/content-engine/create-a-content-type)
This tutorial builds `example.category`, the content type behind **AdminCP → Example → Categories** in VitNode's example plugin. You write one definition, wire it into four small files, run one migration, and get a Postgres table, a list with a create form, staff-only API routes and four staff permissions. Categories have one field, so you spend your time on the wiring instead of naming things.
Every snippet is copied from `plugins/example`, with unrelated content types trimmed out. Use your own plugin id wherever you see `@vitnode/example`.
## Before you begin [#before-you-begin]
You need a plugin that your app already loads, with `src/config.api.ts`, `src/config.tsx` and `src/api/builders.ts`, which binds the API and Content Engine builders to your plugin, so none of them needs a plugin id:
```ts title="plugins/example/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);
```
`src/manifest.gen.ts` is written by `vitnode build` and `vitnode dev` from your `package.json`, and `manifest.id` is the plugin id. [Create a plugin](/docs/dev/plugins/create) generates all of these. You also need a running PostgreSQL database and an AdminCP account.
### Declare the content type [#declare-the-content-type]
Create `src/content/category.ts`:
```ts title="plugins/example/src/content/category.ts"
import { defineContentType, field } from "@vitnode/core/content";
export const categoryContentType = defineContentType({
id: "example.category",
tableName: "example_categories",
fields: {
name: field.text({ required: true, minLength: 1, maxLength: 100 }),
},
admin: {
path: "example/categories",
list: {
columns: ["name", "createdAt"],
orderableFields: ["name"],
},
},
});
```
- `id` is `plugin.entity`, lowercase and dot separated. The part after the first dot (`category`) names the translations and the permissions.
- `tableName` is the Postgres table, in snake_case.
- `admin.path` sets the AdminCP URL, `/admin/content/example/categories`.
- `list.columns` may name your fields and the system columns `id`, `createdAt` and `updatedAt`. `orderableFields` makes the **Name** header sortable.
You get a few things without asking: the list searches its text columns, the first text field becomes the record's title, and create and edit open in a dialog. Every field helper is listed in [Fields](/docs/content-engine/fields).
### Create the model [#create-the-model]
Create `src/database/categories.ts`. `createContentModel` turns the definition into a Drizzle table, Zod schemas and a service:
```ts title="plugins/example/src/database/categories.ts"
import { createContentModel } from "@vitnode/core/content/server";
import { categoryContentType } from "@/content/category";
export const categoryContent = createContentModel(categoryContentType);
export const example_categories = categoryContent.table;
```
Export the table. Drizzle Kit finds tables by reading the exports of your plugin's built `dist/src/database/*.js` files, so a table nobody exports never reaches a migration.
### Add the staff API routes [#add-the-staff-api-routes]
Create an `admin` module and pass the model to `buildContentAdminModule`:
```ts title="plugins/example/src/api/modules/admin/admin.module.ts"
import { buildContentAdminModule, buildModule } from "@/api/builders";
import { categoryContent } from "@/database/categories";
export const adminModule = buildModule({
name: "admin",
routes: [],
modules: [
buildContentAdminModule({
contentTypes: [categoryContent],
}),
],
});
```
Then add the module to your API plugin. This is an excerpt of `src/config.api.ts`:
```ts title="plugins/example/src/config.api.ts"
import { adminModule } from "@/api/modules/admin/admin.module"; // [!code ++]
export const exampleApiPlugin = () =>
buildApiPlugin({
manifest,
modules: [adminModule], // [!code highlight]
});
```
The routes live under `/api/@vitnode/example/admin/content/category` and only answer 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 [#add-the-admincp-screen]
The AdminCP needs two things from your plugin: a sidebar link and the screen behind it. They live in separate files on purpose. The sidebar loads on every AdminCP page, while the screen's forms load only when someone opens it.
Create `src/admin/nav.tsx` with the sidebar link:
```tsx title="plugins/example/src/admin/nav.tsx"
import { FolderIcon } from "lucide-react";
import { categoryContentType } from "@/content/category";
import { manifest } from "@/manifest.gen";
export const adminNav = {
pluginId: manifest.id,
contentTypes: [{ definition: categoryContentType, icon: }],
};
```
Create `src/admin/content.tsx` and reuse the same list for the screen:
```tsx title="plugins/example/src/admin/content.tsx"
export { adminNav as adminContent } from "./nav";
```
That one line is enough for the generated list and forms. Later, this is the file where you swap in [custom cells, fields or form layouts](/docs/content-engine/admincp). VitNode finds both files by name, so keep the paths and the `adminNav` and `adminContent` exports.
### Name everything [#name-everything]
Labels come from your plugin's `src/locales/en.json`. Content type strings live under `content.category`, and each staff permission gets a top-level key:
```json title="plugins/example/src/locales/en.json"
{
"@vitnode/example": {
"title": "Example",
"content": {
"category": {
"label": "{count, plural, one {Category} other {Categories}}",
"title": "Categories",
"desc": "Group articles together.",
"fields": {
"name": "Name",
"createdAt": "Created"
}
}
}
},
"@vitnode/example:category": "Categories",
"@vitnode/example:category:can_view": "View categories",
"@vitnode/example:category:can_create": "Create categories",
"@vitnode/example:category:can_edit": "Edit categories",
"@vitnode/example:category:can_delete": "Delete categories"
}
```
`label` is an ICU plural, used in buttons and messages such as **Create Category**. `title` and `desc` head the list, and the plugin's own `title` names the sidebar group. A missing key falls back to a readable version of the field name, and a missing permission label shows its raw id.
### Build and migrate [#build-and-migrate]
Run this from the workspace root:
```bash tab="bun"
bun run db:migrate
```
```bash tab="pnpm"
pnpm db:migrate
```
```bash tab="npm"
npm run db:migrate
```
`db:migrate` reads the built plugin, so always build first. It writes a migration to `apps/api/migrations/` and applies it. This is the part of the example plugin's migration that creates categories:
```sql title="apps/api/migrations/20260802212938_add_example_content/migration.sql (excerpt)"
CREATE TABLE "example_categories" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"name" varchar(100) NOT NULL
);
ALTER TABLE "example_categories" ENABLE ROW LEVEL SECURITY;
CREATE INDEX "example_categories_created_at_idx" ON "example_categories" USING btree ("createdAt");
CREATE INDEX "example_categories_updated_at_idx" ON "example_categories" USING btree ("updatedAt");
```
You declared one field. The engine added `id`, the two timestamps and their indexes. [Database](/docs/content-engine/database) lists everything it generates.
### Create a few categories [#create-a-few-categories]
Open **AdminCP → Example → Categories** and click **Create Category**. The dialog is generated from your fields, so it has a single **Name** input with the 1 to 100 character limit from the definition.
Click **Create**. A toast says "Category has been created." and the list refreshes. Add a couple more so there is something to sort.
## Check the result [#check-the-result]
The list shows your categories with the **Name** and **Created** columns from `list.columns`. Click **Name** to sort, or type in the search box to filter by name.
Now open **AdminCP → Staff → Administrators**, edit an administrator, choose **Restricted** and select **Example**. The **Categories** group holds the four permissions the Content Engine generated from your definition, labelled by your locale keys. Create, edit and delete each require view. Every generated route checks them, so an administrator without **Delete categories** gets a `403` from the delete route and no delete button in the list.
The `advanced_article`, `localized_article` and `page` groups next to it have no labels in the example plugin. That is what a missing permission key looks like.
## Next [#next]
Categories are staff-only. To publish content to visitors, add drafts and a read-only API in [Public API](/docs/content-engine/public-api).
# Database (/docs/content-engine/database)
Every content type becomes ordinary Postgres tables managed by Drizzle. `createContentModel` from `@vitnode/core/content/server` builds them from the definition, and the usual VitNode migration workflow creates them. Values are stored in typed columns. Only `field.blocks()` and `field.richText()` keep a JSON document, in a `jsonb` column.
## Generate and apply a migration [#generate-and-apply-a-migration]
After you add or change a content type, run this from the workspace root:
```bash tab="bun"
bun run db:migrate
```
```bash tab="pnpm"
pnpm db:migrate
```
```bash tab="npm"
npm run db:migrate
```
1. `db:migrate` reads each plugin's built `dist/` folder. `pnpm dev` rebuilds a plugin every time you save, so while it runs the output is always current. Without `pnpm dev`, such as in CI or right after a checkout, run `pnpm build:plugins` first, or the migration sees the old schema.
2. `db:migrate` compares the exported tables of every plugin in your API config with the last snapshot and writes a new folder to `apps/api/migrations/`.
3. It applies pending migrations, then makes sure the initial data exists.
Commit the generated migration together with the content type. Production never migrates on startup, so run migrations as a deploy step. [Database CLI](/docs/dev/cli/database) covers `vitnode db generate --name ` for a named migration, `vitnode db status` and the rest.
## Export every table [#export-every-table]
Drizzle Kit finds tables by reading the exports of `node_modules//dist/src/database/*.js` for each plugin. A table without an export is left out of the migration, without a warning. A content type with relations, translations and repeatable rows has several tables:
```ts title="plugins/example/src/database/advanced-articles.ts"
import { createContentModel } from "@vitnode/core/content/server";
import { advancedArticleContentType } from "@/content/advanced-article";
import { example_categories } from "./categories";
export const advancedArticleContent = createContentModel(
advancedArticleContentType,
{
references: {
categories: () => example_categories.id,
},
},
);
export const example_advanced_articles = advancedArticleContent.table;
export const example_advanced_articles_translations =
advancedArticleContent.translationTable;
export const example_advanced_articles_categories =
advancedArticleContent.advancedTables.junctions.categories;
export const example_advanced_articles_related_articles =
advancedArticleContent.advancedTables.junctions.relatedArticles;
export const example_advanced_articles_faq =
advancedArticleContent.advancedTables.repeatables.faq;
```
| Model member | Exists when |
| ------------------------------------ | --------------------------------------------------------- |
| `table` | Always |
| `translationTable` | `localization` is enabled; otherwise `null` |
| `advancedTables.junctions.` | A `relation`, `user` or `file` field has `multiple: true` |
| `advancedTables.repeatables.` | The content type has a `field.repeatable()` |
`references` maps each `relation` field to the column it points at. `user` and `file` fields need no entry, because the engine knows `core_users` and `core_files`. See [Relations](/docs/content-engine/relations).
## Generated columns [#generated-columns]
You never declare these. A field with the same name throws an error.
| Column | Type | Added when |
| ------------- | ------------------------------------------------------------------------- | ------------- |
| `id` | Primary key, typed by [`idStrategy`](#ids-and-shared-tables) | Always |
| `createdAt` | `timestamp`, defaults to `now()` | Always |
| `updatedAt` | `timestamp`, defaults to `now()`, refreshed by every update | Always |
| `status` | `varchar(32)`, `draft` or `published`, defaults to `draft` | `publication` |
| `publishedAt` | `timestamp`, nullable | `publication` |
| `version` | `integer`, defaults to `1` | `editorial` |
| `hiddenAt` | `timestamp`, nullable | `visibility` |
| `hiddenBy` | `integer`, nullable, references `core_users.id` with `ON DELETE SET NULL` | `visibility` |
Column names are camelCase in SQL, exactly as in TypeScript. Every generated table has row-level security enabled. A field group adds one column per leaf to the main table, so the `syndication` group's `indexable` field becomes `syndicationIndexable`. Column types per field are listed in [Fields](/docs/content-engine/fields).
### Translations table [#translations-table]
With `localization`, `{tableName}_translations` holds the localized fields:
| Column | Notes |
| ------------------------ | ------------------------------------------------------------------------------------------------------- |
| `itemId` | References the main table, with the same type as its `id`. Deleting the record deletes its translations |
| `languageId` | References `core_languages.id`. A language with content cannot be deleted |
| `version` | Per-language version, always present |
| `createdAt`, `updatedAt` | Same as the main table |
| `status`, `publishedAt` | With `publication`. Each language is published on its own |
The primary key is `(itemId, languageId)`, so a record has at most one translation per language.
### Junction and child tables [#junction-and-child-tables]
Field names become snake_case in table names, so `relatedArticles` on `example_advanced_articles` becomes `example_advanced_articles_related_articles`.
- A **to-many field** gets `{tableName}_{field}` with `itemId`, `relatedItemId`, `position` and `createdAt`. The primary key is `(itemId, relatedItemId)`, so a record links to each target once.
- A **repeatable field** gets `{tableName}_{field}` with `id`, `itemId`, `position`, `createdAt`, `updatedAt` and one column per subfield.
Deleting a record deletes its junction and repeatable rows. Both kinds keep `position` unique per record. That column holds the order an editor picks in the AdminCP.
Every column that points at a record has the type of that record's key. In a junction table, `itemId` follows the owner's `idStrategy` and `relatedItemId` follows the target's. A repeatable's own `id` is always `serial`, while its `itemId` follows the owner. User and file references are always `integer`.
## IDs and shared tables [#ids-and-shared-tables]
The [id strategy](/docs/content-engine/id-strategy) sets the key column:
| `idStrategy` | `id` column |
| -------------------- | ----------------------------------------------------- |
| `"serial"` (default) | `serial PRIMARY KEY` |
| `"uuid"` | `uuid PRIMARY KEY DEFAULT gen_random_uuid()` |
| `"bigint"` | `bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY` |
Core tables that hold records of every content type store the id as text: `core_content_revisions`, `core_content_schedules`, `core_content_slug_history`, `core_search_index`, the [live editing](/docs/content-engine/live-editing-api#where-the-data-lives) tables `core_content_drafts`, `core_content_field_locks` and `core_content_documents`, and `core_ai_translation_sources`. Their `itemId` is a `varchar(64)` holding `contentIdKey(id)`: the digits of a serial or bigint id, or the UUID. Migration `20261011093549_content_visibility_id_strategies` converted them in place from `integer`, so existing serial keys keep their digits:
```sql title="apps/api/migrations/20261011093549_content_visibility_id_strategies/migration.sql (excerpt)"
ALTER TABLE "core_content_revisions" ALTER COLUMN "itemId" SET DATA TYPE varchar(64) USING "itemId"::varchar(64);
```
Custom SQL that joins these tables to a content table needs a cast, such as `"core_content_revisions"."itemId" = "example_articles"."id"::text`.
## Generated indexes [#generated-indexes]
The engine creates these indexes without being asked:
- A unique index on every slug, and on every `field.text({ unique: true })`.
- An index on every single `relation`, `user` and `file` column, and on `hiddenBy` with `visibility`.
- Indexes on `createdAt` and `updatedAt`.
- `(status, publishedAt)` with `publication`.
- On a translations table: `(languageId, status)` with `publication` or `(languageId)` without it, plus a unique `(languageId, slug)` for each localized slug.
- On a junction table: `relatedItemId`, and a unique `(itemId, position)`. On a repeatable table: a unique `(itemId, position)`.
Generated names follow `{table}_{columns}_idx`, or `_key` for unique ones, such as `example_categories_created_at_idx`. Add your own with the [`indexes`](/docs/content-engine/reference) option. An index on the same columns as a generated one replaces it instead of adding a second one:
```ts title="plugins/example/src/content/article.ts"
indexes: [{ on: ["status", "createdAt"] }],
```
## Change a content type later [#change-a-content-type-later]
Changing a definition is a schema change like any other. Build, migrate and read the generated SQL before you commit it.
- **Adding a nullable field, or one with a `defaultValue`,** is safe on a table that already has rows.
- **Adding a required field without a default** fails on a table with rows. Add it as nullable, fill it, then make it required in a second migration.
- **Renaming a field** renames its column. Check that the generated SQL says `RENAME COLUMN`, not a drop followed by an add, or the old values are lost.
- **Turning on `localization`** moves localized fields to a new translations table. The generated SQL does not copy existing values, so add the data move to the migration yourself.
- **Turning on `visibility`** adds two nullable columns, so existing records stay visible.
- **Changing `idStrategy`** on a table with data is not something the generated SQL can do. It needs a hand-written data migration that converts the key, every column that references it and the keys in the shared tables. See [Change the strategy later](/docs/content-engine/id-strategy#change-the-strategy-later).
- **Never edit or regenerate a migration that has already been applied.** Write a new one instead.
# Duplicate content (/docs/content-engine/duplicate-content)
Duplication copies one record into a new draft: every field, every translation, relations in their order and repeatable rows. The blog uses it so an editor can start a new article from a similar one, or prepare a rewrite of a published article without touching the live version.
### Turn on duplication [#turn-on-duplication]
Add `duplication: true` to the definition. The blog's article content type does exactly that:
```ts title="plugins/blog/src/content/post.ts"
export const blogPostContentType = defineContentType({
id: "blog.post",
tableName: "blog_posts",
publication: true,
duplication: true, // [!code ++]
// ...
});
```
Duplication adds no column, so there is nothing to migrate, and no new permission. Duplicating needs `can_create` and `can_view`, because it creates a record by reading another. For the blog those are **Create articles** and **View articles list**.
### Duplicate an article in the AdminCP [#duplicate-an-article-in-the-admincp]
Open **AdminCP → Blog → Articles**, open the **More actions** menu of an article and choose **Duplicate**. Confirm with **Yes, duplicate it**:
The list refreshes, a toast says the article was duplicated as a draft, and the copy opens on its edit page (or in the edit dialog, for a content type whose `admin.edit.mode` is `"dialog"`). If some of the source's languages are switched off on this site, a second toast lists the ones that were left out.
Duplicating the blog's "Hello world", published in English and Polish with its categories and authors, creates a draft with:
| Language | Title | Slug |
| -------- | -------------------- | -------------------- |
| English | Hello world (Copy) | `hello-world-copy` |
| Polish | Witaj świecie (Copy) | `witaj-swiecie-copy` |
The copy has the same categories and the same authors in the same order, and each language starts its own revision history.
## What the copy contains [#what-the-copy-contains]
| Copied | Not copied |
| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Every declared field, including rich text, groups and blocks | Revisions, schedules and preview links |
| Repeatable rows, as new rows in the same order | Slug history and redirects |
| Relation, user and file references, in their order. Files are reused, not uploaded again | Published state and `publishedAt`: the copy is a draft |
| Every translation, each as a draft with its own first revision | Hidden state: the copy is never [hidden](/docs/content-engine/content-visibility) |
The copy gets a new id, new timestamps and `version` 1. Everything happens in one database transaction, so a failure leaves nothing behind. The source is only read, so its history gets no new revision.
### Titles and slugs [#titles-and-slugs]
When `admin.titleField` is a `field.text()`, the copy's title gets a suffix in each language, such as "Hello world (Copy)". The source title is shortened when the suffix would push it past the field's `maxLength`. The suffix is the core API message `core.content.duplicate.title_suffix`, resolved in each copied language and falling back to "(Copy)".
Turn the suffix off with `duplication: { titleSuffix: false }`. `titleSuffix: true` with a title field that is not a text field throws when the content type is defined.
Each slug is regenerated from the copy's title, so `hello-world` becomes `hello-world-copy`. A slug without a `source` gets `-copy` appended. When that slug is taken, by a live record or by another record's old URL, the engine tries `-2`, `-3` and so on, up to 20 candidates.
### Unique fields [#unique-fields]
A `field.text({ unique: true })` cannot keep the source's value. Duplicating such a record without a new value fails before anything is written, with `422` and the code `CONTENT_DUPLICATE_UNIQUE_REQUIRED`. Pass the new value as an override from the API, as shown below.
## Duplicate from the API [#duplicate-from-the-api]
The admin module of a content type with duplication has `POST /{id}/duplicate`. For the blog, the module is named after its permission module, `posts`:
```http
POST /api/@vitnode/blog/admin/content/posts/701/duplicate
Content-Type: application/json
{
"translations": {
"en": { "title": "Hello again", "friendlyUrl": "hello-again" },
"pl": { "title": "Witaj ponownie" }
}
}
```
The body is optional:
| Name | Type | Description |
| -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `overrides` | Partial shared field values | Values the copy takes instead of the source's. A group is merged leaf by leaf. Validated like a create |
| `translations` | `{ [locale]: partial localized values }` | Localized values per copied language. A language the source has no translation in is a `400` |
A slug you pass is used as it is, without `-copy`, and a title you pass is used without the "(Copy)" suffix. In the example above, the English copy is "Hello again" at `hello-again`, and the Polish copy is "Witaj ponownie" with the slug `witaj-ponownie` generated from that title.
The response is `201` with `{ id, sourceId, row, locales, skippedLocales }`: the copy's id, the source id, the new row, the copied languages and the languages left out because they are switched off.
| Status | When |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `400` | An invalid id, override or translation |
| `403` | Missing `can_create` or `can_view` |
| `404` | The source does not exist |
| `409` | No free slug was found (`CONTENT_DUPLICATE_SLUG_CONFLICT`, with `field`, `locale` and `slug`), or an override collides with another record |
| `422` | A unique field needs an override (`CONTENT_DUPLICATE_UNIQUE_REQUIRED`, with `fields`) |
## Duplicate from your own code [#duplicate-from-your-own-code]
`service(c).duplicate(sourceId, options)` and `editorialService(c, { pluginId }).duplicate(sourceId, { actor, ...options })` take the same `overrides` and `translations`, plus `tx`. Both exist only when duplication is on, and both return `null` when the source does not exist.
The services do not emit events or index the copy. Run `contentDuplicateEffects` from `@vitnode/core/content/server` after the transaction commits, as the generated route does:
```ts
import { contentDuplicateEffects, resolveContentActor } from "@vitnode/core/content/server";
const { editorialService } = postContent;
if (!editorialService) throw new Error("Articles have no editorial features.");
const pluginId = manifest.id;
const actor = resolveContentActor(c);
const outcome = await editorialService(c, { pluginId }).duplicate(sourceId, {
actor,
});
if (outcome) {
await contentDuplicateEffects(
c,
postContent,
{ kind: "editorial", outcome },
{ actorUserId: actor.userId, pluginId },
);
}
```
The effects emit `content.blog.post.created` with `duplicatedFromId`, one `translation_created` per copied language, and then `content.blog.post.duplicated`. They also add the copy to the search index as a private draft. See [Events](/docs/content-engine/events).
## Check the result [#check-the-result]
Duplicate "Hello world" and go back to the list. "Hello world (Copy)" is a **Draft**, and `/blog/article/hello-world-copy` answers `404` until you publish it. Open the original's **History** and you'll find no new revision. As far as the original knows, nothing happened.
# Editorial (/docs/content-engine/editorial)
The `editorial` block gives a content type the tools an editorial team expects: a history of every save with restore, protection against two people overwriting each other, signed links that show a draft to someone without an AdminCP account, and publishing at a chosen time. It is also what [live editing](/docs/content-engine/live-editing) builds on, which you turn on separately with `liveEditing: true`.
This page uses `example.article` from the example plugin. It already has [drafts and publishing](/docs/content-engine/public-api) and a public API, which previews and scheduling build on.
## Turn on editorial features [#turn-on-editorial-features]
Add `editorial` to the definition:
```ts title="plugins/example/src/content/article.ts"
export const articleContentType = defineContentType({
id: "example.article",
tableName: "example_articles",
publication: true,
publicApi: {
path: "articles",
fields: ["title", "slug", "excerpt"],
},
// [!code ++:5]
editorial: {
revisions: { retention: 20 },
preview: { expiresInMinutes: 30 },
scheduling: true,
},
fields: {
title: field.text({ required: true, minLength: 3, maxLength: 200 }),
slug: field.slug({ source: "title" }),
},
});
```
Then migrate from the workspace root:
```bash tab="bun"
bun run db:migrate
```
```bash tab="pnpm"
pnpm db:migrate
```
```bash tab="npm"
npm run db:migrate
```
The migration adds a `version` column to `example_articles`. Revisions, schedules, drafts and locks live in shared core tables, so nothing else lands in your schema. Editorial also adds a `can_restore` staff permission, labelled by the `@vitnode/example:article:can_restore` key in your plugin's locale file.
`preview` needs `publicApi` and `scheduling` needs `publication`. Leaving out the partner is a type error, and the definition throws when it loads. Every option and default is in the [content type reference](/docs/content-engine/reference#editorial).
## Revisions [#revisions]
Every create, update, publish, unpublish and restore stores a snapshot in `core_content_revisions`, and so does every hide and unhide with [`visibility`](/docs/content-engine/content-visibility). `revisions.retention` keeps the newest snapshots per record, 50 by default and at most 500.
To see them, open the row's **More actions** menu in the AdminCP list and choose **History**. Each version shows who saved it and when, and **Show changes** lists the fields it changed:
**Restore** puts that version's field values back as a new version, so nothing in between is lost. The publication status and the [hidden](/docs/content-engine/content-visibility) state stay as they are. It needs `can_restore` and emits `content.example.article.restored`. Anyone with the record open in [live editing](/docs/content-engine/live-editing) gets a notice and a reloaded form.
## Safe concurrent edits [#safe-concurrent-edits]
Each editorial write raises the record's `version` by one and succeeds only when the version still matches the one the editor loaded. If someone saved in between, the second save fails instead of quietly replacing their work:
```json title="PUT /api/@vitnode/example/admin/content/article/2 → 409"
{
"code": "CONTENT_VERSION_CONFLICT",
"contentTypeId": "example.article",
"itemId": 2,
"expectedVersion": 2,
"currentVersion": 3
}
```
The AdminCP sends the version for you. Your own calls to the edit routes must send it too: `PUT /{id}` takes `{ "expectedVersion": 3, "values": { "title": "…" } }` and `DELETE /{id}` takes `{ "expectedVersion": 3 }`. In server code, `articleContent.editorialService` does the same check; see [Save like the AdminCP](/docs/content-engine/services#save-like-the-admincp). A translation has its own version and fails with `CONTENT_TRANSLATION_VERSION_CONFLICT`.
## Preview links [#preview-links]
A preview link lets a reviewer read a draft without signing in. In the AdminCP, open **More actions** and choose **Preview**. Creating a link needs `can_view`.
The link is HMAC-signed, expires after `expiresInMinutes` (15 by default, 1 to 1440) and cannot be revoked early, so treat it like a password. It is pinned to the last saved version, so it shows what **Save** committed, not unsaved [live editing](/docs/content-engine/live-editing) drafts.
The link points to the first of these that exists:
1. `preview.pathTemplate`, such as `/preview/articles/{token}`, with exactly one `{token}`.
2. The record's own page with `?preview=`, when the type has [delivery](/docs/content-engine/public-pages).
3. The public API route `GET /api/@vitnode/example/content/articles/preview/{token}`.
Every page still has to load the draft from that API route. A page built from [Show content on a public page](/docs/content-engine/public-pages) does not read `?preview=` on its own, so the example's link opens a 404 until you check for it in your loader:
```ts title="Read a preview in a page loader"
const preview = await fetcher({
plugin: "@vitnode/example",
method: "get",
module: "content/articles",
path: "/preview/{token}",
args: { params: { token: encodeURIComponent(token) } },
});
```
The route returns the record's public fields, and the same `404` for a forged, expired or unknown token. It sends `Cache-Control: private, no-store` and `X-Robots-Tag: noindex, nofollow`, so the draft stays out of caches and search results.
Links are built from `VITNODE_WEB_URL` and `VITNODE_API_URL`. When either is not an absolute URL, the API logs a warning at startup and the **Preview** button answers `503`.
## Schedule publishing [#schedule-publishing]
Scheduling publishes or unpublishes a record at a chosen time. Open **More actions**, choose **Schedule**, pick **Publish** or **Unpublish** and a time, then press **Schedule it**. Booking or cancelling a schedule needs `can_publish`.
A schedule is a queued task, drained by the `process-queue` [cron job](/docs/dev/cron) once a minute. If nothing triggers your cron endpoint, schedules are saved but never fire. When the install has no cron adapter at all, the dialog says so with a **No scheduler is running** warning. When a schedule fires, it publishes whatever the record says at that moment, not what it said when you booked it.
A few rules keep schedules sane:
- A time up to two minutes in the past is accepted. Anything older fails with `CONTENT_SCHEDULE_IN_PAST`.
- An unpublish cannot come before a pending publish (`CONTENT_SCHEDULE_ORDER`).
- Booking emits `content.example.article.scheduled` and cancelling emits `schedule_cancelled`. When the time comes, the usual `published` or `unpublished` event carries the `scheduleId`.
- A scheduled publish of a [hidden](/docs/content-engine/content-visibility) record leaves it hidden, so it stays off the public site until someone unhides it.
If a separate front end caches pages, [Notify a separate front end](/docs/content-engine/caching#notify-a-separate-front-end) tells it about the change.
# Events (/docs/content-engine/events)
Each content type emits events named `content..` after a change, such as `content.example.article.published`. Subscribe to them to send a newsletter, sync an external index or purge a CDN, without touching the code that made the change.
Events come from the generated routes, scheduled publishing and [`contentEditorialEffects`](/docs/content-engine/services#save-like-the-admincp). The plain `service(c)` emits nothing. A save that changes nothing emits nothing either.
## Event reference [#event-reference]
Which events exist depends on what the definition enables. `example.category` declares only fields and emits three; `example.article` has publication, editorial with scheduling and delivery, and emits ten.
| Action | Payload | Needs |
| --------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ |
| `created` | `{ contentId, duplicatedFromId? }` | always |
| `updated` | `{ contentId, changedFields }` | always |
| `deleted` | `{ contentId }` | always |
| `published` | `{ contentId, publishedAt, scheduledBy?, scheduleId? }` | `publication` |
| `unpublished` | `{ contentId, scheduledBy?, scheduleId? }` | `publication` |
| `restored` | `{ contentId, changedFields, version, revisionId, restoredFromRevisionId }` | `editorial` |
| `scheduled` | `{ contentId, action, actorUserId, scheduledFor, scheduleId }` | `editorial.scheduling` |
| `schedule_cancelled` | `{ contentId, action, actorUserId, scheduleId }` | `editorial.scheduling` |
| `translation_created` | `{ contentId, locale, languageId, version, revisionId? }` | `localization` |
| `translation_updated` | `{ contentId, locale, languageId, version, changedFields, revisionId? }` | `localization` |
| `translation_deleted` | `{ contentId, locale, languageId, version, revisionId? }` | `localization` |
| `translation_published` | `{ contentId, locale, languageId, version, publishedAt, revisionId? }` | `localization` + `publication` |
| `translation_unpublished` | `{ contentId, locale, languageId, version, revisionId? }` | `localization` + `publication` |
| `translation_restored` | `{ contentId, locale, languageId, version, changedFields, revisionId, restoredFromRevisionId }` | `localization` + `editorial` |
| `delivery_slug_changed` | `{ contentId, slug, previousSlug, previousPath, canonicalPath, locale }` | `delivery` |
| `delivery_redirect_created` | `{ contentId, previousSlug, previousPath, canonicalPath, locale }` | `delivery` |
| `duplicated` | `{ actorUserId, contentId, contentTypeId, sourceId }` | `duplication` |
| `hidden` | `{ actorUserId, contentId, hiddenAt }` | `visibility` |
| `unhidden` | `{ actorUserId, contentId }` | `visibility` |
A few payload details that change what a listener does:
- `contentId` has the type of the content type's [id strategy](/docs/content-engine/id-strategy): a `number` for `serial`, a `string` for `uuid` and `bigint`. A bigint id arrives as its exact decimal string.
- A [duplicate](/docs/content-engine/duplicate-content) emits the copy's `created` with `duplicatedFromId`, one `translation_created` per copied language, then `duplicated`. `duplicatedFromId` is absent on an ordinary create.
- `hidden` and `unhidden` never replace `published` or `unpublished`, because [hiding](/docs/content-engine/content-visibility) does not change the status.
- `changedFields` lists only the fields whose value actually moved, typed as the content type's own field names. On translation events it names localized fields only.
- `restored` replaces `updated` for a rollback, so a listener that cares about field changes should handle both.
- `publishedAt` is when the record first went live. It is never rewritten, and it can be missing, so guard for `undefined`.
- `scheduledBy` and `scheduleId` are set only when a schedule fired the change. `actorUserId` on `scheduled` is the same person as `scheduledBy` later.
- The delivery events arrive alongside `updated`, `restored` or a publication event, never instead of them. `locale` is `null` for a shared slug.
[Container collections](/docs/content-engine/containers) emit their own four events, `content.container.created`, `updated`, `moved` and `deleted`, listed in [Container hierarchy](/docs/content-engine/container-hierarchy#events).
A record waiting for [approval](/docs/content-engine/approvals) emits its `published` or `updated` only when a moderator approves it, once. The submission itself emits `content.approval.submitted`, `approved` and `rejected`, listed in [Built-in events](/docs/dev/events/built-in-events#content-approval-events).
The listener also receives an envelope with the actor, the time and `pluginId`. `pluginId` is always the plugin that owns the content type, even when a core queue task ran the change. Field-by-field descriptions are in [Built-in events](/docs/dev/events/built-in-events#content-engine-events).
## Subscribe to an event [#subscribe-to-an-event]
### Register the event types [#register-the-event-types]
Add the content type to the global event map once per plugin. The listener's event name and payload are then type-checked, and an event the definition does not enable, such as `content.example.category.published`, does not compile.
```ts title="plugins/example/src/api/lib/events.ts"
import type { ContentEventsFor } from "@vitnode/core/content";
import type { articleContentType } from "@/content/article";
import type { categoryContentType } from "@/content/category";
declare module "@vitnode/core/api/models/events" {
interface VitNodeEvents
extends
ContentEventsFor,
ContentEventsFor {}
}
```
The example plugin imports this file from `config.api.ts` with `import "@/api/lib/events";`, so the declaration travels with the API build.
### Write the listener [#write-the-listener]
`contentEventName(id, action)` builds the name from the definition, so a renamed content type cannot leave a stale string behind:
```ts title="plugins/example/src/api/lib/listeners.ts"
import { buildEventListener } from "@vitnode/core/api/lib/events";
import { contentEventName } from "@vitnode/core/content";
import { articleContentType } from "@/content/article";
export const announceArticleListener = buildEventListener({
event: contentEventName(articleContentType.id, "published"),
name: "announce-published-article",
description: "Logs every article that goes live",
handler: async (c, payload) => {
await c.get("log").debug(`Article ${payload.contentId} was published`);
},
});
```
### Add it to a top-level module [#add-it-to-a-top-level-module]
VitNode collects listeners only from the `events` array of a module passed directly to `buildApiPlugin`. A listener on a nested module is silently ignored.
```ts title="plugins/example/src/api/modules/admin/admin.module.ts"
export const adminModule = buildModule({
name: "admin",
routes: [],
modules: [
buildContentAdminModule({
contentTypes: [articleContent],
}),
],
events: [announceArticleListener], // [!code ++]
});
```
The blog plugin does the same with its `blogLegacyEventListeners`, which turn `content.blog.post.created` into the older `blog.post.created` event.
## Check the result [#check-the-result]
Save the file, publish an article in **AdminCP → Example → Articles** and look for the debug line in the API terminal. A failing listener does not undo the change. The write has already committed, and the engine logs the failure.
Events from a scheduled publish run in a queue task that retries on failure, so the same `published` event can arrive twice. Make work that must happen once idempotent, for example keyed on `scheduleId`. See [A scheduled event may arrive twice](/docs/dev/events/built-in-events#a-scheduled-event-may-arrive-twice).
# Fields (/docs/content-engine/fields)
A field is one property of a content type, such as a title or a view count. You build each one with a helper from `field`, exported by `@vitnode/core/content`. The helper decides three things at once: the Postgres column, the validation of every write and the control in the AdminCP form.
```ts title="plugins/example/src/content/article.ts"
import { defineContentType, field } from "@vitnode/core/content";
export const articleContentType = defineContentType({
id: "example.article",
tableName: "example_articles",
fields: {
title: field.text({ required: true, minLength: 3, maxLength: 200 }),
slug: field.slug({ source: "title" }),
views: field.number({ integer: true, min: 0, defaultValue: 0 }),
featured: field.boolean({ defaultValue: false }),
author: field.user(),
},
});
```
The example plugin's article form shows most controls. The first half holds the plain values:
The second half holds the references: a user picker, two file dropzones and a relation picker.
## Field helpers [#field-helpers]
| Helper | Postgres column | AdminCP control | Can be `localized` |
| -------------------- | -------------------------------------------------- | ---------------------------------------- | ------------------ |
| `field.text()` | `varchar(maxLength)`, 255 by default | Text input | Yes |
| `field.textarea()` | `text` | Textarea | Yes |
| `field.richText()` | `jsonb` | Rich text editor | Yes |
| `field.number()` | `integer` or `double precision` | Number input with steppers | No |
| `field.boolean()` | `boolean` | Switch | No |
| `field.enum()` | `varchar(length)`, 64 by default | Select or radio buttons | No |
| `field.dateTime()` | `timestamp` | Date and time picker | No |
| `field.slug()` | `varchar(maxLength)`, 160 by default, unique | Text input | Yes |
| `field.user()` | `integer` referencing `core_users.id` | User picker | No |
| `field.file()` | `integer` referencing `core_files.id` | File dropzone | No |
| `field.relation()` | The target's key type, referencing its table | Search picker | No |
| `field.container()` | `integer` referencing `core_content_containers.id` | Container tree picker | No |
| `field.group()` | One column per leaf | A fieldset with the leaf controls | Yes, as a whole |
| `field.repeatable()` | A child table | A list of rows you can add, move, remove | No |
| `field.blocks()` | `jsonb`, `NOT NULL DEFAULT '[]'` | Not in the form; edited as widgets | Yes |
`user`, `file` and `relation` with `multiple: true` store their values in a junction table instead of a column. Localized fields live in the translation table. See [Translations](/docs/content-engine/localization).
## Shared options [#shared-options]
Every helper except `slug`, `repeatable` and `blocks` accepts these:
| Name | Type | Required | Default | Description |
| ------------- | --------- | -------- | ------- | ---------------------------------------------------------- |
| `required` | `boolean` | No | `false` | A create request must include the field |
| `nullable` | `boolean` | No | `false` | The column accepts `NULL`, and so does validation |
| `description` | `string` | No | | Text or an i18n key shown under the control in the AdminCP |
A field that is neither required nor nullable needs a fallback, or `defineContentType` throws `Field "x" is neither required nor nullable, so it needs a default value`. A `defaultValue`, `defaultNow: true` on a date and `source` on a slug all count as a fallback. On update every field is optional.
`required: true` on a text field still accepts an empty string. Add `minLength: 1` when you need actual text.
## Text and textarea [#text-and-textarea]
```ts
code: field.text({ required: true, maxLength: 100, unique: true }),
excerpt: field.textarea({ maxLength: 500, nullable: true }),
```
| Name | Type | Required | Default | Description |
| -------------- | --------- | -------- | -------------------- | ------------------------------------------------------------------------ |
| `minLength` | `number` | No | | Shortest accepted value, in characters |
| `maxLength` | `number` | No | 255 column on `text` | Longest accepted value. On `text` it is also the `varchar` length |
| `defaultValue` | `string` | No | | Written when a create leaves the field out. Becomes the column `DEFAULT` |
| `unique` | `boolean` | No | `false` | `text` only. Adds a unique index. Has no effect on a localized field |
| `localized` | `boolean` | No | `false` | One value per language |
| `ai` | object | No | | Adds an AI suggestion button, see [AI suggestions](#ai-suggestions) |
Set `maxLength` on every `text` field. Without it, validation accepts any length, and Postgres rejects anything over 255 characters with a database error instead of a validation message. A `defaultValue` must satisfy the field's own `minLength` and `maxLength`.
## Rich text [#rich-text]
```ts
content: field.richText({ localized: true, required: true }),
```
| Name | Type | Required | Default | Description |
| ----------- | --------- | -------- | ------- | ------------------------------------------------------- |
| `maxBytes` | `number` | No | 1 MB | Largest document, measured as UTF-8 JSON. At most 16 MB |
| `localized` | `boolean` | No | `false` | One document per language |
| `uploads` | `boolean` | No | `false` | The AdminCP editor accepts images, see [Editor images](/docs/ui/editor-images) |
The value is a ProseMirror JSON document. A rich text field has no `defaultValue`, so set `required` or `nullable`. With `required: true` a document without any text or media is refused. Documents nested deeper than 64 levels are refused too. [Rich text](/docs/content-engine/rich-text) covers rendering and the plain text and HTML helpers.
## Number [#number]
```ts
views: field.number({ integer: true, min: 0, defaultValue: 0 }),
```
| Name | Type | Required | Default | Description |
| -------------- | --------- | -------- | ------- | ------------------------------------------------------------------------- |
| `integer` | `boolean` | Yes | | `true` makes an `integer` column, `false` a `double precision` one |
| `min`, `max` | `number` | No | | Accepted range. With `integer: true` a whole number must fit between them |
| `defaultValue` | `number` | No | | Must sit inside the range, and be whole when `integer: true` |
## Boolean [#boolean]
```ts
featured: field.boolean({ defaultValue: false }),
```
The only option besides the shared ones is `defaultValue`. A nullable boolean, such as `noIndex: field.boolean({ nullable: true })`, can hold `true`, `false` or `null`.
## Enum [#enum]
```ts
difficulty: field.enum({ values: ["easy", "medium", "hard"], defaultValue: "easy", display: "radio" }),
```
| Name | Type | Required | Default | Description |
| -------------- | ----------------------- | -------- | ---------- | -------------------------------------- |
| `values` | `string[]` | Yes | | Non-empty, without duplicates |
| `defaultValue` | one of `values` | No | | |
| `display` | `"select"` \| `"radio"` | No | `"select"` | AdminCP control |
| `length` | `number` | No | `64` | `varchar` length. Every value must fit |
The column is a plain `varchar`, not a Postgres enum, so adding a value later needs no type migration. The AdminCP translates each value from `{pluginId}.content.{entity}.enums.{field}.{value}` when that key exists.
## Date and time [#date-and-time]
```ts
eventDate: field.dateTime({ defaultNow: true }),
```
| Name | Type | Required | Default | Description |
| ------------ | --------- | -------- | ------- | ------------------------------------------------ |
| `defaultNow` | `boolean` | No | `false` | Fills the column with the current time on insert |
There is no `defaultValue`. Send dates as ISO 8601 strings.
## Slug [#slug]
```ts
slug: field.slug({ source: "title" }),
```
| Name | Type | Required | Default | Description |
| ------------- | -------------------- | -------- | ------- | ---------------------------------------------------------- |
| `source` | name of a text field | No | | Fills the slug from that field when a create sends no slug |
| `generated` | `boolean` | No | `false` | Mints a random token when a create sends no slug. Not with `source` |
| `maxLength` | `number` | No | `160` | `varchar` length and the point where long slugs are cut |
| `localized` | `boolean` | No | `false` | One slug per language |
| `description` | `string` | No | | Shown under the input |
A slug has no `required` or `nullable` option. It is required when there is no `source`, and it is never empty. Every value is normalized, so `"Weekend Pancakes!"` becomes `weekend-pancakes`. Slugs are unique across the table, or within each language when localized. The slug is derived only on create, so renaming the title later keeps the old URL. `source` must name a `field.text()`, and a localized slug needs a localized source.
For records without a natural title, such as forum comments, use `field.slug({ generated: true })`. The server mints a random token of lowercase letters and digits, 10 characters or `maxLength` if shorter, such as `k3v9q2m7xa`. A generated slug cannot be `localized` or have a `source`, and it is never [publicly writable](/docs/content-engine/public-write-api#writable-fields). See [Threaded replies](/docs/content-engine/threads).
## User [#user]
```ts
author: field.user(),
authorId: field.user({ multiple: true, ordered: true, min: 1 }),
```
| Name | Type | Required | Default | Description |
| ---------- | ------------------------------------------- | -------- | --------- | ------------------------------------------ |
| `multiple` | `boolean` | No | `false` | Several people, stored in a junction table |
| `ordered` | `boolean` | No | `false` | Keeps the order editors arrange |
| `min` | `number` | No | | Fewest people. Needs `multiple` |
| `onDelete` | `"cascade"` \| `"restrict"` \| `"set null"` | No | see below | What deleting the user does |
A single user field is nullable unless you set `required: true`. `onDelete` defaults to `"set null"` for a nullable single user, `"restrict"` for a required one and `"cascade"` for `multiple`. User fields cannot be listed in `publicApi.fields`. To publish an author's name and avatar, name a single, shared user field in [`publicApi.people`](/docs/content-engine/reference#publicapi) instead.
## File [#file]
```ts
animation: field.file({
maxBytes: 10 * 1024 * 1024,
allowedExtensions: [".gif"],
allowedMimeTypes: ["image/gif"],
}),
gallery: field.file({ multiple: true, min: 1, max: 8, maxBytes: 5 * 1024 * 1024 }),
```
| Name | Type | Required | Default | Description |
| ------------------- | ---------- | -------- | ------------------ | ------------------------------------------ |
| `maxBytes` | `number` | Yes | | Largest accepted file, in bytes |
| `allowedExtensions` | `string[]` | No | any | Lowercased, with a leading dot added |
| `allowedMimeTypes` | `string[]` | No | any | Lowercased |
| `multiple` | `boolean` | No | `false` | Several files in a junction table |
| `min` | `number` | No | | Fewest files, at least 1. Needs `multiple` |
| `max` | `number` | No | `20` | Most files, up to 200. Needs `multiple` |
| `ordered` | `boolean` | No | same as `multiple` | Keeps the order editors arrange |
A single file field is nullable unless you set `required: true`, and it cannot have a default or be localized. Translate a caption or alt text in a separate localized text field instead. Uploads go through the [storage](/docs/dev/storage) adapter and land in `core_files`. A file in use cannot be deleted, because the reference is `ON DELETE RESTRICT`.
The public API returns a file as `{ id, name, url, mimeType, size, width, height }`, never as a raw id. A write may only point at a file uploaded for this content type, uploaded by the signed-in user, or already on the record. The API refuses anything else with `CONTENT_FILE_NOT_FOUND`.
## Relation [#relation]
```ts
category: field.relation({ required: true, onDelete: "restrict", target: () => categoryContentType }),
```
| Name | Type | Required | Default | Description |
| ---------- | ------------------------------------------- | ------------------ | ------------ | ---------------------------------------------------------------------------- |
| `target` | `() => ContentTypeDefinition` | Yes, unless `self` | | The linked content type |
| `self` | `true` | No | `false` | Links to the same content type. Do not combine with `target` |
| `multiple` | `boolean` | No | `false` | Uses a junction table, up to 500 targets per record |
| `ordered` | `boolean` | No | `false` | Keeps the editor's order. Needs `multiple` |
| `min` | `number` | No | | Fewest targets, 1 to 500. Needs `multiple` |
| `onDelete` | `"restrict"` \| `"cascade"` \| `"set null"` | No | `"restrict"` | What deleting the target does. `"set null"` needs a nullable single relation |
| `container` | `boolean` | No | `false` | The record lives in the target record's [container](#container). Required, to-one, never `self` |
A single relation must be `required` or `nullable`. A to-many relation can be neither. A to-one `self: true` relation, such as the reply a reply answers, must be `nullable`, because the first record has nothing to point at.
`container: true` files the record in the container of the record it points at, such as a comment in its topic's forum. The server always reads that container from the related row, so a client cannot claim another one, and [publishing policies](/docs/content-engine/container-policies) and container-scoped moderators apply as if the record had its own `field.container()`. A content type takes its container from one field only: one `field.container()` or one `container: true` relation. The target must have a `field.container()`, or the API stops at boot with ``Field "x" is `container: true`, but its target ... is not filed in a container.`` The column has the type of the target's key: `integer`, `uuid` or `bigint`, depending on its [`idStrategy`](/docs/content-engine/id-strategy). [Relations](/docs/content-engine/relations) shows the model wiring and the junction tables.
## Container [#container]
```ts
board: field.container({ definition: boardContainers, required: true }),
```
| Name | Type | Required | Default | Description |
| ------------- | --------------------------------- | -------- | ------- | ------------------------------------------------------------------ |
| `definition` | `defineContentContainer()` result | Yes | | The collection the record's container comes from |
| `required` | `boolean` | No | `false` | Every record needs a container. Without it, a record may have none |
| `description` | `string` | No | | Text or an i18n key shown under the picker in the AdminCP |
`field.container()` files a record into one container of a [container collection](/docs/content-engine/containers), such as a discussion into a forum board. `nullable` is always the opposite of `required`, so there is no `nullable` option, no default and no `localized`. A content type can have at most one container field, and its collection must be registered with `buildContentAdminModule({ containers })`.
The column is an `ON DELETE RESTRICT` foreign key with an index on the field (plus `publishedAt` when `publication` is on), so a container that still holds records cannot be deleted. Every write checks that the id belongs to the field's collection and answers `400` with `CONTAINER_NOT_FOUND` or `CONTAINER_DEFINITION_MISMATCH` otherwise.
The public API returns the container as its id. In `publicApi.filterableFields`, it filters a list with `?board=4`, and `includeDescendants=true` widens that to the whole subtree. In `publicWrite`, it may be listed in `create.fields` but not in `update.fields`. [Containers on the frontend](/docs/content-engine/container-frontend) shows both.
## Group [#group]
```ts
syndication: field.group({
fields: {
noIndex: field.boolean({ defaultValue: false }),
priority: field.number({ integer: true, min: 0, max: 10, defaultValue: 5 }),
},
}),
```
| Name | Type | Required | Default | Description |
| ----------- | --------------------- | -------- | ------- | -------------------------------------------------------------------- |
| `fields` | object of leaf fields | Yes | | `text`, `textarea`, `number`, `boolean`, `enum` or `dateTime` leaves |
| `localized` | `boolean` | No | `false` | Moves every leaf to the translation table together |
Each leaf becomes a column named ``, such as `syndicationPriority`. Leaves cannot set `localized` themselves. An optional group needs leaves that are nullable or have a default. A nullable group needs nullable leaves.
## Repeatable [#repeatable]
```ts
faq: field.repeatable({
max: 20,
fields: {
question: field.text({ required: true, minLength: 3, maxLength: 200 }),
answer: field.textarea({ required: true }),
},
}),
```
| Name | Type | Required | Default | Description |
| ------------- | --------------------- | -------- | ------- | -------------------------- |
| `fields` | object of leaf fields | Yes | | Same leaf kinds as a group |
| `min` | `number` | No | `0` | Fewest rows |
| `max` | `number` | No | `100` | Most rows, up to 1000 |
| `description` | `string` | No | | Shown in the AdminCP form |
Rows live in a child table. A repeatable is always shared between languages.
## Blocks [#blocks]
```ts
content: field.blocks({ allowed: ["core:*", "example:callout", "example:features"], max: 50 }),
```
| Name | Type | Required | Default | Description |
| ------------- | ------------------- | -------- | ------- | ---------------------------------------------- |
| `allowed` | `"*"` or `string[]` | No | `"*"` | Which blocks may be placed, such as `"core:*"` |
| `min` | `number` | No | | Fewest block instances |
| `max` | `number` | No | `200` | Most block instances, up to 1000 |
| `localized` | `boolean` | No | `false` | Each language gets its own blocks |
| `description` | `string` | No | | |
`field.widgets()` is the same helper under another name. A blocks field is never required or nullable. No blocks means an empty list. It does not appear in the AdminCP form. Editors place widgets on the page itself, as described in [The blocks() field](/docs/dev/widgets/blocks-field).
## AI suggestions [#ai-suggestions]
`text` and `textarea` fields accept an `ai` option. The form then shows an **AI** button next to the label, like the excerpt in the screenshot above.
```ts
excerpt: field.textarea({
ai: {
action: "@vitnode/example:article.excerpt",
mode: "suggestion",
sourceFields: ["title", "code"],
},
maxLength: 500,
nullable: true,
}),
```
| Name | Type | Required | Default | Description |
| -------------- | ------------------------- | -------- | ------- | ---------------------------------------------------------------------- |
| `action` | `":"` | Yes | | A registered AI action |
| `mode` | `"suggestion"` | Yes | | The only supported mode. The editor reviews the text before it is used |
| `sourceFields` | `string[]` | Yes | | Other fields of this content type sent to the action as input |
[AI in fields and the editor](/docs/dev/ai/fields-and-editor) shows how to write the action.
## Searching and sorting [#searching-and-sorting]
Fields have no `searchable` or `sortable` option. Each surface picks its own fields: `admin.list.searchableFields` and `orderableFields` for the AdminCP list, `publicApi.searchableFields`, `orderableFields` and `filterableFields` for the public API, and `search.contentFields` for site search. The [content type reference](/docs/content-engine/reference) lists which field kinds each one accepts.
# Record ID strategies (/docs/content-engine/id-strategy)
`idStrategy` decides the type of a content type's primary key. The default, `"serial"`, is an auto-incrementing integer. `"uuid"` gives random ids that reveal nothing about how many records exist. `"bigint"` gives integers that will not run out. Pick it when you create the content type, because changing it later means a [hand-written data migration](#change-the-strategy-later), and nobody enjoys those.
The example plugin keys its tags by UUID:
```ts title="plugins/example/src/content/tag.ts"
export const tagContentType = defineContentType({
id: "example.tag",
tableName: "example_tags",
idStrategy: "uuid", // [!code ++]
localization: { defaultLocale: "en", fallback: "default" },
publication: true,
fields: {
name: field.text({ localized: true, required: true, minLength: 1, maxLength: 100 }),
slug: field.slug({ localized: true, source: "name" }),
// category and relatedTags relations
},
// editorial, visibility, duplication, publicApi and admin
});
```
## Strategies [#strategies]
| Strategy | Postgres key | In JSON and URLs | In TypeScript |
| -------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------- |
| `"serial"` (default) | `serial`, up to 2,147,483,647 | A number: `42` | `number` |
| `"uuid"` | `uuid`, `DEFAULT gen_random_uuid()` | A lowercase string: `"047d2c6c-1d86-47e2-bcd3-2df9f5359541"` | `string` |
| `"bigint"` | `bigint GENERATED BY DEFAULT AS IDENTITY`, up to 9,223,372,036,854,775,807 | A decimal string: `"9007199254740994"` | `string` |
Choose by what the ids are for:
- **`serial`** suits most content. Ids are short and readable in URLs and logs. The blog's articles and categories use it.
- **`uuid`** suits content whose ids should not be guessable or countable, or records created in several places that must not collide. The example plugin's `example.tag` uses it.
- **`bigint`** suits tables that may outgrow two billion rows, such as events or logs. The example plugin's `example.event` uses it.
Both example types appear in **AdminCP → Example → Tags** and **Events**, and their edit links carry the UUID or the decimal string, such as `/admin/content/example/event/9007199254740994/edit`.
### Why bigint ids are strings [#why-bigint-ids-are-strings]
A JavaScript number holds integers exactly only up to 2^53 − 1 (`9007199254740991`). A bigint key can be much larger, and a larger id read as a number is silently rounded to a different record: `Number("9007199254740993")` is `9007199254740992`. VitNode never converts a bigint id through `Number`. Drizzle reads the column in `string` mode, and every API, event, cursor and AdminCP URL carries the exact decimal string.
Once the `example_events` id sequence is past 2^53, the public API still returns every id intact:
```bash
curl "http://localhost:3000/api/@vitnode/example/content/events/big-launch"
```
```json title="Response (trimmed)"
{
"id": "9007199254740994",
"title": "Big launch",
"slug": "big-launch",
"primaryTag": { "id": "047d2c6c-1d86-47e2-bcd3-2df9f5359541" },
"tags": ["047d2c6c-1d86-47e2-bcd3-2df9f5359541"]
}
```
`primaryTag` and `tags` point at `example.tag` records, so they carry UUIDs. Keep bigint ids as strings in your own code too.
## Valid ids [#valid-ids]
Every route that takes an id parses it with the content type's strategy before touching the database. Anything else answers `400`, never a database error:
| Strategy | Accepted | Refused, for example |
| -------- | ---------------------------------------------------------------------------------------- | -------------------------------------- |
| `serial` | A positive integer up to 2,147,483,647, or its digits in a URL | `abc`, `0012`, `-1` |
| `bigint` | Positive decimal digits up to 9,223,372,036,854,775,807, without a sign or leading zeros | `0012`, `-1`, `9223372036854775808` |
| `uuid` | Lowercase, hyphenated 8-4-4-4-12 hex | An uppercase UUID, `not-a-uuid`, `123` |
Each record has exactly one spelling, so one record is never cached or indexed under two keys.
## Where ids appear [#where-ids-appear]
Every Content Engine feature works with all three strategies: the public API, the AdminCP lists, editors, pickers and edit links, [public pages](/docs/content-engine/public-pages) and sitemaps, previews, schedules, revisions, [live editing](/docs/content-engine/live-editing), events, search, pagination cursors, [duplicate](/docs/content-engine/duplicate-content) and [hide](/docs/content-engine/content-visibility).
- **Relations** use the key type of the record they point at. A bigint event that links to uuid tags gets a `uuid` foreign key and a `bigint`-to-`uuid` junction table. See [Relations across id strategies](/docs/content-engine/relations#relations-across-id-strategies).
- **Shared core tables** that hold records of any content type, such as revisions, schedules, slug history, live editing drafts and the search index, store the id as text in a `varchar(64)` `itemId` column. See [Database](/docs/content-engine/database#ids-and-shared-tables).
- **Events** type `contentId` by strategy: `number` for serial, `string` for uuid and bigint.
- **Sitemaps** page through records in key order. For a uuid content type that order is lexical, not creation order, and every record still appears once.
- **Repeatable rows** keep their own `serial` ids, whatever the owner uses. User and file references stay integers.
## Helpers [#helpers]
Import these from `@vitnode/core/content` when your own code handles ids:
| Helper | Returns | Use it to |
| -------------------------------------- | -------------------- | ---------------------------------------------------------------------------- |
| `ContentIdOf` | `number` or `string` | Type an id of one content type |
| `ContentId` | `number \| string` | Type an id in code that handles any content type |
| `parseContentIdOf(definition, value)` | The id, or `null` | Validate an id from a request, a job payload or a URL |
| `parseContentId(strategy, value)` | The id, or `null` | The same, when you hold only the strategy |
| `contentIdParamSchema(strategy)` | A Zod schema | Validate a path or query parameter. A serial id becomes a number |
| `contentIdSchema(strategy)` | A Zod schema | Validate an id inside a JSON body |
| `contentAnyIdSchema` | A Zod schema | Accept an id of any strategy, never rounding a bigint |
| `contentIdKey(id)` | `string` | Store an id in a text column, cache key or log. Serial ids keep their digits |
| `contentIdFromKey(strategy, key)` | The id, or `null` | Turn a stored key back into an id |
| `compareContentIds(strategy, a, b)` | `number` | Sort ids the way Postgres does |
`CONTENT_SERIAL_MAX` (`2147483647`) and `CONTENT_BIGINT_MAX` (`"9223372036854775807"`) are exported too. A custom route that reads an id is shown in [Services](/docs/content-engine/services#ids-from-a-url).
## Change the strategy later [#change-the-strategy-later]
The strategy is part of the table's schema, and the engine does not convert existing data. Don't change `idStrategy` on a content type that has records and then run `db:migrate`. The generated SQL cannot turn integer keys into UUIDs.
To switch a live content type, write the data migration yourself:
1. Add the new key column and fill it for every row.
2. Convert every column that references the old key: to-one relation columns in other content types, junction tables (`itemId` and `relatedItemId`), the translations table's `itemId` and repeatable child tables' `itemId`.
3. Rewrite the keys stored for that content type in the shared tables: `core_content_revisions`, `core_content_schedules`, `core_content_slug_history`, `core_search_index`, `core_content_drafts`, `core_content_field_locks`, `core_content_documents` and `core_ai_translation_sources`.
4. Swap the primary key and foreign key constraints, then change `idStrategy` and check that generating migrations again produces no further changes.
Old URLs and API clients that stored the previous ids stop working, so plan redirects if ids appear in public URLs. Creating a new content type and copying the records into it is often simpler.
# Introduction (/docs/content-engine)
The Content Engine turns one TypeScript definition into structured content for your plugin. You describe the fields of an article or a category, and VitNode generates the database table, Zod validation, staff-only API routes, staff permissions and AdminCP screens. Optional blocks add drafts, a public API, pages with SEO metadata, revisions, scheduling, live editing, translations, search, hiding and duplication.
This is the whole definition behind the example plugin's **Categories** screen:
```ts title="plugins/example/src/content/category.ts"
import { defineContentType, field } from "@vitnode/core/content";
export const categoryContentType = defineContentType({
id: "example.category",
tableName: "example_categories",
fields: {
name: field.text({ required: true, minLength: 1, maxLength: 100 }),
},
admin: {
path: "example/categories",
list: {
columns: ["name", "createdAt"],
orderableFields: ["name"],
},
},
});
```
Its sibling `example.article` turns on more blocks. Same engine, more fields, a busier list:
## On the public site [#on-the-public-site]
The same definition also drives the public side. Add `publicApi` and the records become readable; a few more options and members write their own. The example plugin's discussions use all of it.
A list with search, filters and pages, from [Public lists](/docs/content-engine/public-lists):
A page for each record, from [Public pages](/docs/content-engine/public-pages), with **Report** from [Report content](/docs/content-engine/reports):
And a form members post with, from [Public create form](/docs/content-engine/public-write-frontend):
## What you get [#what-you-get]
| From the definition | VitNode generates |
| ------------------- | -------------------------------------------------------------------------------------- |
| `fields` | A Postgres table with system columns and indexes, Zod schemas and a typed service |
| `admin` | An AdminCP list with search, sorting and bulk actions, plus create and edit forms |
| Every content type | Staff routes and the `can_view`, `can_create`, `can_edit` and `can_delete` permissions |
| `publication` | Draft and published states, a `can_publish` permission and **Publish** buttons |
| `publicApi` | Read-only public routes that return only the fields you list |
| `publicWrite` | Create and edit routes for signed-in members, with server-set ownership |
| `reports` | A **Report** button readers use and a moderators' queue |
| `thread` | Nested replies with a route that returns the tree a page at a time |
| `delivery` | Page URLs, SEO metadata, slug redirects and sitemap entries |
| `editorial` | Revisions with restore, preview links and scheduling |
| `liveEditing` | Field locks, an autosaved shared draft and presence in the AdminCP edit form |
| `localization` | A translations table and per-language fields and publishing |
| `search` | Records in the site [search](/docs/content-engine/search) index |
| `visibility` | **Hide** and **Unhide** actions and a `can_hide` permission |
| `duplication` | A **Duplicate** action that copies a record into a new draft |
Content types live in code and in your migrations, not in the database. There is no screen for creating a content type at runtime.
## Pick a guide [#pick-a-guide]
| You want to | Guide |
| -------------------------------------------------------- | --------------------------------------------------------------------- |
| Build your first content type, with its AdminCP screen | [First content type](/docs/content-engine/create-a-content-type) |
| Let anyone read published records through an API | [Public API](/docs/content-engine/public-api) |
| Understand how public reads are cached and refreshed | [Caching](/docs/content-engine/caching) |
| Give each record its own page with SEO metadata | [Public pages](/docs/content-engine/public-pages) |
| Keep drafts private until an editor publishes them | [Publication](/docs/content-engine/publication) |
| Show a list with search, filters and pages | [Public lists](/docs/content-engine/public-lists) |
| Let members create and edit records from the site | [Public write](/docs/content-engine/public-write) |
| Let readers report content to moderators | [Report content](/docs/content-engine/reports) |
| Control titles, descriptions, noindex and the sitemap | [SEO](/docs/content-engine/seo) |
| Make records findable in the site search | [Search](/docs/content-engine/search) |
| Link records, group fields or repeat rows | [Relations](/docs/content-engine/relations) |
| Organize records into boards, sections or categories | [Containers](/docs/content-engine/containers) |
| Show a container tree and its content on the site | [Containers on the frontend](/docs/content-engine/container-frontend) |
| Decide who posts in each container and what needs review | [Publishing policies](/docs/content-engine/container-policies) |
| Let moderators approve new posts and edits first | [Approvals](/docs/content-engine/approvals) |
| Build nested replies under a topic or a post | [Threaded replies](/docs/content-engine/threads) |
| Translate content into several languages | [Translations](/docs/content-engine/localization) |
| Store and render formatted text | [Rich text](/docs/content-engine/rich-text) |
| Keep revisions, share previews or schedule publishing | [Editorial](/docs/content-engine/editorial) |
| Let several editors work on one record at the same time | [Live editing](/docs/content-engine/live-editing) |
| Take a record off the public site without unpublishing | [Hide content](/docs/content-engine/content-visibility) |
| Let editors copy a record as a new draft | [Duplicate content](/docs/content-engine/duplicate-content) |
| Key records by UUID or bigint instead of serial integers | [Record ID strategies](/docs/content-engine/id-strategy) |
| Change list columns, form sections or field components | [AdminCP](/docs/content-engine/admincp) |
| Read and write content from your own API routes | [Services](/docs/content-engine/services) |
| React to created, updated or published records | [Events](/docs/content-engine/events) |
| Understand the generated tables and ship a schema change | [Database](/docs/content-engine/database) |
| Check a content type before it goes live | [Production](/docs/content-engine/production) |
## Reference [#reference]
- [Options](/docs/content-engine/reference): every `defineContentType` option and how the options depend on each other.
- [Fields](/docs/content-engine/fields): every `field.*` helper with its options, column type and form control.
- [Plugin files](/docs/content-engine/plugin-files): the files and exports the app and the API read from your plugin.
- [Live editing API](/docs/content-engine/live-editing-api): the HTTP API and deployment requirements behind live editing.
- [Publishing policies reference](/docs/content-engine/container-policies-api): policy keys, resolution order, routes and errors.
# Live editing API (/docs/content-engine/live-editing-api)
The AdminCP drives [live editing](/docs/content-engine/live-editing) for you. This page is for the cases where you need the details: deploying it, calling its routes from your own code, or building a form layout that shows who is editing. Examples use `example.article`, whose staff API lives at `/api/@vitnode/example/admin/content/article`.
## Deployment requirements [#deployment-requirements]
Live editing runs on two channels. Field locks and the shared draft are plain HTTP routes, so they work everywhere. Presence and rich text co-editing travel over the [WebSocket](/docs/dev/websocket) at `/api/ws`, which a standalone API server or a single app with the [Nitro WebSocket route](/docs/dev/websocket/setup) provides, on [Vercel](/docs/dev/deployments/cloud/vercel) too.
| Deployment | Field locks and autosave | Presence | Rich text |
| --------------------------------------------- | ---------------------------- | -------- | ----------------------------------------------- |
| WebSocket at `/api/ws` | Live, pushed over the socket | Yes | Co-edited |
| No WebSocket, or while the socket reconnects | Polled over HTTP every 10 s | No | One editor at a time, locked like a plain field |
After a reconnect each editor joins the room again, reloads the locks and the draft, and reopens its rich text documents, which fetches the edits it missed.
A form without a socket polls `GET /{id}/locks` and `GET /{id}/draft` every 10 seconds, on top of lock renewals. Every request counts against the [rate limiter](/docs/dev/advanced/rate-limiter), which is keyed by IP address: the polls against the read budget (300 a minute), lock renewals and draft saves against the write budget (60 a minute). A newsroom behind one office IP can reach the write budget. A refused lock request leaves the field without autosave, so raise `rateLimiter.write.points` if that is you.
With more than one API instance, set `REDIS_URL`. Instances share presence and document updates over Redis pub/sub. Without it, editors connected to different instances do not see each other.
## HTTP routes [#http-routes]
The routes live under the content type's staff API module. Every route needs an AdminCP session and the type's `can_edit` permission, and answers `404` for a record that does not exist.
| Method and path | Body | Success | Errors |
| -------------------------- | --------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
| `GET /{id}/locks` | | `{ locks }`, the unexpired locks | |
| `POST /{id}/locks` | `{ field, locale, action }` | `{ lock }`, or `{ lock: null }` on release | `409` `CONTENT_FIELD_LOCKED`, `400` for an unknown field or wrong locale |
| `GET /{id}/draft` | | `{ shared, translations }` | |
| `PUT /{id}/draft` | `{ locale, values }` | `{ updatedAt }` | `409` `CONTENT_FIELD_NOT_LOCKED`, `400` `CONTENT_DRAFT_WRONG_SCOPE` or `CONTENT_DRAFT_INVALID` |
| `POST /{id}/draft/discard` | `{}` | `{ discarded: true }`, and a `reset` with reason `discarded` to the room | |
- `action` is `acquire`, `renew` or `release`.
- `locale` is `null` for a field that is not localized and an enabled language code for a localized one. A shared field and a localized field never share a draft write.
- A draft write accepts only fields you currently hold a lock on, and validates each value with the field's own rules. It never touches the record; **Save** does that.
Taking a lock on the excerpt:
```http title="Request"
POST /api/@vitnode/example/admin/content/article/2/locks
Content-Type: application/json
{ "field": "excerpt", "locale": null, "action": "acquire" }
```
```json title="200"
{
"lock": {
"expiresAt": "2026-10-10T11:12:14.701Z",
"field": "excerpt",
"locale": null,
"user": { "id": 16, "name": "Anna Kowalska" }
}
}
```
When someone else asks for the same field, the `409` names the holder:
```json title="409"
{
"code": "CONTENT_FIELD_LOCKED",
"lock": {
"expiresAt": "2026-10-10T11:12:14.701Z",
"field": "excerpt",
"locale": null,
"user": { "id": 16, "name": "Anna Kowalska" }
}
}
```
Reading the draft returns the shared fields and one draft per language:
```json title="GET /api/@vitnode/example/admin/content/article/2/draft"
{
"shared": {
"baseVersion": 3,
"updatedAt": "2026-10-10T10:57:20.381Z",
"updatedBy": { "id": 16, "name": "Anna Kowalska" },
"values": { "title": "Spring menu launches Monday at noon" }
},
"translations": {}
}
```
`baseVersion` is the record or translation version the draft was written on.
## WebSocket channel [#websocket-channel]
The socket side is one channel, `@vitnode/core_content_live`, on the shared `/api/ws` connection. Joining a record's room checks the same AdminCP session and `can_edit` as the HTTP routes. Every message carries the record as `{ contentTypeId, itemId }`, and every client message carries the `clientId` of the tab that sent it.
| Direction | Messages |
| --------- | --------------------------------------------------------------------------------------------------------------------------- |
| Client | `join`, `focus`, `heartbeat`, `leave`, and `doc:open`, `doc:update`, `doc:awareness`, `doc:close` for rich text |
| Server | `joined`, `presence`, `locks`, `draft`, `reset`, `error`, and `doc:seed`, `doc:state-vector`, `doc:update`, `doc:awareness` |
Rich text documents are [Yjs](https://yjs.dev) documents, one per record, field and language. Updates travel as base64. The protocol types live in `@vitnode/core/content/live/protocol` if you need them, but the AdminCP is the only client you should need.
## Timings [#timings]
| What | Value |
| ----------------------------------------------- | ---------- |
| Lock lifetime without renewal | 60 s |
| Lock renewal while the field has focus | every 20 s |
| Autosave after the last keystroke | 1.5 s |
| Presence heartbeat | every 15 s |
| Member dropped after missed heartbeats | 45 s |
| Rich text document stored after the last change | 2 s |
| Polling without a socket | every 10 s |
| Untouched drafts and documents removed after | 30 days |
## Where the data lives [#where-the-data-lives]
| Table | Holds |
| -------------------------- | ----------------------------------------------------------------- |
| `core_content_drafts` | The shared draft of plain fields, one row per record and language |
| `core_content_field_locks` | Active field locks with their holder and expiry |
| `core_content_documents` | The Yjs state of each rich text field, per record and language |
Each table keys the record by its id as text in a `varchar(64)` `itemId`, so live editing works with every [id strategy](/docs/content-engine/id-strategy). The socket accepts a uuid or bigint id and checks it against the content type's strategy, refusing a malformed one.
The daily `content-editorial-cleanup` [cron job](/docs/dev/cron) removes expired locks, documents untouched for 30 days, and drafts untouched for 30 days whose record has moved on since. A stale draft whose record has not changed is kept, because it is somebody's unsaved work. When an editor's last tab leaves a record, their locks go too.
## Use live state in a custom form layout [#use-live-state-in-a-custom-form-layout]
A plugin that replaces the edit form with [its own layout](/docs/content-engine/admincp) still gets locks and outlines on every field it renders with `ContentFormField`. The rest of the live UI comes from `@vitnode/core/content/admin-form`:
```tsx title="plugins/example/src/views/admin/article-layout.tsx"
import {
ContentFormField,
ContentLivePresence,
ContentLiveStatus,
} from "@vitnode/core/content/admin-form";
export const ArticleFormLayout = () => (
);
```
Register it with `forms: { layout: ArticleFormLayout }` in the type's `contentTypeAdmin(...)` entry, the way the blog plugin registers `BlogArticleFormLayout`.
| Export | What it gives you |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ContentLivePresence` | Avatars of everyone else in the record. `max` sets how many show before **+N**, 5 by default |
| `ContentLiveFieldPresence` | Avatars of the people in one `field` and `locale` |
| `ContentLiveLanguagePresence` | Who is working in one `locale` |
| `ContentLiveStatus` | The autosave state: **Saving…**, **Autosaved 5 minutes ago** (with an amber dot while the draft is not saved into the record), **Autosave failed** |
| `useContentLive()` | The session: `session.members`, `session.locks`, `status` (`saving`, `savedAt`, `failed`, `dirty`) and `coEditing` |
| `useContentRichTextReplace()` | `(field, locale, document) => void` that replaces a rich text field through the shared editor, so everyone sees it and it can be undone. `null` while rich text is not co-edited |
All of them render nothing, or return `null`, outside a live form, so the same layout works for the create form.
# Live editing (/docs/content-engine/live-editing)
Live editing lets a team work on the same record at the same time without overwriting each other. Changes autosave to a shared draft, everyone sees who is in which field, plain fields are edited by one person at a time, and [rich text](/docs/content-engine/rich-text) fields are typed in together.
## Turn it on [#turn-it-on]
Add `liveEditing: true` next to [`editorial`](/docs/content-engine/editorial). Live editing needs `editorial`, because field locks and the shared draft are measured against the record version it adds. Without it, `defineContentType` throws ``liveEditing needs `editorial` ``, and TypeScript flags the option first:
```ts title="plugins/example/src/content/article.ts"
export const articleContentType = defineContentType({
id: "example.article",
tableName: "example_articles",
editorial: true,
liveEditing: true, // [!code highlight]
fields: {
title: field.text({ required: true, minLength: 3, maxLength: 200 }),
excerpt: field.textarea({ maxLength: 500, nullable: true }),
},
});
```
A live session starts when someone opens an existing record's edit form in the AdminCP. That needs three things:
- An AdminCP session, from signing in at `/admin`.
- The content type's `can_edit` permission. Someone who can only view the record does not join.
- A saved record. The create form is not live, because there is nothing to share yet.
Presence and rich text co-editing also need the API's [WebSocket](/docs/dev/websocket). Without it, autosave and field locks still work over HTTP, a little slower. [Deployment requirements](/docs/content-engine/live-editing-api#deployment-requirements) lists what each setup gets.
## What editors see [#what-editors-see]
### Presence [#presence]
The form header shows an avatar for everyone else in the record, up to five and then **+N**. Hover or focus an avatar to read where that person is, such as "editing Title". A ring in their color means they are in a field right now.
The field they are in gets an outline in their color with a name tag. When they move to another field, the outline glides after them, and nothing on the page shifts to make room. In a localized form, the language switcher marks the languages people are working in. You never see yourself, and one person in several tabs counts once.
### Field locks [#field-locks]
Clicking into a plain field, such as a title, slug or category, takes a lock on that field in that language. Everyone else sees the field dimmed and read-only inside the editor's outline, and the value updates as they type. Screen readers announce "Anna Kowalska is editing".
A lock lasts 60 seconds and renews every 20 seconds while the editor stays in the field. Leaving the field releases it, and so does closing the record. A lock nobody renews, say because a laptop went to sleep, expires on its own.
### Autosave and the shared draft [#autosave-and-the-shared-draft]
About 1.5 seconds after you stop typing in a field you hold, the value is saved to the record's draft. Close the tab and it's still there, but readers don't see it yet. The header shows **Saving…** and then **Autosaved just now**, later **Autosaved 5 minutes ago**, or **Autosave failed** when the write failed. An amber dot in front means the draft holds changes the record doesn't have yet, and it stays until someone clicks **Save changes**. Hover it for a reminder.
Changed your mind? **Discard** next to the autosave time throws the draft away after you confirm, and the form goes back to the last saved version. It is a team draft, so everyone editing the record goes back too, and they get a short note saying so.
There is one draft per record and language, shared by the whole team. When you open the record, you see the draft, not only what was last saved. **Save** commits it through the usual [version check](/docs/content-engine/editorial#safe-concurrent-edits) and creates one revision. Autosave itself never creates revisions, emits no events and leaves search and caches alone. [Preview links](/docs/content-engine/editorial#preview-links) also ignore the draft until someone saves.
### Rich text co-editing [#rich-text-co-editing]
Rich text fields are not locked and get no outline. Everyone in the same field and language types into one shared document and sees each other's caret with a name tag, plus a tint over the text they select. Undo only undoes your own changes. The example plugin's article has no rich text field, so this is the blog plugin's article editor:
Each language has its own shared document. The first person to open a field fills it from the record. If the connection drops, the editor keeps what you type and sends it when the connection comes back. The server stores the document about two seconds after the last change, and **Save** writes its current content, everyone's edits included, to the record.
### Restores and deletes [#restores-and-deletes]
Restoring a revision or deleting the record throws away its drafts, locks and shared documents. Everyone in the session gets a notice, and the form reloads with the new state.
## Check the result [#check-the-result]
1. Sign in at `/admin` as two staff members with `can_edit`, for example in two browser profiles.
2. Open the same record's edit form in both.
3. Click into **Title** in one window. The other window shows the outline and name tag, and the field turns read-only there.
4. Type a few words and wait two seconds. The other window shows the new value, and the header of the first says **Autosaved just now**.
Building your own form layout, or calling the lock and draft routes yourself? See the [live editing API](/docs/content-engine/live-editing-api).
# Translations (/docs/content-engine/localization)
Localization stores chosen fields once per language, while every other field stays shared. An article's title and body get a Polish version, but whether it is featured stays one value. Translated values live in a second table, `{tableName}_translations`, with one row per record and language. This guide uses `example.localized-article` from the example plugin.
## Before you begin [#before-you-begin]
The languages come from your app's `i18n.locales` in `vitnode.config.ts` (see [Languages & Localization](/docs/dev/i18n)) and from the languages created at install. `defaultLocale` must be one of them. A language turned off in the config stays readable in the AdminCP, but nobody can write to it and the public API does not serve it.
### Mark the translatable fields [#mark-the-translatable-fields]
Add a `localization` block and set `localized: true` on each field that changes per language:
```ts title="plugins/example/src/content/localized-article.ts"
export const localizedArticleContentType = defineContentType({
id: "example.localized-article",
tableName: "example_localized_articles",
// [!code ++:4]
localization: {
defaultLocale: "en",
fallback: "default",
},
publication: true,
fields: {
// [!code highlight:8]
title: field.text({
localized: true,
required: true,
minLength: 3,
maxLength: 200,
}),
slug: field.slug({ localized: true, source: "title" }),
body: field.textarea({ localized: true, required: true }),
featured: field.boolean({ defaultValue: false }),
},
});
```
`text`, `textarea`, `richText`, `slug`, `blocks` and whole `group` fields can be localized. Numbers, booleans, enums, dates, files, users, relations and repeatables are always shared. A slug and its `source` must both be localized or both shared, otherwise every language would get the same URL. `localized: true` without a `localization` block throws, and so does a `localization` block without a localized field.
### Export the translations table [#export-the-translations-table]
```ts title="plugins/example/src/database/localized-articles.ts"
export const localizedArticleContent = createContentModel(
localizedArticleContentType,
);
export const example_localized_articles = localizedArticleContent.table;
export const example_localized_articles_translations = // [!code ++:2]
localizedArticleContent.translationTable;
```
Drizzle Kit only sees exported tables, so without this line the migration has no translations table.
### Build and migrate [#build-and-migrate]
```bash tab="bun"
bun run db:migrate
```
```bash tab="pnpm"
pnpm db:migrate
```
```bash tab="npm"
npm run db:migrate
```
The localized columns move to `example_localized_articles_translations`, keyed by `(itemId, languageId)`. That table has its own `version`, `createdAt` and `updatedAt`, plus `status` and `publishedAt` when the type has publication.
The migration does not copy existing values into the translations table. If
the table already holds records, review the SQL before you apply it and add a
step that copies them.
## Edit languages in the AdminCP [#edit-languages-in-the-admincp]
There are no language tabs. Each localized field carries its own language selector, so an editor can fix the Polish title without leaving the form. The field first shows your AdminCP language if it has text, then the default language, then any language that has text. Shared fields have no selector.
This is the blog plugin's category dialog, where `name` is `field.text({ localized: true })`:
Saving the form sends the shared fields and every language you typed into in one request: `POST /localized` for a new record, `PUT /{id}/localized` for an existing one. The server writes them in one transaction. A new record must include its default language. A Polish-only edit does not touch the shared row, so it does not bump the record's version or expire the English cache.
The first time you type into a language, the save creates that translation, so it needs that language's required fields too. If a new translation (other than the default language) is still missing one, the AdminCP leaves that language out, saves everything else and warns "Polish was not saved: fill in Title in this language to save it." With live editing on, the unfinished translation stays in its draft until you finish it.
When the server refuses a value anyway, nothing is saved. The save answers `400` with a body that names each field and its language, and the AdminCP shows it as "Fill in Title (Polish), then save again.":
```json title="PUT /api/@vitnode/blog/admin/content/posts/5/localized → 400"
{
"code": "CONTENT_INVALID_INPUT",
"contentTypeId": "blog.post",
"issues": [{ "code": "required", "field": "title", "locale": "pl" }]
}
```
`code` is `required` for a missing value and Zod's own issue code (`too_big`, `invalid_format`, …) otherwise. `locale` is `null` for a shared field. Read the body with `parseContentInvalidInput` from `@vitnode/core/content`. Every admin write route answers invalid input this way, not only the localized ones.
With publication, each language has its own status. Publishing the record publishes its languages too, and you can then unpublish one language alone. A translation is public only when both the record and that translation are published, and the record is not [hidden](/docs/content-engine/content-visibility). With `editorial`, each language also keeps its own revisions and preview links.
The example plugin registers `example.localized-article` on the API only, so
it has no AdminCP screen. Add it to the plugin's `adminContent` and `adminNav`
content types to get the generated form shown above.
## Read a language from the public API [#read-a-language-from-the-public-api]
The public API serves one language per request, chosen in this order:
1. The `?locale=` query parameter. A locale that the site does not serve returns `404`.
2. Otherwise the `Accept-Language` header. The response then carries `Vary: Accept-Language`.
3. Otherwise `defaultLocale`.
```bash
curl -i "http://localhost:3000/api/@vitnode/example/content/localized-articles/witaj-content-engine?locale=pl"
```
```http
HTTP/1.1 200
content-language: pl
{"title":"Witaj Content Engine","slug":"witaj-content-engine","body":"Jeden rekord, dwa języki.","featured":false,"publishedAt":"2026-10-10T10:57:12.452Z","locale":"pl"}
```
The `locale` property says which language the body is in. When the requested translation is missing or unpublished, `fallback: "default"` serves the default language instead, so `"locale"` becomes `"en"`. With `fallback: "none"` (the default), the API returns `404`.
In a page loader, pass the route's locale through. This is the example plugin's localized article page:
```ts title="plugins/example/src/pages/localized-article-page.tsx"
const detail = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/localized-articles",
path: "/{slug}",
args: {
params: { slug: encodeURIComponent(slug) },
query: { locale },
},
});
```
Opening `/pl/localized-articles/witaj-content-engine` renders the Polish translation:
Each translation can have its own slug and URL; [Public pages](/docs/content-engine/public-pages) covers localized URLs and `hreflang`.
## Translation routes [#translation-routes]
`buildContentAdminModule` adds staff routes for each language under the content type's admin path. They use the same staff permissions as the record:
| Route | Does |
| ----------------------------------------------------------- | ------------------------------------------------------------ |
| `GET /{id}/translations` | Lists the record's translations |
| `GET`, `POST`, `PUT`, `DELETE /{id}/translations/{locale}` | Reads, creates, updates or deletes one language |
| `POST /{id}/translations/{locale}/publish` and `/unpublish` | Changes one language's status. Needs publication |
| `GET /{id}/translations/{locale}/revisions` | One language's history. Needs editorial |
| `POST /{id}/translations/{locale}/preview` | A preview link for one language. Needs editorial preview |
| `POST /localized`, `PUT /{id}/localized` | Saves shared fields and several languages in one transaction |
Each translation write emits an event such as `content.example.localized-article.translation_created` or `translation_published`; see [Events](/docs/content-engine/events).
## Options [#options]
| Name | Type | Required | Default | Description |
| --------------- | ----------------------- | -------- | -------- | ---------------------------------------------------------------------- |
| `defaultLocale` | `string` | Yes | | A language code such as `"en"` or `"pt-BR"`. Every record starts in it |
| `fallback` | `"none"` \| `"default"` | No | `"none"` | What the public API serves when the requested language is missing |
# Plugin files (/docs/content-engine/plugin-files)
VitNode reads a plugin's content types from several small files instead of one big config. A different part of the app loads each file, so a public page never downloads the AdminCP's forms and a browser never sees your database code. [First content type](/docs/content-engine/create-a-content-type) writes each file step by step; this page is the lookup.
## Files at a glance [#files-at-a-glance]
| File | Export | Read by | Holds |
| --------------------------------------- | ---------------------------------- | ------------------------------------------ | ------------------------------------------------------------------ |
| `src/content/.ts` | Any name | Every file below | The `defineContentType` definition. Safe in the browser |
| `src/database/.ts` | Every table | Drizzle Kit, from `dist/src/database/*.js` | `createContentModel` and the generated tables. Server only |
| `src/api/modules/admin/admin.module.ts` | Any name | `src/config.api.ts` | `buildContentAdminModule` inside a module named `admin` |
| `src/config.api.ts` | The API plugin, `VitNodeApiPlugin` | The API, and the app's typed `fetcher` | The admin module, and `buildContentPublicModule` for public routes |
| `src/admin/nav.tsx` | `adminNav` | The AdminCP layout, on every AdminCP page | `{ definition, icon }` per content type and container collection |
| `src/admin/content.tsx` | `adminContent` | The content screens, loaded on demand | Screen registrations, custom cells, fields and form layouts |
| `src/content.ts` | `contentTypes` | The web build and the API at startup | Every content type with `delivery` or `search` |
| `src/routes.ts` | `routes` | The web build | The pages that serve `delivery` and `search` URLs |
| `src/config.tsx` | The plugin | The app's `vitnode.config.ts` | `manifest`, `messages`, `localeFiles` and `routes` only |
| `src/manifest.gen.ts` | `manifest` | Every file that needs the plugin id | The plugin id as `manifest.id`. Written by `vitnode build` / `dev` |
| `src/api/builders.ts` | `buildModule`, `buildRoute`, `buildContent*Module` | Every API module and route file | The builders bound to `manifest` by `bindApiPlugin` and `bindContentPlugin` |
| `src/locales/en.json` | JSON | The app, through `localeFiles` | Labels for content types, fields and permissions |
A content type with an AdminCP screen needs an entry in both `admin/nav` and `admin/content`. The first adds the sidebar link, the second the screen behind it. They are separate files so the sidebar stays small on every AdminCP page, while form and editor code loads only with the screen.
Without custom cells, fields or layouts, `admin/content` can re-export the nav list:
```tsx title="src/admin/content.tsx"
export { adminNav as adminContent } from "./nav";
```
Once you customize a screen, build `adminContent` on its own and reuse the nav entries, as the example plugin does:
```tsx title="plugins/example/src/admin/content.tsx"
import type { ContentFrontendPluginSource } from "@vitnode/core/lib/plugin";
import { manifest } from "@/manifest.gen";
import { exampleArticleNav, exampleCategoryNav, examplePageNav } from "./nav";
export const adminContent = {
pluginId: manifest.id,
contentTypes: [exampleArticleNav, exampleCategoryNav, examplePageNav],
} satisfies ContentFrontendPluginSource;
```
## How the app finds them [#how-the-app-finds-them]
The app never imports these files by hand. Its Vite plugin resolves a fixed subpath of every plugin listed in `vitnode.config.ts` and writes the result into a generated file:
| Subpath | Generated file | Used for |
| -------------------------- | ----------------------------- | ----------------------------------------------------- |
| `/admin/nav` | `src/admin-nav.gen.ts` | The AdminCP sidebar |
| `/admin/content` | `src/content-registry.gen.ts` | The AdminCP content screens |
| `/content` | `src/content-modules.gen.ts` | URL checks in the build and in the API |
| `/config.api` | `src/api-registry.gen.ts` | Types for `fetcher`, imported with `import type` only |
| `/routes` | `src/plugin-routes.gen.ts` | Plugin pages |
This happens each time `vite dev` or `vite build` starts, and the dev server regenerates the files when a resolved module changes. A subpath that does not resolve is skipped without an error, because most plugins have no content types. That is also the usual reason a new screen does not show up: a wrong file path, a wrong export name, or a plugin that was not rebuilt.
Resolution only works when the plugin id equals the npm package name and the package exports its `dist` folder. A generated plugin already has the right `exports`:
```json title="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"
}
}
}
```
Tables are the exception. Drizzle Kit reads `node_modules//dist/src/database/*.js` directly for every plugin in the API config. Export each table from those files, or it never reaches a migration. [Database](/docs/content-engine/database#export-every-table) shows a model with several tables.
## Keep `config.tsx` small [#keep-configtsx-small]
`config.tsx` is bundled with the document shell of every public page:
```tsx title="plugins/example/src/config.tsx"
import { buildPlugin } from "@vitnode/core/lib/plugin";
import messages from "./locales";
import { manifest } from "./manifest.gen";
import { routes } from "./routes";
export const examplePlugin = () =>
buildPlugin({
manifest,
localeFiles: {
en: "@vitnode/example/locales/en.json",
},
messages,
routes,
});
```
Do not import `admin/nav` or `admin/content` here. The app loads `admin/nav` with the AdminCP layout and `admin/content` behind a dynamic `import()`, so form code arrives only with the screen that needs it. Pulling them into `config.tsx` would put the whole editing stack, rich text editor included, into every public page.
`localeFiles` maps each language to your locale JSON so the app can load it. Without it nothing fails, but every label from your locale file is missing.
## `content.ts` and public URLs [#contentts-and-public-urls]
A content type with `delivery` or `search` publishes URLs. List every such content type in `src/content.ts`. The example plugin lists all of its types, which is allowed:
```ts title="plugins/example/src/content.ts"
import { advancedArticleContentType } from "@/content/advanced-article";
import { articleContentType } from "@/content/article";
import { categoryContentType } from "@/content/category";
import { localizedArticleContentType } from "@/content/localized-article";
import { pageContentType } from "@/content/page";
export const contentTypes = [
articleContentType,
advancedArticleContentType,
localizedArticleContentType,
categoryContentType,
pageContentType,
];
```
Keep the module browser-safe: it imports definitions only, never `src/database`. The web build checks that one of your pages serves each URL, and the API refuses to start when a plugin publishes URLs that this module does not declare. See [Content URLs need a page](/docs/dev/i18n/localized-urls#content-urls-need-a-page).
## Errors [#errors]
| Error or symptom | Cause | Fix |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| A content type has no sidebar entry or screen | `admin/nav` or `admin/content` did not resolve, or the export name is wrong | Check the file path and the `adminNav` / `adminContent` export |
| A table is missing from the migration | The table is not exported from `src/database/*.ts`, or the plugin was not built | Export it and run `db:migrate` while `pnpm dev` runs |
| `content-module-missing` when the API starts | A content type has `delivery` or `search`, but `/content` does not resolve | Add `src/content.ts` with `export const contentTypes` |
| `incomplete-content-types` when the API starts | `src/content.ts` leaves out a content type that publishes URLs, or declares it differently | Export the same definition the API registers in `contentTypes` |
| `content-url-without-page` in `dev` or `build` | No page route serves a `delivery` or `search` URL | Add the page to `src/routes.ts`, or change the URL |
| Labels show raw keys or field names | `localeFiles` is missing from `config.tsx`, or the key path is wrong | Add `localeFiles` and check the `content.` keys |
# Production (/docs/content-engine/production)
A content type that works on your machine needs a few more checks before real editors and visitors use it. Go through this list for every content type you ship.
## Expose only what you mean to [#expose-only-what-you-mean-to]
- [ ] **Review `publicApi.fields`.** It is the allowlist for everything public: the public API, page metadata from `delivery` and the search index. A field not listed there never leaves the server. Keep internal notes, moderation flags and anything personal out of it.
- [ ] **Keep `publication` on.** `publicApi` and `search` both refuse to load without it, and new records start as drafts, so nothing is public until someone publishes it.
- [ ] **Treat search as public.** `search.titleField`, `descriptionField` and `contentFields` must be public fields, and `search.authorField` shows who wrote a record in search results.
The example article lists eight of its own fields plus `publishedAt`. `code`, `views` and `author` stay private, because they are not in the list:
```ts title="plugins/example/src/content/article.ts"
publicApi: {
path: "articles",
fields: [
"title",
"slug",
"excerpt",
"featured",
"category",
"animation",
"gallery",
"noIndex",
"publishedAt",
],
},
```
## Grant the right permissions [#grant-the-right-permissions]
- [ ] **Give staff only what they need** in **AdminCP → Staff**. Each content type gets `can_view`, `can_create`, `can_edit` and `can_delete`, plus `can_publish` with `publication`, `can_restore` with `editorial` and `can_hide` with [`visibility`](/docs/content-engine/content-visibility).
- [ ] **Guard your own admin routes.** Generated routes check their permissions themselves. A custom route next to them needs its own `adminStaffPermission`, as shown in [Services](/docs/content-engine/services#save-like-the-admincp).
## Ship the migration [#ship-the-migration]
- [ ] Commit the migration that `pnpm db:migrate` generated together with the content type.
- [ ] Run `db:migrate` as a deploy step. Production never migrates on startup.
- [ ] Read the generated SQL when you change an existing content type. See [Database](/docs/content-engine/database).
## Configure what editorial features rely on [#configure-what-editorial-features-rely-on]
- [ ] **Scheduled publishing** is a queue task, and the `process-queue` [cron job](/docs/dev/cron) drains the queue every minute. Make sure something calls your cron endpoint in production.
- [ ] **Set `CRON_SECRET`** to your own random value. The API rejects the built-in default outside development, which stops cron runs and front-end revalidation calls.
- [ ] **Preview links** are built from `VITNODE_WEB_URL` and `VITNODE_API_URL`, which default to localhost. Set both to your public URLs. If either is not a valid absolute URL, the API logs a warning at startup and previews stay off.
- [ ] **Integrations send the version they read.** With `editorial`, a save with an outdated `expectedVersion` fails with `409 CONTENT_VERSION_CONFLICT` instead of overwriting someone else's work.
- [ ] **A separate front end that caches public reads** is listed in `content.revalidateOrigins` with an `https` URL. See [Caching](/docs/content-engine/caching).
- [ ] **Live editing** has its own deployment requirements, listed in the [Live editing API](/docs/content-engine/live-editing-api).
## Check content health [#check-content-health]
**Debug Panel → Search** in the AdminCP (`/admin/core/debug/search`) shows how much of each content type is in the search index. Use **Reindex** on a collection, or **Rebuild index**, after a bulk import or a failed sync.
For a monitoring probe, core has a staff-only route that reports every content type, its search drift per language and failed scheduled effects. It needs the `can_view` permission of the `system` module:
```bash
curl -b "vitnode_auth_admin=" \
http://localhost:8000/api/@vitnode/core/admin/debug/content/status
```
```json
{
"healthy": false,
"searchHealthy": false,
"effectsHealthy": true,
"contentTypes": [
{
"contentTypeId": "example.article",
"pluginId": "@vitnode/example",
"features": {
"editorial": true,
"localization": false,
"publicApi": true,
"publication": true,
"scheduling": true,
"search": true
},
"search": {
"healthy": false,
"expectedTotal": 1,
"canonicalIndexedTotal": 2,
"provider": {
"name": "postgres",
"healthy": false,
"indexedTotal": 2,
"verified": true
}
},
"schedules": { "pending": 0, "withErrors": 0, "failedEffects": 0 }
}
]
}
```
This excerpt shows two indexed documents for one published article, so search is not healthy until the collection is reindexed. `failedEffects` counts scheduled publishes that committed but whose event or search update failed. In your own code, `contentEngineDiagnostics(c)` from `@vitnode/core/content/server` returns the same object.
## Know the limits [#know-the-limits]
- Content types are defined in code and shipped with migrations. There is no AdminCP screen for creating them at runtime.
- A relation points at one content type. To link to several unrelated types, use one relation field per type.
- `field.blocks()` is not in the generated form by default. Editors change it through [widgets](/docs/dev/widgets) or a custom field component.
# Public API (/docs/content-engine/public-api)
`publicApi` gives a content type read-only routes that anyone can call: one for a list and one for a single record. Only published records come back, and only the fields you name. Your pages read these routes; nothing is public until you add them.
You need [publication](/docs/content-engine/publication) first, so drafts can never leak.
### Choose the public fields [#choose-the-public-fields]
Add `publicApi` with a URL `path` and the `fields` readers may see:
```ts title="plugins/example/src/content/discussion.ts"
export const discussionContentType = defineContentType({
id: "example.discussion",
// ...
publication: true,
// [!code ++:4]
publicApi: {
path: "discussions",
fields: ["id", "title", "slug", "body", "board", "publishedAt"],
},
});
```
- `fields` has no wildcard. A field you add to the content type later stays private until you list it.
- Exactly one slug field must be in the list, because the detail route finds records by it.
- User fields never appear here. To show an author, add `people: ["author"]`, which returns a small public profile instead.
Search, sorting, filters and lighter list rows are a few more options, covered in [Public lists](/docs/content-engine/public-lists#choose-what-the-list-can-read).
### Register the routes [#register-the-routes]
Add the model to `buildContentPublicModule` in the plugin's API config:
```ts title="plugins/example/src/config.api.ts"
import { discussionContent } from "@/database/discussions";
export const exampleApiPlugin = () =>
buildApiPlugin({
manifest,
modules: [
// [!code ++:3]
buildContentPublicModule({
contentTypes: [discussionContent],
}),
],
});
```
`pnpm dev` serves the new routes as soon as you save. `publicApi` adds no columns, so there is nothing to migrate.
### Read it from a page [#read-it-from-a-page]
Call the routes with the [fetcher](/docs/dev/fetcher). The module is `content/` plus `path`, and the route, its query and the response are all typed:
```ts title="plugins/example/src/pages/discussion-page.tsx"
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { manifest } from "@/manifest.gen";
const res = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/discussions",
path: "/{slug}",
args: { params: { slug: encodeURIComponent(slug) } },
});
if (res.status === 200) {
const discussion = await res.json(); // { id, title, slug, body, board, publishedAt }
}
```
A field you did not list, or a module that does not exist, is a compile error.
## The routes [#the-routes]
All routes live under `/api/{pluginId}/content/{publicApi.path}`.
| Route | Returns |
| ---------------------------- | --------------------------------------------------------------------------------------------- |
| `GET /` | Published records, a page at a time: `{ edges, pageInfo }` |
| `GET /{slug}` | One published record, or `404` |
| `GET /permissions?ids=1,2` | What the visitor may do with each record: `canEdit`, `canReport`. With `publicWrite` or `reports` |
| `/delivery/...` | Page URLs, SEO data and sitemap entries. With [`delivery`](/docs/content-engine/public-pages) |
| `GET /thread?root={id}` | Nested replies under a record. With [`thread`](/docs/content-engine/threads) |
| `POST /`, `PATCH /{id}` | Create and edit from the site. With [`publicWrite`](/docs/content-engine/public-write) |
Each record comes back with exactly the listed fields. Dates are ISO strings, a relation is `{ id }`, a container field is the container's id, and a file is `{ id, name, url, mimeType, size, width, height }`. A draft, a record scheduled for later, a hidden record and a typo all answer the same `404`. A record that belongs to a parent - its thread root, or its search context relation - is only as public as that parent: once the topic is hidden or unpublished, its comments drop out of the list and their detail answers `404` too. The list adds the hidden records a signed-in author, editor or moderator may see, marked `hidden: true`.
The list accepts `first` (up to 50), `page` or `cursor`, `search`, `orderBy`, `order` and one parameter per filterable field. [Public lists](/docs/content-engine/public-lists) turns it into a page with search, filters and pagination.
## Check the result [#check-the-result]
Call the list route while `pnpm dev` runs:
```bash
curl "http://localhost:3000/api/@vitnode/example/content/discussions?first=1"
```
```json title="Response (trimmed)"
{
"edges": [
{
"id": 13,
"title": "Show the author on list rows",
"slug": "show-the-author-on-list-rows",
"board": 3,
"publishedAt": "2026-10-20T16:04:32.879Z",
"author": { "id": 2, "name": "Ada Lovelace", "nameCode": "Ada-Lovelace" }
}
],
"pageInfo": { "totalCount": 12, "totalPages": 12, "hasNextPage": true }
}
```
Pages turn these responses into what readers see. The example plugin's `/discussions` page, from [Public lists](/docs/content-engine/public-lists), shows the same records:
# Public list API (/docs/content-engine/public-lists-api)
Every helper on this page is exported from `@vitnode/core/tanstack/content` and is universal unless noted. For a guided setup, follow [Public lists](/docs/content-engine/public-lists). Examples use the blog plugin's `/blog` list.
## `defineContentList` [#definecontentlist]
Declares a list's sorts, URL filters and page size, and returns pure helpers for the URL state.
```ts
const blogPostList = defineContentList({
defaultSort: 'newest',
filters: { category: contentListIdFilter },
pageSize: 12,
sorts: {
newest: { order: 'desc', orderBy: 'publishedAt' },
oldest: { order: 'asc', orderBy: 'publishedAt' },
},
})
```
| Name | Type | Required | Default | Description |
| ------------- | ------------------------------------------------------ | -------- | ------- | -------------------------------------------------------------------------------------------------- |
| `sorts` | `Record` | Yes | | Each URL value of `sort`. `orderBy` must be in `publicApi.orderableFields` or be `publishedAt` |
| `defaultSort` | a key of `sorts` | Yes | | The sort a URL without `sort` uses. Never written to the URL |
| `filters` | `Record value \| undefined>` | No | none | URL filters by URL name. A parser returns a string, number or boolean, or `undefined` to ignore it |
| `pageSize` | `number` | No | `12` | Sent as `first`. Clamped to 1-50, the public list route's limit |
`contentListIdFilter` parses a positive integer id, such as `?category=3`.
| Method | Returns | Behavior |
| ---------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `parseSearch(input)` | `ContentListSearch` | The route's `search` validator. Keeps `page` above 1, a trimmed `q` of up to 256 characters, a non-default `sort` and valid filters. Drops everything else |
| `withSearch(current, patch)` | `ContentListSearch` | Applies a change. Changing anything but `page` resets it to the first page. `undefined` clears a value |
| `isFiltered(search)` | `boolean` | `true` when a term, a filter or a non-default sort is set. `page` does not count |
| `apiQuery(search)` | `ContentListApiQuery` | `{ first, order, orderBy, page, search? }` for the list route. Map filters yourself |
| `href(pathname, search)` | `string` | The internal URL of one state, e.g. `/blog?category=3&page=2` |
| `sorts` | `string[]` | Every sort, in declaration order |
| `defaultSort`, `pageSize` | value | The resolved config |
## `contentListQueryOptions` [#contentlistqueryoptions]
TanStack Query options for one page of a list. Use them in a loader with `queryClient.query(...)` and in a component with `useQuery(...)`.
| Name | Type | Required | Default | Description |
| ----------- | ---------------------------------------- | -------- | -------- | ------------------------------------------------------------------------- |
| `queryKey` | `QueryKey` | Yes | | The prefix every page shares, e.g. `["@vitnode/blog", "posts"]` |
| `locale` | `string` | Yes | | The page's locale. Pass it on to the API as `?locale=` |
| `search` | object | Yes | | The normalized state from `parseSearch`, or a fixed one such as `{}` |
| `fetch` | `({ locale, search }) => Promise` | Yes | | Makes the request. Write the `fetcher` call here so its route stays typed |
| `staleTime` | `number` | No | `60_000` | Milliseconds a page stays fresh, `RECORD_STALE_TIME` |
The key is `[...queryKey, { locale, ...search }]`. Add `meta: LOADER_DATA_QUERY_META` when a route returns the data from its loader, so it is not sent to the browser twice.
## `contentListPageHead` [#contentlistpagehead]
Builds a plugin route `head` for a list page.
| Name | Type | Required | Default | Description |
| ------------- | ------------------- | -------- | ----------------------- | -------------------------------------- |
| `pathname` | `string` | Yes | | The list's internal path, e.g. `/blog` |
| `search` | object | Yes | | The normalized state |
| `title` | `string` | No | | The page title, already translated |
| `description` | `string` | No | | The meta description |
| `locales` | `readonly string[]` | No | every configured locale | The locales the page exists in |
With an empty `search`, the head is `index, follow` with a canonical link and one `hreflang` alternate per locale. Any other state is `noindex, follow` without a canonical.
## `ContentListResults` [#contentlistresults]
Renders one of the list's states. A React component; item rendering and every label come from you.
| Prop | Type | Required | Default | Description |
| ---------------------------------------- | ---------------------------- | -------- | ------- | ------------------------------------------------ |
| `items` | `TItem[] \| undefined` | Yes | | `undefined` while the first page loads |
| `renderItem` | `(item, index) => ReactNode` | Yes | | One item, inside an `
` |
| `getKey` | `(item) => Key` | Yes | | The React key |
| `label` | `string` | Yes | | The list's accessible name |
| `isFiltered` | `boolean` | Yes | | Picks `noResults` over `empty` for an empty page |
| `loading`, `error`, `empty`, `noResults` | `ReactNode` | Yes | | What each state shows |
| `isError` | `boolean` | No | `false` | Shows `error` while there are no items |
| `isFetching` | `boolean` | No | `false` | Marks the list `aria-busy` and dims it |
| `as` | `"ul" \| "ol"` | No | `"ul"` | The list element |
| `className`, `itemClassName` | `string` | No | | Classes for the list and each `
` |
## `ContentListPagination` [#contentlistpagination]
Numbered page links from the list route's `pageInfo`. Renders nothing for a single page. Links go through `HrefLink`, so the locale prefix is added and every page works without JavaScript.
| Prop | Type | Required | Default | Description |
| ------------ | -------------------------- | -------- | ------------ | ----------------------------------------------- |
| `pageInfo` | `ContentListPageInfo` | Yes | | The list response's `pageInfo` |
| `hrefFor` | `(page: number) => string` | Yes | | A page's internal URL, usually `list.href(...)` |
| `label` | `string` | No | "Pagination" | The `
}
noResults={null}
renderItem={discussion =>
discussion.slug === null ? (
{discussion.title}
) : (
{discussion.title}
)
}
/>
);
};
```
An empty `search` is the plain list, newest first. The box never filters, so `isFiltered` is `false` and `noResults` is never shown. The labels live under `@vitnode/example.boards.latest`, a namespace the `/boards` route already loads.
### Render it on the server too [#render-it-on-the-server-too]
`useQuery` does not fetch during SSR, so on its own the box starts as `loading`. To send it with the HTML, warm the same options in the host route's loader. Leave out `LOADER_DATA_QUERY_META`, so the query is dehydrated and `useQuery` finds it in the browser:
```tsx title="plugins/example/src/pages/boards-page.tsx"
const loadBoards = async (
queryClient: RouteLoadContext["queryClient"],
locale: string,
) => {
const [boards] = await Promise.all([
queryClient.query(boardsQueryOptions()),
// [!code ++:3]
queryClient
.query(discussionsQueryOptions({ locale, search: {} }))
.catch(() => null),
]);
return { tree: contentContainerTree(boards) };
};
export const route = defineRoute({
load: async ({ context }) =>
await loadBoards(context.queryClient, context.locale), // [!code highlight]
});
```
Then render `` anywhere in the page. Both calls build the key `["@vitnode/example", "discussions", { locale }]`, the same one the first page of `/discussions` uses, so they share one cache entry. `invalidateDiscussionLists` refreshes the box together with the list after a member posts.
## Use a different list shape [#use-a-different-list-shape]
`/discussions` shows ten rows a page, newest first. For a box that needs a different page size, sort or filter set, declare a second list with [`defineContentList`](/docs/content-engine/public-lists-api#definecontentlist) and its own query options. Keep the same `queryKey` prefix, `[manifest.id, "discussions"]`, so one invalidation still covers both.
## Check the result [#check-the-result]
Open `http://localhost:3000/boards` and view the page source. The discussion titles are in the HTML. Start a discussion from **Start a discussion**, then come back to `/boards`, and it sits at the top of the box.
# Public lists (/docs/content-engine/public-lists)
A public list is a page such as `/discussions` that shows published records a page at a time, with search, filters and sorting in its URL. The Content Engine provides the data and the list behavior; your plugin defines the route, the item layout and the words. This guide builds the example plugin's discussion list: search by title, a board filter, three sorts, ten discussions a page, and links to each discussion.
| The Content Engine provides | Your plugin defines |
| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
| The list route `GET /content/{publicApi.path}` with paging, search, filters, locale fallback, caching and cache tags | The `page()` route and where it lives |
| Only published, already-live records. Drafts and scheduled records never appear, and hidden ones only for their authors and editors | Which sorts and filters the URL accepts |
| `defineContentList`, `contentListQueryOptions`, `contentListPageHead` | The `fetcher` call and the item layout |
| `ContentListResults`, `ContentListPagination`, `useContentListSearchInput` | The search field, the selects and every label |
The Content Engine never creates list pages on its own, and it ships no sort or filter controls. You add a list where it belongs and draw the controls you want.
## Before you begin [#before-you-begin]
You need a content type with a [public API](/docs/content-engine/public-api) and pages for its records, such as [public pages](/docs/content-engine/public-pages). This guide uses `example.discussion` from the example plugin, which members write through [public write](/docs/content-engine/public-write).
### Choose what the list can read [#choose-what-the-list-can-read]
Tell the public API which fields a list row carries and what readers may search, sort and filter by:
```ts title="plugins/example/src/content/discussion.ts"
publicApi: {
path: "discussions",
fields: ["id", "title", "slug", "body", "board", "publishedAt"],
// [!code ++:5]
listFields: ["id", "title", "slug", "board", "publishedAt"],
filterableFields: ["board"],
searchableFields: ["title"],
orderableFields: ["title"],
people: ["author"],
},
```
- `listFields` must be a subset of `fields`. The list route selects only these, so a page of ten discussions never loads ten rich text bodies. The detail route still returns every field in `fields`.
- `searchableFields` takes text, textarea and slug fields. `orderableFields` adds sorts; `publishedAt` is always orderable.
- `filterableFields` adds one query parameter per field. A [container field](/docs/content-engine/containers) such as `board` also accepts `includeDescendants=true`.
- `people: ["author"]` adds a small public profile of each discussion's author to every row. A user field never appears through `fields`.
These options change the API, not the table, so there is nothing to migrate. Every option is in the [`publicApi` reference](/docs/content-engine/reference#publicapi).
### Declare the list and its query [#declare-the-list-and-its-query]
Create `src/pages/discussion-list.ts`. `defineContentList` from `@vitnode/core/tanstack/content` names the sorts, the URL filters and the page size once. `contentListQueryOptions` turns one URL state into TanStack Query options. Write the `fetcher` call inline in `fetch`, so the route stays typed:
```ts title="plugins/example/src/pages/discussion-list.ts"
import type { QueryClient } from "@tanstack/react-query";
import type { ContentListSearch } from "@vitnode/core/tanstack/content";
import {
contentListIdFilter,
contentListQueryOptions,
defineContentList,
} from "@vitnode/core/tanstack/content";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { manifest } from "@/manifest.gen";
export const DISCUSSIONS_HREF = "/discussions";
export const discussionList = defineContentList({
defaultSort: "newest",
filters: { board: contentListIdFilter },
pageSize: 10,
sorts: {
newest: { order: "desc", orderBy: "publishedAt" },
oldest: { order: "asc", orderBy: "publishedAt" },
title: { order: "asc", orderBy: "title" },
},
});
export type DiscussionListSearch = ContentListSearch<
(typeof discussionList.sorts)[number],
{ board: typeof contentListIdFilter }
>;
export const discussionListSearch = (
input: Record,
): DiscussionListSearch => discussionList.parseSearch(input);
const discussionQueryKeys = {
all: [manifest.id, "discussions"] as const,
};
export const discussionsQueryOptions = (request: {
locale: string;
search: DiscussionListSearch;
}) =>
contentListQueryOptions({
...request,
queryKey: discussionQueryKeys.all,
fetch: async ({ search }) => {
const res = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/discussions",
path: "/",
args: {
query: {
...discussionList.apiQuery(search),
...(search.board === undefined
? {}
: { board: String(search.board), includeDescendants: "true" }),
},
},
});
if (res.status !== 200) {
throw new Error(`Loading discussions answered ${res.status}.`);
}
return await res.json();
},
});
export const invalidateDiscussionLists = async (queryClient: QueryClient) => {
await queryClient.invalidateQueries({ queryKey: discussionQueryKeys.all });
};
```
- The URL keys are `q`, `sort`, `page` and one per filter, here `board`. Each `orderBy` must be in `publicApi.orderableFields` or be `publishedAt`.
- `apiQuery` turns the URL state into `first`, `page`, `order`, `orderBy` and `search`. Filters are yours to map, because a URL name and an API name can differ.
- The response is typed from the content type. Each row carries exactly `listFields` plus `author`, with dates as ISO strings. There is nothing to re-validate.
- `invalidateDiscussionLists` expires every cached page of the list. The create and edit pages call it after a save. See [Public create form](/docs/content-engine/public-write-frontend#write-the-page).
A localized content type also passes `locale` in the query, so each language gets its own response and cache entry. Discussions are not localized, so the locale only keys the cache.
### Add the route [#add-the-route]
Register the page with the list's `search` validator and a skeleton for the first load:
```ts title="plugins/example/src/routes.ts"
import { FeedPendingSkeleton } from "@vitnode/core/tanstack/pending";
import { discussionListSearch } from "./pages/discussion-list";
export const routes = definePluginRoutes([
// [!code ++:6]
page("/discussions", {
component: lazy(() => import("./pages/discussions-page")),
messages: ["@vitnode/example.discussions"],
pendingComponent: FeedPendingSkeleton,
search: discussionListSearch,
}),
]);
```
`messages` loads the `@vitnode/example.discussions` namespace for the page, its `head` and every component inside it. `pendingComponent` shows while the loader runs on a client-side navigation. A server render waits for the loader instead.
### Load the page and build its head [#load-the-page-and-build-its-head]
Create `src/pages/discussions-page.tsx`. The loader warms the query, so the server renders the list and the browser starts with it:
```tsx title="plugins/example/src/pages/discussions-page.tsx"
import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client";
import { contentListPageHead } from "@vitnode/core/tanstack/content";
import {
defineRoute,
type RouteLoadContext,
} from "@vitnode/core/tanstack/plugin-routes";
import {
DISCUSSIONS_HREF,
type DiscussionListSearch,
discussionsQueryOptions,
} from "./discussion-list";
import { boardsQueryOptions } from "./example-queries";
const loadDiscussions = async (
queryClient: RouteLoadContext["queryClient"],
{ locale, search }: { locale: string; search: DiscussionListSearch },
) => {
// A failed list is an error state on the page, not an error page.
const [discussions, boards] = await Promise.all([
queryClient
.query({
...discussionsQueryOptions({ locale, search }),
meta: LOADER_DATA_QUERY_META,
})
.catch(() => null),
queryClient.query(boardsQueryOptions()).catch(() => []),
]);
return { boards, discussions };
};
type DiscussionsPageData = Awaited>;
export const route = defineRoute({
load: async ({ context, search }) =>
await loadDiscussions(context.queryClient, {
locale: context.locale,
search,
}),
head: ({ search, t }) =>
contentListPageHead({
description: t("@vitnode/example.discussions.list.desc"),
pathname: DISCUSSIONS_HREF,
search,
title: t("@vitnode/example.discussions.list.title"),
}),
});
```
- `LOADER_DATA_QUERY_META` keeps the result out of the dehydrated cache, because the loader data already carries it to the browser.
- `.catch(() => null)` turns a failed request into the list's error state instead of the app's error page.
- `contentListPageHead` gives the plain list `index, follow` and a canonical link, and every searched, filtered, sorted or paged view `noindex, follow`.
- `boardsQueryOptions` is the example's own query for the board picker. It is not part of the list.
### Render the controls and the results [#render-the-controls-and-the-results]
Add the component to the same file. `ContentListResults` decides between the loading, error, empty, no-results and item states. `ContentListPagination` renders page links from `pageInfo`:
```tsx title="plugins/example/src/pages/discussions-page.tsx"
const DiscussionsPage = ({
loaderData: { boards, discussions },
navigate,
search,
}: PluginRoutePageProps) => {
const t = useTranslations("@vitnode/example.discussions.list");
const [isPending, startTransition] = React.useTransition();
const update = (patch: Partial) => {
startTransition(async () => {
await navigate({
resetScroll: false,
search: discussionList.withSearch(search, patch),
});
});
};
const input = useContentListSearchInput({
onCommit: q => {
update({ q });
},
value: search.q,
});
return (
);
};
export default DiscussionsPage;
```
The excerpt trims styling, the author line and the per-row actions. `ListState` is a small wrapper around the `Empty` components from `@vitnode/core/components/ui/empty`. The full file is `plugins/example/src/pages/discussions-page.tsx`.
- `withSearch` returns the new URL state and drops `page` when anything else changes, because page 2 of the old results says nothing about the new ones.
- `useContentListSearchInput` waits 500 ms after the last keystroke before it navigates, commits at once on Enter, and follows the URL when the reader goes back or forward.
- `navigate` inside `startTransition` keeps the current rows on screen, dimmed and `aria-busy`, while the next page loads.
- `empty` shows when nothing is published and `noResults` when a search or filter matches nothing, so only the second one offers **Clear filters**.
- `loading` renders while `items` is `undefined`. This page always has data from its loader, so it passes `null`; a list that fetches in the browser shows a skeleton there.
- Link each row to its record's page with the same helper that page's route uses, here `discussionHref(slug)`. A row whose `slug` is `null` has no page to link to.
To put Edit and Report buttons on each row, see [Public edit form](/docs/content-engine/public-write-edit#show-the-edit-button) and [Report content](/docs/content-engine/reports#add-it-to-every-row-of-a-list).
### Add the messages [#add-the-messages]
Every label lives in the plugin's locale file, under the namespace the route loads:
```json title="plugins/example/src/locales/en.json"
{
"@vitnode/example": {
"discussions": {
"list": {
"title": "Discussions",
"desc": "Questions, ideas and show-and-tell from the community, newest first.",
"search_label": "Search discussions",
"search_placeholder": "Search by title",
"board_label": "Board",
"all_boards": "All boards",
"sort_label": "Sort by",
"sort": { "newest": "Newest", "oldest": "Oldest", "title": "Title (A-Z)" },
"pagination": "Discussion pages",
"empty": { "title": "No discussions yet" },
"no_results": { "title": "No discussions match", "clear": "Clear filters" },
"error": { "title": "Couldn't load discussions" }
}
}
}
}
```
## How URL parameters, locales and pagination interact [#how-url-parameters-locales-and-pagination-interact]
- **Normalized URLs.** `parseSearch` drops unknown keys, invalid values, `page=1` and the default sort. `/discussions?sort=newest&page=1&utm_source=x` and `/discussions` are the same state, so they share a query key and a canonical link.
- **Locales.** On a [localized](/docs/content-engine/localization) content type, pass `locale` in the query. A record with no translation in that language falls back to the default language when the type uses `fallback: "default"`, and each row says which language it is in under `locale`. An unknown `?locale=` answers `404` rather than an English list.
- **Pagination.** `ContentListPagination` reads `currentPage`, `totalPages`, `hasNextPage` and `hasPreviousPage` from `pageInfo`, and every page is a real link, so it works before hydration. A `page` past the end gets the last page. The list route also accepts `cursor` for "Older" and "Newer" links; [Containers on the frontend](/docs/content-engine/container-frontend#add-the-board-page) uses it.
- **Caching.** The API caches each list read per query and locale, and every publish, edit, unpublish, hide or delete of the content type expires it. TanStack Query keeps its own copy in the browser for `RECORD_STALE_TIME`, which is why a page that writes records invalidates its list.
- **Hidden records.** With [`visibility`](/docs/content-engine/content-visibility#who-can-still-see-it), a signed-in author, editor or moderator also gets the hidden records they may see. Those rows carry `hidden: true`, so mark them, for example with `HiddenIcon` from `@vitnode/core/components/hidden-icon`. That response is `private, no-store` and skips the API cache. A route with a page `cache` serves signed-in visitors the HTML rendered for guests, so refetch the list in the browser under a key that includes the viewer, like the blog's `blogViewerPostsQueryOptions`. For your own counts or queries, combine `contentListHiddenAccess` and `contentPublicListCondition` from `@vitnode/core/content/server`.
Records reach crawlers through their own pages and the [sitemap](/docs/content-engine/seo#sitemap). The sitemap route lists records, not lists, so add the list's own URL, such as `/discussions`, where your app builds `/sitemap.xml`.
The blog plugin builds `/blog` and `/blog/category/:slug` from the same pieces with only pages and a category, in `plugins/blog/src/pages/blog-list.ts` and `blog-queries.ts`.
## Check the result [#check-the-result]
Open `http://localhost:3000/discussions`:
1. Type a word from a title. After half a second the URL gets `?q=...` and the list narrows.
2. Pick a board, then **Title (A-Z)**. The URL keeps both, and the list jumps back to page 1.
3. Search for a word no title contains. **No discussions match** shows with **Clear filters**.
4. Move to page 2, refresh, then press back. Each view comes back exactly as the URL describes it.
The plain list is the indexable one; every other state stays out of the index but keeps its links followable:
```bash
curl -s http://localhost:3000/discussions | grep -oE ''
curl -s "http://localhost:3000/discussions?board=1" | grep -oE ''
```
```html
```
# Public pages (/docs/content-engine/public-pages)
Delivery turns a published record into a web page at a stable URL, such as `/articles/hello-content-engine`. The Content Engine resolves the URL, answers `404` for drafts, redirects retired slugs and builds the page's SEO metadata from fields you choose. Your plugin owns the page component, so the content type, its API and its URL stay in one plugin.
The content type needs [publication and a public API](/docs/content-engine/public-api). This guide continues with `example.article`.
### Turn on delivery [#turn-on-delivery]
Add a `delivery` block to the definition:
```ts title="plugins/example/src/content/article.ts"
export const articleContentType = defineContentType({
id: "example.article",
// [!code ++:10]
delivery: {
redirects: true,
seo: {
titleField: "title",
descriptionField: "excerpt",
noIndexField: "noIndex",
openGraph: { titleField: "title", descriptionField: "excerpt" },
},
sitemap: { changeFrequency: "weekly", priority: 0.7 },
},
});
```
- Every SEO field must also be in `publicApi.fields`, because the page renders it publicly. That is why the example lists `noIndex` there.
- `noIndexField` names a boolean. When it is `true`, the page gets `noindex, nofollow` and the record leaves the sitemap.
- `redirects` needs `editorial: true`, which the example article already has. `sitemap` needs `publication`.
- The page URL defaults to `/{publicApi.path}/:slug`, so articles live at `/articles/:slug`. Set `delivery.path` to use another URL. It needs exactly one `:slug` and cannot start with `/admin` or `/api`.
### Export the plugin's content types [#export-the-plugins-content-types]
Export every content type with public URLs from `src/content.ts`:
```ts title="plugins/example/src/content.ts"
import { advancedArticleContentType } from "@/content/advanced-article";
import { articleContentType } from "@/content/article";
import { categoryContentType } from "@/content/category";
import { localizedArticleContentType } from "@/content/localized-article";
import { pageContentType } from "@/content/page";
export const contentTypes = [
articleContentType,
advancedArticleContentType,
localizedArticleContentType,
categoryContentType,
pageContentType,
];
```
The build compares these URLs with your plugin's routes. A content type whose URL no page serves stops `dev` and `build` with a `content-url-without-page` error, so a broken canonical link never ships. [Plugin files](/docs/content-engine/plugin-files) explains how the build finds this module.
### Claim the URL [#claim-the-url]
Add a page for the same path to the plugin's routes:
```ts title="plugins/example/src/routes.ts"
export const routes = definePluginRoutes([
// [!code ++:4]
page("/articles/:slug", {
component: lazy(() => import("./pages/article-page")),
messages: ["@vitnode/example.articles"],
}),
]);
```
### Write the page [#write-the-page]
The loader calls two public routes in parallel. `/delivery/resolve/{slug}` says whether the URL is a record, an old slug to redirect, or nothing. `/{slug}` returns the record's public fields.
```tsx title="plugins/example/src/pages/article-page.tsx"
import {
definePluginRoute,
type PluginRoutePageProps,
} from "@vitnode/core/routing";
import {
contentDeliveryPage,
contentDeliveryPageHead,
} from "@vitnode/core/tanstack/content";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { z } from "zod";
import { manifest } from "@/manifest.gen";
import { ContentArticle } from "./content-article";
const zodArticle = z.object({
excerpt: z.string().nullable(),
publishedAt: z.string().nullable(),
title: z.string(),
});
const loadArticle = async (slug: string) => {
const [resolution, detail] = await Promise.all([
fetcher({
plugin: manifest.id,
method: "get",
module: "content/articles",
path: "/delivery/resolve/{slug}",
args: { params: { slug: encodeURIComponent(slug) } },
}),
fetcher({
plugin: manifest.id,
method: "get",
module: "content/articles",
path: "/{slug}",
args: { params: { slug: encodeURIComponent(slug) } },
}),
]);
if (resolution.status !== 200) {
throw new Error(
`Resolving the article "${slug}" answered ${resolution.status}.`,
);
}
return contentDeliveryPage({
item: detail.status === 200 ? zodArticle.parse(await detail.json()) : null,
resolution: await resolution.json(),
});
};
type ArticlePageData = Awaited>;
export const route = definePluginRoute({
load: async ({ params }) => await loadArticle(params.slug),
head: ({ loaderData }) =>
contentDeliveryPageHead(loaderData?.metadata, {
title: loaderData?.item.title,
}),
});
const ArticlePage = ({
loaderData: { item },
}: PluginRoutePageProps) => (
);
export default ArticlePage;
```
- `contentDeliveryPage` returns `{ item, metadata }`. It throws a not-found for an unknown slug or a draft, and a `308` redirect for a retired slug.
- `contentDeliveryPageHead` sets the title, the meta description (HTML stripped, cut at 160 characters), `robots` for a no-index record and `hreflang` alternates for localized content.
- `load` comes before `head`, so TypeScript can infer `loaderData`. `ContentArticle` is the plugin's own layout component.
## Render the record's fields [#render-the-records-fields]
The detail route returns each field in `publicApi.fields` in a shape made for rendering. The blog's `plugins/blog/src/pages/post-page.tsx` uses all of them:
| Field kind | What `/{slug}` returns | Render it with |
| -------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Text, textarea, slug | `string` (or `null` when nullable) | Plain JSX. React escapes it |
| [Rich text](/docs/content-engine/rich-text) | A ProseMirror document. Validate it with `zodRichTextDocument` | `RichTextContent` from `@vitnode/core/components/rich-text` |
| Dates such as `publishedAt` | An ISO string, or `null` | `DateFormat` from `@vitnode/core/components/date-format` inside a `