# 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 { CONFIG_PLUGIN } from "@/const";
import { BlogCategoryColorCell } from "@/views/admin/category/color-cell";
import { BlogCategoryColorField } from "@/views/admin/category/color-field";
import { blogCategoryNav } from "./nav";
export const adminContent = {
pluginId: CONFIG_PLUGIN.pluginId,
contentTypes: [
contentTypeAdmin({
...blogCategoryNav,
fields: {
color: { component: BlogCategoryColorField },
},
columns: {
color: { cell: BlogCategoryColorCell },
},
}),
],
} satisfies ContentFrontendPluginSource;
```
A cell receives the row, typed by the definition:
```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, restart `pnpm dev` and open **AdminCP → Example → Articles**. The API validates sorting and form input against the same definition, so the restart makes sure both sides read the new `admin` options.
Next, give the screens the permissions they need in [Production checklist](/docs/content-engine/production), or read every option in the [reference](/docs/content-engine/reference).
# Caching (/docs/content-engine/caching)
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. 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";
import { z } from "zod";
const zodArticleCard = z.object({
excerpt: z.string().nullable(),
slug: z.string(),
title: z.string(),
});
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 z.array(zodArticleCard).parse(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 { CONFIG_PLUGIN } from "@/const";
export const fetchBoards = async () => {
const res = await fetcher({
plugin: CONFIG_PLUGIN.pluginId,
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 { CONFIG_PLUGIN } from "@/const";
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: CONFIG_PLUGIN.pluginId,
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: CONFIG_PLUGIN.pluginId,
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 onSubmit: AutoFormOnSubmit = async (
{ board, ...values },
form,
) => {
const res = await fetcher({
plugin: CONFIG_PLUGIN.pluginId,
method: "post",
module: "content/discussions",
path: "/",
args: { body: { values: { ...values, board: Number(board) } } },
});
if (res.status === 201) {
// Redirect to the new discussion, as in Public write frontend.
return;
}
await rejectDiscussion(res, 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."
}
]
}
```
`useDiscussionRejection` from [Public write frontend](/docs/content-engine/public-write-frontend#build-the-shared-form) already puts field issues under their field. Add `"board"` to its list of fields and a board-specific message for `unavailable`:
```tsx title="plugins/example/src/pages/discussion-form.tsx"
const DISCUSSION_FIELDS = ["title", "body", "board"] as const; // [!code highlight]
const messageOf = (code: string, field?: DiscussionField) =>
t(
field === "board" && code === "unavailable"
? "issues.board_unavailable"
: isKnownIssueCode(code)
? ISSUE_MESSAGE_KEYS[code]
: "invalid",
);
```
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 { buildRoute } from "@vitnode/core/api/lib/route";
import { createContentContainerService } from "@vitnode/core/content/server";
import { HTTPException } from "hono/http-exception";
import { z } from "zod";
import { CONFIG_PLUGIN } from "@/const";
import { boardContainers } from "@/content/boards";
export const archiveBoardRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
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: CONFIG_PLUGIN.pluginId,
});
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. 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).
# 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 |
`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).
### 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({
pluginId: CONFIG_PLUGIN.pluginId,
containers: [boardContainers], // [!code ++]
contentTypes: [articleContent, discussionContent],
}),
```
```ts title="plugins/example/src/config.api.ts"
buildContentPublicModule({
pluginId: CONFIG_PLUGIN.pluginId,
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: CONFIG_PLUGIN.pluginId,
containers: [exampleBoardsNav], // [!code ++]
contentTypes: [exampleArticleNav, exampleDiscussionNav],
} satisfies AdminNavPluginSource;
```
```tsx title="plugins/example/src/admin/content.tsx"
export const adminContent = {
pluginId: CONFIG_PLUGIN.pluginId,
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".
### Build, migrate and restart [#build-migrate-and-restart]
Run this from the workspace root, then restart `pnpm dev`:
```bash tab="bun"
bun run build:plugins && bun run db:migrate
```
```bash tab="pnpm"
pnpm build:plugins && pnpm db:migrate
```
```bash tab="npm"
npm run build:plugins && 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]
Per-container policies are planned, not shipped. Today every container behaves
the same way.
Containers are where per-place rules will live: a publishing mode per board (go live or wait as a draft), moderator approval, who may post or read in a board, and boards hidden from guests. The groundwork is in place. A public create already resolves the container on the server and hands it to `resolveContentPublicationPolicy`, so these policies can arrive without changing your routes or forms.
- [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.
# 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/public-api#publish-an-article) |
| 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 build:plugins && pnpm db:migrate`, then restart `pnpm dev`. 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 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 `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.
- **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`.
- [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**.
Everyone else, signed in or not, still gets a `404`. Old slugs never redirect to a hidden record, and the list, sitemap and `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: **AdminCP → Advanced → 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#hide-duplicate-and-content-ids).
## 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/second-post"
curl -o /dev/null -w "%{http_code}\n" "http://localhost:3000/api/@vitnode/blog/content/blog/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 the page while signed in as one of its authors, and it loads with the hidden note instead.
# 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 a `src/const.ts` that exports the plugin id:
```ts title="plugins/example/src/const.ts"
export const CONFIG_PLUGIN = { pluginId: "@vitnode/example" as const };
```
[Create a plugin](/docs/dev/plugins/create) generates all three. 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 { buildModule } from "@vitnode/core/api/lib/module";
import { buildContentAdminModule } from "@vitnode/core/content/server";
import { CONFIG_PLUGIN } from "@/const";
import { categoryContent } from "@/database/categories";
export const adminModule = buildModule({
pluginId: CONFIG_PLUGIN.pluginId,
name: "admin",
routes: [],
modules: [
buildContentAdminModule({
pluginId: CONFIG_PLUGIN.pluginId,
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({
pluginId: CONFIG_PLUGIN.pluginId,
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 { CONFIG_PLUGIN } from "@/const";
import { categoryContentType } from "@/content/category";
export const adminNav = {
pluginId: CONFIG_PLUGIN.pluginId,
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 build:plugins && bun run db:migrate
```
```bash tab="pnpm"
pnpm build:plugins && pnpm db:migrate
```
```bash tab="npm"
npm run build:plugins && 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.
### Restart the dev server [#restart-the-dev-server]
Stop `pnpm dev` and start it again. The API registers routes when it starts, so the new staff routes exist only after a restart.
### 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 build:plugins && bun run db:migrate
```
```bash tab="pnpm"
pnpm build:plugins && pnpm db:migrate
```
```bash tab="npm"
npm run build:plugins && npm run db:migrate
```
1. `build:plugins` compiles each plugin to `dist/`. `db:migrate` reads that output and does not build, so skipping this step migrates 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**. Run `pnpm build:plugins` and restart `pnpm dev`.
### 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 = CONFIG_PLUGIN.pluginId;
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/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 rebuild the plugin and migrate:
```bash tab="bun"
bun run build:plugins && bun run db:migrate
```
```bash tab="pnpm"
pnpm build:plugins && pnpm db:migrate
```
```bash tab="npm"
npm run build:plugins && 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 the [Feature this article example](/docs/content-engine/services#example-3-a-feature-this-article-button). 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#example-3-a-feature-this-article-button). 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).
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({
pluginId: CONFIG_PLUGIN.pluginId,
name: "admin",
routes: [],
modules: [
buildContentAdminModule({
pluginId: CONFIG_PLUGIN.pluginId,
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]
Restart `pnpm dev`, 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 |
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 |
| `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.
## 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`.
## 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 |
A single relation must be `required` or `nullable`. A to-many relation can be neither. 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#hide-duplicate-and-content-ids).
## 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:
## 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 |
| `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) |
| Add drafts and a read-only public 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) |
| 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) |
| 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.
# 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 only a long-lived Node API server provides, such as `apps/api` or a [self-hosted](/docs/dev/deployments/self-hosted) install.
| Deployment | Field locks and autosave | Presence | Rich text |
| ------------------------------------------------------------------ | ---------------------------- | -------- | ----------------------------------------------- |
| Long-lived Node API server with the WebSocket | Live, pushed over the socket | Yes | Co-edited |
| No WebSocket, such as [Vercel](/docs/dev/deployments/cloud/vercel) | Polled over HTTP every 10 s | No | One editor at a time, locked like a plain field |
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. A newsroom behind one office IP can reach the default of 80 requests a minute. A refused lock request leaves the field without autosave, so raise `rateLimiter.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 build:plugins && bun run db:migrate
```
```bash tab="pnpm"
pnpm build:plugins && pnpm db:migrate
```
```bash tab="npm"
npm run build:plugins && 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.
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: CONFIG_PLUGIN.pluginId,
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` | Plugin id, `messages`, `localeFiles` and `routes` only |
| `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 { CONFIG_PLUGIN } from "@/const";
import { exampleArticleNav, exampleCategoryNav, examplePageNav } from "./nav";
export const adminContent = {
pluginId: CONFIG_PLUGIN.pluginId,
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 { CONFIG_PLUGIN } from "@/const";
import messages from "./locales";
import { routes } from "./routes";
export const examplePlugin = () =>
buildPlugin({
...CONFIG_PLUGIN,
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, the `adminNav` / `adminContent` export and rebuild the plugin |
| A table is missing from the migration | The table is not exported from `src/database/*.ts`, or the plugin was not rebuilt | Export it and run `build:plugins` before `db:migrate` |
| `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#example-3-a-feature-this-article-button).
## Ship the migration [#ship-the-migration]
- [ ] Commit the migration that `pnpm build:plugins && 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]
**AdminCP → Advanced → 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)
A content type is private until you opt in. This guide follows `example.article` from the example plugin. It gets a draft and a published state, and its published records become readable by anyone at `/api/@vitnode/example/content/articles`. You list exactly which fields leave the server.
You need a content type with an AdminCP screen, like the one built in [Create a content type](/docs/content-engine/create-a-content-type).
### Turn on publication and the public API [#turn-on-publication-and-the-public-api]
Add `publication` and `publicApi` to the definition. This excerpt leaves out the article fields this guide does not use:
```ts title="plugins/example/src/content/article.ts"
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" }),
excerpt: field.textarea({ maxLength: 500, nullable: true }),
featured: field.boolean({ defaultValue: false }),
author: field.user(),
category: field.relation({
required: true,
onDelete: "restrict",
target: () => categoryContentType,
}),
},
publication: true, // [!code ++]
// [!code ++:9]
publicApi: {
path: "articles",
fields: ["title", "slug", "excerpt", "featured", "category", "publishedAt"],
searchableFields: ["title", "excerpt"],
orderableFields: ["publishedAt", "title"],
filterableFields: ["category", "featured"],
defaultOrderBy: "publishedAt",
defaultOrder: "desc",
},
});
```
- `publication` adds a `status` column (`draft` or `published`, default `draft`), a `publishedAt` timestamp, a `can_publish` staff permission and **Publish** / **Unpublish** actions in the AdminCP.
- `publicApi.fields` is an allowlist with no wildcard. It needs exactly one slug field, because the detail route finds records by slug. `status` and `field.user()` fields can never be listed, which is why `author` stays private.
- Searchable, filterable and orderable fields must also be in `fields`. `publishedAt` is always orderable and is the default sort.
- `path` is one lowercase URL segment and cannot be `admin`.
`publicApi` without `publication` throws when the definition loads, so drafts cannot leak by accident.
### Register the public module [#register-the-public-module]
Add `buildContentPublicModule` to the plugin's API config. The example plugin passes all five content models; one without `publicApi` is skipped:
```ts title="plugins/example/src/config.api.ts"
import { buildContentPublicModule } from "@vitnode/core/content/server"; // [!code ++]
import { articleContent } from "@/database/articles"; // [!code ++]
export const exampleApiPlugin = () =>
buildApiPlugin({
pluginId: CONFIG_PLUGIN.pluginId,
modules: [
adminModule,
// [!code ++:4]
buildContentPublicModule({
pluginId: CONFIG_PLUGIN.pluginId,
contentTypes: [articleContent],
}),
],
});
```
### Label the publish permission [#label-the-publish-permission]
Give the new permission a name next to the other article permissions. The `Status` and `Published at` columns need no strings, because the AdminCP derives them from the column names.
```json title="plugins/example/src/locales/en.json"
{
"@vitnode/example:article:can_publish": "Publish and unpublish articles"
}
```
### Build, migrate and restart [#build-migrate-and-restart]
Run this from the workspace root, then restart `pnpm dev` so the API registers the new routes:
```bash tab="bun"
bun run build:plugins && bun run db:migrate
```
```bash tab="pnpm"
pnpm build:plugins && pnpm db:migrate
```
```bash tab="npm"
npm run build:plugins && npm run db:migrate
```
The migration adds `status`, `publishedAt` and an `example_articles_status_published_at_idx` index. Existing rows become drafts. [Database](/docs/content-engine/database) covers migrations in detail.
### Publish an article [#publish-an-article]
Open **AdminCP → Example → Articles**. New articles show a **Draft** badge. Click the paper plane **Publish** button at the end of a row:
Confirm with **Yes, publish it**. The badge turns into **Published** and the article appears in the public API.
The **Create Article** dialog also gets **Save as draft** and **Publish** buttons, and you can publish many rows at once with [bulk actions](/docs/content-engine/admincp#bulk-actions).
## Check the result [#check-the-result]
Request the list. Filters are query parameters named after the field, and `orderBy` accepts the orderable fields:
```bash
curl "http://localhost:3000/api/@vitnode/example/content/articles?featured=false&orderBy=title&order=asc&first=1"
```
```json
{
"edges": [
{
"title": "Hello Content Engine",
"slug": "hello-content-engine",
"excerpt": "A first article from the example plugin, served by the public API and rendered on its own page.",
"featured": false,
"category": { "id": 5 },
"publishedAt": "2026-10-10T10:55:34.335Z"
}
],
"pageInfo": {
"totalCount": 1,
"totalPages": 1,
"currentPage": null,
"pageSize": 1,
"count": 1,
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "eyJjb2x1bW4iOiJ0aXRsZSIsImlkIjozLCJ2YWx1ZSI6IkhlbGxvIENvbnRlbnQgRW5naW5lIn0",
"endCursor": "eyJjb2x1bW4iOiJ0aXRsZSIsImlkIjozLCJ2YWx1ZSI6IkhlbGxvIENvbnRlbnQgRW5naW5lIn0"
}
}
```
A relation comes back as `{ "id": 5 }`. A file field, such as the example's `gallery`, comes back as `{ id, name, url, mimeType, size, width, height }`, never as the raw file ID.
Unpublish the article and request it by slug. A draft, a record scheduled for later and a slug that never existed all get the same `404`, so the API never confirms that a draft exists:
```bash
curl -i http://localhost:3000/api/@vitnode/example/content/articles/hello-content-engine
```
```text
HTTP/1.1 404
Article not found.
```
## Read the API from your plugin [#read-the-api-from-your-plugin]
The [fetcher](/docs/dev/fetcher) types the routes from the plugin's API registry. The module is `content/` followed by `publicApi.path`. Put a helper like this anywhere in your plugin:
```ts title="featured-articles.ts"
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { z } from "zod";
const zodArticleCard = z.object({
excerpt: z.string().nullable(),
publishedAt: z.string().nullable(),
slug: z.string(),
title: z.string(),
});
export const fetchFeaturedArticles = async () => {
const response = await fetcher({
plugin: "@vitnode/example",
method: "get",
module: "content/articles",
path: "/",
args: { query: { featured: true, first: 5 } },
});
if (response.status !== 200) {
throw new Error(`Loading articles answered ${response.status}.`);
}
const { edges } = await response.json();
return z.array(zodArticleCard).parse(edges);
};
```
The path, the query and `pageInfo` are typed. Each record is a `Record`, so parse the fields you use with Zod, as the example's article page does. A content type without `publicApi` adds no module: `module: "content/categories"` is a compile error. An `orderBy` outside the allowlist type-checks but answers `400`.
## Public API routes [#public-api-routes]
All routes are `GET` and live under `/api/{pluginId}/content/{publicApi.path}`.
| Route | Returns |
| -------------------------- | --------------------------------------------------------------------------------------------------------- |
| `/` | Published records with `edges` and `pageInfo` |
| `/{slug}` | One published record, or `404` |
| `/preview/{token}` | A draft behind a signed link, only with [preview links](/docs/content-engine/editorial#preview-links) |
| `/delivery/resolve/{slug}` | Whether a URL is a record, a redirect or nothing, only with [delivery](/docs/content-engine/public-pages) |
| `/delivery/item/{id}` | Canonical URL and SEO metadata for one record, only with delivery |
| `/delivery/sitemap` | Sitemap entries, only with `delivery.sitemap` |
The list accepts `first` or `last` (at most 50, also the default), `cursor`, `page`, `search` over `searchableFields`, `orderBy`, `order` and one parameter per filterable field. A filterable [container field](/docs/content-engine/fields#container) also accepts `includeDescendants=true`, so `?board=4&includeDescendants=true` lists board 4 and every board below it; an unknown container answers `404`. See [Containers on the frontend](/docs/content-engine/container-frontend#add-the-board-page). A record is public only when its `status` is `published`, its `publishedAt` is set and not later than the database clock, and it is not [hidden](/docs/content-engine/content-visibility). Every `publicApi` option is in the [content type reference](/docs/content-engine/reference#publicapi).
Next, give each article a web page with [Public pages](/docs/content-engine/public-pages), or see how these reads are cached in [Caching](/docs/content-engine/caching).
To let signed-in members create and edit records through the same path, add [Public write](/docs/content-engine/public-write).
# 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 { CONFIG_PLUGIN } from "@/const";
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: CONFIG_PLUGIN.pluginId,
method: "get",
module: "content/articles",
path: "/delivery/resolve/{slug}",
args: { params: { slug: encodeURIComponent(slug) } },
}),
fetcher({
plugin: CONFIG_PLUGIN.pluginId,
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.
## Check the result [#check-the-result]
Run `pnpm build:plugins`, restart `pnpm dev` and open `http://localhost:3000/articles/hello-content-engine`. Delivery adds no columns, so there is nothing to migrate.
The page head carries the article's title and excerpt:
```bash
curl -s http://localhost:3000/articles/hello-content-engine | grep -oE '[^<]*|'
```
```html
Hello Content Engine - VitNode
```
The resolve route shows what the loader received. `openGraph`, `canonicalPath` and `hreflang` are there too if you want to render more tags yourself:
```bash
curl http://localhost:3000/api/@vitnode/example/content/articles/delivery/resolve/hello-content-engine
```
```json
{
"type": "content",
"canonicalPath": "/articles/hello-content-engine",
"canonicalInternalPath": "/articles/hello-content-engine",
"seo": {
"title": "Hello Content Engine",
"description": "A first article from the example plugin, served by the public API and rendered on its own page."
},
"openGraph": {
"title": "Hello Content Engine",
"description": "A first article from the example plugin, served by the public API and rendered on its own page."
},
"robots": { "follow": true, "index": true },
"alternates": [],
"hreflang": { "languages": {} },
"isFallback": false,
"itemId": null,
"locale": null,
"requestedLocale": null
}
```
`itemId` is `null` because `id` is not in the article's `publicApi.fields`. Unpublish the article and the same URL shows the app's not-found page.
## Keep old URLs working [#keep-old-urls-working]
Renaming a slug breaks links people already shared. With `delivery.redirects`, the old slug is stored in `core_content_slug_history` when an editor changes it, and the resolve route answers `{ "type": "redirect", "status": 308, "location": "/articles/new-slug" }`. `contentDeliveryPage` turns that into a `308` redirect. While a record is [hidden](/docs/content-engine/content-visibility), its page and its old slugs answer `404` instead.
## Sitemap entries [#sitemap-entries]
`delivery.sitemap` lists every published URL for crawlers. [SEO](/docs/content-engine/seo#sitemap) covers the route, paging and turning the entries into XML.
## Localized URLs [#localized-urls]
Every URL delivery builds follows the app's [localized URL rules](/docs/dev/i18n/localized-urls): language prefixes, per-language domains and translated route segments. The blog plugin's posts are a working example. They are [localized](/docs/content-engine/localization) and live at `/blog/:slug`, and the web app translates that route for Polish:
```ts title="apps/web/src/vitnode.config.ts"
i18n: {
routePaths: {
pl: {
'/blog/:slug': '/wpisy/:slug',
},
},
},
```
The blog's sitemap then lists `/blog/english-title` for English and `/pl/wpisy/polish-title` for Polish, and only published translations become `hreflang` alternates. Every `delivery` option is in the [content type reference](/docs/content-engine/reference#delivery).
# Public write API (/docs/content-engine/public-write-api)
This page lists everything `publicWrite` accepts and everything its routes answer. For a guided setup, follow [Public write](/docs/content-engine/public-write). Examples use `example.discussion` from the example plugin, whose write routes live at `/api/@vitnode/example/content/discussions`.
## `publicWrite` options [#publicwrite-options]
`publicWrite` is a block of `defineContentType` from `@vitnode/core/content`. Leave it out, or set it to `false`, to keep public writing off.
```ts title="plugins/example/src/content/discussion.ts"
publicWrite: {
ownership: { field: "author" },
create: { fields: ["title", "body"], status: "published" },
update: { fields: ["title", "body"] },
},
```
| Name | Type | Required | Default | Description |
| ----------------- | -------------------------- | -------- | --------- | ------------------------------------------------------------------------------------------------------- |
| `ownership.field` | field name | Yes | | A single, shared `field.user()`. The server stamps it with the signed-in user on create |
| `create` | object | No | off | Adds `POST /`. At least one of `create` and `update` is required |
| `create.fields` | field names | Yes | | Non-empty allowlist of [writable fields](#writable-fields). Must include every field a new record needs |
| `create.status` | `"draft"` \| `"published"` | No | `"draft"` | What a new record starts as. The server decides; the payload never does |
| `create.captcha` | `boolean` | No | `false` | Requires a [captcha](/docs/dev/captcha) token on every create, when a captcha provider is configured |
| `update` | object | No | off | Adds `GET /{id}/edit` and `PATCH /{id}`, and registers the `can_edit` moderator permission |
| `update.fields` | field names | Yes | | Non-empty allowlist of writable fields |
| `update.own` | `boolean` | No | `true` | Lets the owner edit their own record while it is not hidden. With `false`, only moderators edit |
The content type also needs:
- **`publicApi`**, and therefore `publication`. Without it, `publicWrite` only accepts `false` at the type level and throws when the definition loads.
- **No localized fields.** Public writes do not create translations yet, so a content type with `localization` cannot use `publicWrite`.
The read and write allowlists are independent. A field can be writable without being in `publicApi.fields`, and the other way around.
### Writable fields [#writable-fields]
`create.fields` and `update.fields` accept top-level, shared fields of these kinds, from `CONTENT_PUBLIC_WRITABLE_KINDS`:
`boolean`, `container`, `dateTime`, `enum`, `number`, `relation`, `richText`, `slug`, `text`, `textarea`
They never accept:
| Field | Why |
| -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `file` | A file id names somebody's upload, and the upload routes are AdminCP-only |
| `user` | A user id names a person. Ownership is stamped by the server instead |
| `group`, `repeatable`, `blocks` | Each hides more fields or child rows behind one name, which a strict allowlist cannot describe |
| A group leaf such as `"seo.title"` | Only top-level fields can be written publicly |
| A slug with a `source` | Derived on the server. List its source field instead, so nobody can squat on another address |
| A `localized: true` field | Public writes do not create translations yet |
| The ownership field | Only the server sets the owner |
| `id`, `createdAt`, `updatedAt`, `status`, `publishedAt`, `version`, `hiddenAt`, `hiddenBy`, `visibility` | Managed by the server |
A `relation` is writable only when its target content type has a `publicApi`. On each request, every referenced record must also be published and not hidden.
A `container` is writable in `create.fields` only. A member picks the container once, when they create the record; moving it later is a staff edit, so an author cannot drop their record into a container they could not post in. On each create, the server reads the container back inside the write's transaction and checks that it belongs to the field's collection. A missing or foreign container answers `400` with an `unavailable` issue on that field. See [Containers on the frontend](/docs/content-engine/container-frontend#let-members-pick-a-board).
### Moderator permission [#moderator-permission]
A content type with `update` adds `can_edit` to its plugin's moderator permissions, under the content type's permission module, such as `discussion`. It shares the `@vitnode/example:discussion:can_edit` label with the admin permission. If the plugin declares that module under `permissionStaff.moderator` itself, the declared list wins.
## Generated routes [#generated-routes]
The routes live in the public module, under `/api/{pluginId}/content/{publicApi.path}`, next to the [public read routes](/docs/content-engine/public-api#public-api-routes). Only the ones `publicWrite` enables are mounted, and the [fetcher](/docs/dev/fetcher) types them the same way: a `post` to a content type without `create` is a compile error.
| Method and path | Enabled by | Body | Success |
| ---------------- | ---------- | ----------------------------- | -------------------------------------------------------------------------- |
| `POST /` | `create` | `{ values }` | `201` with the record |
| `GET /{id}/edit` | `update` | | `200` with the record, drafts included, `Cache-Control: private, no-store` |
| `PATCH /{id}` | `update` | `{ values, expectedVersion }` | `200` with the record |
`GET /{id}/edit` and `PATCH /{id}` answer only for the owner and for moderators. There is no public delete, publish or hide route.
A request passes these checks in order: the rate limit, the captcha (create only), the session, the body, then for an edit the owner or moderator check and the relation targets, all before anything is written.
### Request bodies [#request-bodies]
Both bodies are strict JSON objects. A key outside the allowlist is refused, never dropped.
| Body | Field | Rules |
| ------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /` | `values` | The fields in `create.fields`. Required fields must be present; optional fields may be left out |
| `PATCH /{id}` | `values` | Any non-empty subset of `update.fields`. Only the fields sent change |
| `PATCH /{id}` | `expectedVersion` | Positive integer, required on an [editorial](/docs/content-engine/editorial#safe-concurrent-edits) content type and refused on any other |
Each value uses the field's own validation, the same as in the AdminCP. A rich text field takes a ProseMirror document, and a relation takes an id, or an array of ids for `multiple: true`.
```http title="PATCH request"
PATCH /api/@vitnode/example/content/discussions/1
Content-Type: application/json
Cookie: vitnode_auth=
{ "values": { "title": "How do I add a widget zone below an article?" }, "expectedVersion": 2 }
```
### Response record [#response-record]
Every successful response is the same record shape:
| Name | Type | Description |
| ------------- | -------------------------- | -------------------------------------------------------------------------------------- |
| `id` | `number` or `string` | A string for the `uuid` and `bigint` [ID strategies](/docs/content-engine/id-strategy) |
| `slug` | `string` \| `null` | The value of the `publicApi` slug field, the key of `GET /{slug}` |
| `status` | `"draft"` \| `"published"` | |
| `publishedAt` | ISO date string \| `null` | |
| `version` | `number` | Editorial content types only. Send it back as `expectedVersion` |
| `values` | object | Every field in `create.fields` or `update.fields`, `null` when empty. Nothing else |
```json title="200"
{
"id": 1,
"slug": "how-do-i-add-a-widget-zone-below-an-article",
"status": "published",
"publishedAt": "2026-10-13T09:12:44.512Z",
"version": 3,
"values": {
"title": "How do I add a widget zone below an article?",
"body": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "I want one under my article." }
]
}
]
}
}
}
```
## Status codes [#status-codes]
| Status | Body | When |
| ------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `200` | Record | `GET /{id}/edit` or `PATCH /{id}` succeeded |
| `201` | Record | `POST /` succeeded |
| `400` | Rejection, `CONTENT_INVALID_INPUT` | The body is not JSON, breaks a rule, names a field outside the allowlist, or links to a record that is not available |
| `400` | Plain text | `create.captcha` is on and the captcha token is missing or invalid |
| `401` | Rejection, `CONTENT_UNAUTHENTICATED` | No user session |
| `403` | Rejection, `CONTENT_FORBIDDEN` | The record is publicly readable, but you are not its owner or a moderator |
| `404` | Rejection, `CONTENT_NOT_FOUND` | The record does not exist, or it is a draft or hidden and you may not edit it |
| `409` | Conflict | A unique value is taken, or the record changed since `expectedVersion` |
| `429` | `{ error, retryAfter }` | The [rate limit](#rate-limit) is used up |
Validation failures are `400`, the status every VitNode input error uses, not `422`.
### Rejection [#rejection]
`400`, `401`, `403` and `404` answer with one JSON shape, `zodContentPublicWriteRejection` from `@vitnode/core/content`:
```ts
type ContentPublicWriteRejection = {
code:
| "CONTENT_FORBIDDEN"
| "CONTENT_INVALID_INPUT"
| "CONTENT_NOT_FOUND"
| "CONTENT_UNAUTHENTICATED";
contentTypeId: string;
issues: { code: string; field: string | null; message: string }[];
};
```
`issues` is empty for `401`, `403` and `404`. For `400`, each issue names the field it belongs to, or `null` for the request as a whole. Translate an issue by its `code`; `message` is English text for developers.
A title that is too short and an owner the client tried to set:
```json title="400"
{
"code": "CONTENT_INVALID_INPUT",
"contentTypeId": "example.discussion",
"issues": [
{
"code": "too_small",
"field": "title",
"message": "Too small: expected string to have >=3 characters"
},
{
"code": "not_writable",
"field": "author",
"message": "This field cannot be written."
}
]
}
```
A body that is not JSON:
```json title="400"
{
"code": "CONTENT_INVALID_INPUT",
"contentTypeId": "example.discussion",
"issues": [
{
"code": "invalid_json",
"field": null,
"message": "The body must be JSON."
}
]
}
```
If discussions had a `category` relation, a draft, hidden or missing category would all get the same answer:
```json title="400"
{
"code": "CONTENT_INVALID_INPUT",
"contentTypeId": "example.discussion",
"issues": [
{
"code": "unavailable",
"field": "category",
"message": "The selected record is not available."
}
]
}
```
A member editing somebody else's published discussion:
```json title="403"
{
"code": "CONTENT_FORBIDDEN",
"contentTypeId": "example.discussion",
"issues": []
}
```
The same member editing somebody else's draft gets `404` with `"code": "CONTENT_NOT_FOUND"`, so the answer never confirms that the draft exists.
### Conflict [#conflict]
`409` answers with `zodContentConflict`, the same shape the AdminCP's editorial routes use:
```json title="409 version conflict"
{
"code": "CONTENT_VERSION_CONFLICT",
"contentTypeId": "example.discussion",
"currentVersion": 4,
"expectedVersion": 3,
"itemId": 1
}
```
```json title="409 unique conflict"
{
"code": "CONTENT_UNIQUE_CONFLICT",
"contentTypeId": "example.discussion",
"itemId": null
}
```
`itemId` is `null` for a unique conflict on create. On `example.discussion`, a unique conflict means the slug derived from the title is taken.
### Rate limit [#rate-limit]
`POST` and `PATCH` requests to a path containing `/content/`, outside `/admin/`, share a budget of 20 requests per 60 seconds per IP, on top of the global limit. Change it with `rateLimiter.contentWrite` in `apps/api/src/vitnode.api.config.ts`. It is off when `NODE_ENV=development`. See [Rate limiter](/docs/dev/advanced/rate-limiter) for the `429` body and Redis-backed counters.
### Codes [#codes]
| Constant | Value |
| -------------------------------------------- | -------------------------- |
| `CONTENT_PUBLIC_WRITE_CODES.invalidInput` | `CONTENT_INVALID_INPUT` |
| `CONTENT_PUBLIC_WRITE_CODES.unauthenticated` | `CONTENT_UNAUTHENTICATED` |
| `CONTENT_PUBLIC_WRITE_CODES.forbidden` | `CONTENT_FORBIDDEN` |
| `CONTENT_PUBLIC_WRITE_CODES.notFound` | `CONTENT_NOT_FOUND` |
| `CONTENT_CONFLICT_CODES.version` | `CONTENT_VERSION_CONFLICT` |
| `CONTENT_CONFLICT_CODES.unique` | `CONTENT_UNIQUE_CONFLICT` |
Issue codes:
| Code | Source | Meaning |
| ------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------- |
| `not_writable` | `CONTENT_PUBLIC_WRITE_ISSUE_CODES.notWritable` | A key in `values` outside the allowlist. One issue per key |
| `unavailable` | `CONTENT_PUBLIC_WRITE_ISSUE_CODES.unavailable` | A relation names a record that is missing, a draft or hidden |
| `invalid_json` | `CONTENT_PUBLIC_WRITE_ISSUE_CODES.invalidJson` | The body is not JSON |
| `too_small`, `too_big`, `invalid_type`, `invalid_value` | Zod | A value breaks the field's rules, such as `minLength` or an enum |
| `custom` | Zod | A custom rule, such as an empty required rich text document or an update with no fields |
| `unrecognized_keys` | Zod | A key beside `values`, such as `expectedVersion` on a content type without `editorial` |
`parseContentPublicWriteRejection(body)` takes the parsed JSON body and returns the rejection, or `null` for any other shape. Unlike `parseContentConflict`, it does not take a string.
```ts
import { parseContentPublicWriteRejection } from "@vitnode/core/content";
const rejection = parseContentPublicWriteRejection(
await res.json().catch(() => null),
);
```
## Server API [#server-api]
Use these from `@vitnode/core/content/server` when a plugin needs its own write route, such as "start a discussion from a question", with the same ownership, authorization and effects as the generated routes.
### `createContentPublicWriteService` [#createcontentpublicwriteservice]
```ts
createContentPublicWriteService({ c, model, pluginId });
```
`c` is the Hono context, `model` is the content model, such as `discussionContent`, and `pluginId` is the plugin that owns the content type. The service acts as the user session of `c`, never as an AdminCP session.
| Method | Returns | Behavior |
| ------------------ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `create(values)` | `Promise` | Validates `values` against `create.fields`, stamps the owner, checks relation targets, creates and, when the policy says so, publishes in one transaction. Emits events and syncs search after the commit |
| `findEditable(id)` | `Promise` | Reads one record for its owner or a moderator, drafts included |
| `update(id, body)` | `Promise` | Validates `{ values, expectedVersion }`, locks the row, authorizes, checks relation targets and writes in one transaction. Emits events and syncs search after the commit |
The methods throw instead of answering:
| Error | When |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `ContentPublicWriteDenied` | `reason` is `"unauthenticated"`, `"forbidden"` or `"notFound"`. Also `"notFound"` when `create` or `update` is off |
| `ContentPublicWriteInputError` | A relation target is not available. Carries `field` and `detail` |
| `ZodError` | `values` or the body breaks the allowlist or a field rule |
| `ContentVersionConflict` | `expectedVersion` is out of date (editorial content types) |
| Postgres unique violation | A unique value is taken |
A route that starts a discussion from a single question. `withHttpErrors` turns a unique conflict into the structured `409`, and the `catch` maps a refusal to its status:
```ts title="plugins/example/src/api/modules/discussions/ask.route.ts"
import { buildRoute } from "@vitnode/core/api/lib/route";
import { ContentPublicWriteDenied } from "@vitnode/core/content";
import {
createContentPublicWriteService,
withHttpErrors,
} from "@vitnode/core/content/server";
import { HTTPException } from "hono/http-exception";
import { z } from "zod";
import { CONFIG_PLUGIN } from "@/const";
import { discussionContent } from "@/database/discussions";
export const askRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
route: {
method: "post",
path: "/ask",
description: "Start a discussion from a question",
withCaptcha: true,
request: {
body: {
required: true,
content: {
"application/json": {
schema: z.object({ question: z.string().trim().min(3).max(200) }),
},
},
},
},
responses: {
201: {
content: {
"application/json": {
schema: z.object({ id: z.number(), slug: z.string().nullable() }),
},
},
description: "Discussion started",
},
401: { description: "Not signed in" },
409: { description: "A discussion with this title exists" },
},
},
handler: async c => {
const { question } = c.req.valid("json");
const service = createContentPublicWriteService({
c,
model: discussionContent,
pluginId: CONFIG_PLUGIN.pluginId,
});
try {
const record = await withHttpErrors(
"create",
async () =>
await service.create({
title: question,
body: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: question }],
},
],
},
}),
{ contentTypeId: discussionContent.definition.id, structured: true },
);
return c.json({ id: record.id, slug: record.slug }, 201);
} catch (error) {
if (error instanceof ContentPublicWriteDenied) {
throw new HTTPException(error.reason === "unauthenticated" ? 401 : 404);
}
throw error;
}
},
});
```
A route outside `/content/` only gets the global rate limit, which is why this one asks for a captcha.
### Helpers [#helpers]
| Function | Returns | Behavior |
| ------------------------------------------------------------------------------ | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `resolveContentPublicWriteActor(c)` | `{ type: "api", userId }` \| `null` | The signed-in user of the public session. Never an AdminCP session, never the body |
| `authorizeContentPublicWrite(c, { action, actor, definition, pluginId, row })` | `{ allowed, via: "owner" \| "moderator" \| null }` | The owner and moderator rules above, for a stored `row`. `action` is `"update"` |
| `contentPublicWriteRefusal(row)` | `"forbidden"` \| `"notFound"` | `"forbidden"` only when the public can already read `row` |
| `resolveContentPublicationPolicy({ actor, container, definition, operation })` | `"draft"` \| `"published"` | The status a public create starts with. Today it returns `publicWrite.create.status`. `container` is the resolved container, or `null` |
## Definition errors [#definition-errors]
`defineContentType` checks `publicWrite` when the definition loads and throws a `ContentEngineError` that starts with `[Content Engine] :`. Relation targets that were still loading are checked again when the routes are built at boot.
| Problem | Error starts with |
| --------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| No `publicApi` | ``publicWrite needs `publicApi`.`` |
| A localized content type | `publicWrite is not available on a localized content type yet.` |
| Not an object or `false` | ``publicWrite is an options object or `false`.`` |
| An unknown option | `publicWrite.x is not an option. publicWrite accepts: create, ownership, update.` |
| `delete` | `publicWrite.delete is not supported.` |
| `permissions`, `create.permission` or `update.permission` | `publicWrite.permissions is not an option.` |
| No `ownership` | `publicWrite.ownership is required` |
| `ownership.assignOnCreate` | `publicWrite.ownership.assignOnCreate is not an option.` |
| `ownership.field` is unknown | `publicWrite.ownership.field references unknown field "x".` |
| `ownership.field` is not a single, shared user field | ``publicWrite.ownership.field "x" must be a single, shared `field.user()`.`` |
| Neither `create` nor `update` | `publicWrite enables nothing.` |
| An empty `fields` list | `publicWrite.create.fields is empty. There is no wildcard` |
| A field listed twice | `publicWrite.create.fields lists "x" twice.` |
| A group leaf | `publicWrite.create.fields includes the leaf path "x".` |
| A server-managed column | `publicWrite.create.fields includes "x", which the server manages.` |
| The ownership field | `publicWrite.create.fields includes the ownership field "x".` |
| An unknown field | `publicWrite.create.fields references unknown field "x".` |
| A localized field | `publicWrite.create.fields includes the localized field "x".` |
| A kind that cannot be written | `publicWrite.create.fields includes "x" of kind "file", which cannot be written publicly.` |
| A slug with a `source` | `publicWrite.create.fields includes the slug "slug", which is derived from "title"` |
| A relation whose target has no `publicApi` | `publicWrite.create.fields includes the relation "x", but its target "y" has no public API.` |
| A required field missing from `create.fields` | `publicWrite.create.fields leaves out "x", which every new record needs.` |
| A container field in `update.fields` | `publicWrite.update.fields includes the container field "x".` |
| `create.status` is not a status | `publicWrite.create.status is "x". It is one of: draft, published.` |
| `create.captcha` or `update.own` is not a boolean | ``publicWrite.create.captcha is `true` or `false`.`` |
The field-list errors read `publicWrite.update.fields` for the `update` list.
# Public write frontend (/docs/content-engine/public-write-frontend)
This guide builds the two pages members use to write content: `/discussions/new` to start a discussion and `/discussions/:id/edit` to change one. Both call the [public write routes](/docs/content-engine/public-write) of `example.discussion` with the typed [fetcher](/docs/dev/fetcher), show field errors where they belong, and handle sign-in, permission and version problems.
The finished pages are in the example plugin under `plugins/example/src/pages/`. The code below is the same, with some styling and helpers trimmed.
## Before you begin [#before-you-begin]
You need a content type with `publicWrite`, registered in the public module, like the one built in [Public write](/docs/content-engine/public-write#enable-public-writing). The fetcher's `module` is `content/` plus `publicApi.path`, so every call here uses `module: "content/discussions"`. Routes, bodies and responses are inferred from that, and a `post` to a content type without `publicWrite.create` is a compile error.
### Add the routes [#add-the-routes]
Register both pages in your plugin's `src/routes.ts`. The create page needs a signed-in visitor, so `requires: "authenticated"` sends guests to sign in first. The edit page handles a missing session itself, because the API decides who may edit:
```ts title="plugins/example/src/routes.ts"
import { discussionSearch } from "./pages/discussion-search"; // [!code ++]
export const routes = definePluginRoutes([
// [!code ++:16]
page("/discussions/new", {
component: lazy(() => import("./pages/discussion-new-page")),
messages: ["@vitnode/example.discussions"],
requires: "authenticated",
}),
page("/discussions/:id/edit", {
component: lazy(() => import("./pages/discussion-edit-page")),
messages: ["@vitnode/example.discussions"],
}),
page("/discussions/:slug", {
component: lazy(() => import("./pages/discussion-page")),
messages: ["@vitnode/example.articles", "@vitnode/example.discussions"],
search: discussionSearch,
}),
]);
```
`/discussions/:slug` is the read page the create form redirects to. It loads the discussion from the public `GET /{slug}` route like any [public page](/docs/content-engine/public-pages), and shows the success toast when the URL carries `?created=true`. Copy it from `plugins/example/src/pages/discussion-page.tsx`.
### Add the messages [#add-the-messages]
The pages translate everything from the `discussions` namespace. Error messages are keyed by the API's codes, never by its English `message`:
```json title="plugins/example/src/locales/en.json"
{
"@vitnode/example": {
"discussions": {
"new": {
"title": "Start a discussion",
"desc": "Ask a question or share an idea. Everyone can read it as soon as you post it.",
"submit": "Post discussion"
},
"edit": {
"title": "Edit discussion",
"desc": "Changes are saved as a new revision, so nothing you replace is lost.",
"submit": "Save changes",
"success": {
"title": "Changes saved",
"desc": "\"{title}\" now shows your latest edits."
},
"conflict": {
"title": "Someone else edited this discussion",
"desc": "It changed after you opened it. Reload to see the latest version - copy anything you want to keep first, because reloading discards your edits.",
"reload": "Reload latest version"
}
},
"form": {
"title": {
"label": "Title",
"min": "Use at least 3 characters",
"max": "Keep it under 200 characters"
},
"body": {
"label": "Message",
"desc": "Type / for headings, lists and more.",
"required": "Write a message"
}
},
"state": {
"unauthenticated": {
"title": "Sign in to continue",
"desc": "You need an account to start or edit a discussion.",
"action": "Sign in"
},
"forbidden": {
"title": "You can't edit this discussion",
"desc": "Only its author or a moderator can change it."
},
"not_found": {
"title": "Discussion not found",
"desc": "It may have been removed, or the link is wrong."
}
},
"errors": {
"title": "Couldn't save the discussion",
"invalid": "Some details need another look.",
"unauthenticated": "Your session has ended. Sign in again, then try once more.",
"forbidden": "Only its author or a moderator can change this discussion.",
"not_found": "This discussion no longer exists.",
"unique": "A discussion with this title already exists. Try a different title.",
"unknown": "Something went wrong on our side. Please try again.",
"issues": {
"not_writable": "This field can't be changed here.",
"unavailable": "The selected item is no longer available."
}
}
}
}
}
```
### Build the shared form [#build-the-shared-form]
Create `src/pages/discussion-form.tsx`. It holds the [AutoForm](/docs/ui/auto-form) both pages render and `useDiscussionRejection`, which turns a refusal into field errors and toasts:
```tsx title="plugins/example/src/pages/discussion-form.tsx"
import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form";
import type { ContentPublicWriteRejection } from "@vitnode/core/content";
import {
AutoForm,
setFormFieldError,
} from "@vitnode/core/components/form/auto-form";
import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor";
import { AutoFormInput } from "@vitnode/core/components/form/fields/input";
import {
emptyRichTextDocument,
isRichTextEmpty,
parseContentPublicWriteRejection,
type RichTextDocument,
zodRichTextDocument,
} from "@vitnode/core/content";
import { toast } from "sonner";
import { useTranslations } from "use-intl";
import { z } from "zod";
const DISCUSSION_FIELDS = ["title", "body"] as const;
type DiscussionField = (typeof DISCUSSION_FIELDS)[number];
export interface DiscussionFormValues {
body: RichTextDocument;
title: string;
}
const createDiscussionFormSchema = (
t: (key: "body.required" | "title.max" | "title.min") => string,
defaults: DiscussionFormValues,
) =>
z.object({
title: z
.string()
.trim()
.min(3, t("title.min"))
.max(200, t("title.max"))
.default(defaults.title),
body: zodRichTextDocument
.refine(doc => !isRichTextEmpty(doc), t("body.required"))
.default(defaults.body),
});
export type DiscussionFormSchema = ReturnType<
typeof createDiscussionFormSchema
>;
export type DiscussionFormApi = Parameters<
AutoFormOnSubmit
>[1];
const isDiscussionField = (field: null | string): field is DiscussionField =>
DISCUSSION_FIELDS.some(name => name === field);
const ISSUE_MESSAGE_KEYS = {
not_writable: "issues.not_writable",
unavailable: "issues.unavailable",
} as const;
const isKnownIssueCode = (
code: string,
): code is keyof typeof ISSUE_MESSAGE_KEYS => code in ISSUE_MESSAGE_KEYS;
const REJECTION_MESSAGE_KEYS = {
CONTENT_FORBIDDEN: "forbidden",
CONTENT_INVALID_INPUT: "invalid",
CONTENT_NOT_FOUND: "not_found",
CONTENT_UNAUTHENTICATED: "unauthenticated",
} as const satisfies Record;
export const useDiscussionRejection = () => {
const t = useTranslations("@vitnode/example.discussions.errors");
return async (response: Response, form: DiscussionFormApi) => {
const rejection = parseContentPublicWriteRejection(
await response.json().catch(() => null),
);
const messageOf = (code: string) =>
t(isKnownIssueCode(code) ? ISSUE_MESSAGE_KEYS[code] : "invalid");
const unmatched = (rejection?.issues ?? []).filter(issue => {
if (!isDiscussionField(issue.field)) return true;
setFormFieldError(form, issue.field, messageOf(issue.code));
return false;
});
if (rejection?.code === "CONTENT_INVALID_INPUT" && unmatched.length === 0) {
return;
}
toast.error(t("title"), {
description: rejection
? rejection.code === "CONTENT_INVALID_INPUT"
? messageOf(unmatched[0]?.code ?? "")
: t(REJECTION_MESSAGE_KEYS[rejection.code])
: t("unknown"),
});
};
};
export const DiscussionForm = ({
defaultValues = { body: emptyRichTextDocument(), title: "" },
isLeaving = false,
onSubmit,
submitLabel,
}: {
defaultValues?: DiscussionFormValues;
isLeaving?: boolean;
onSubmit: AutoFormOnSubmit;
submitLabel: string;
}) => {
const t = useTranslations("@vitnode/example.discussions.form");
const formSchema = createDiscussionFormSchema(t, defaultValues);
return (
(
),
},
{
id: "body",
component: props => (
),
},
]}
formSchema={formSchema}
onSubmit={onSubmit}
submitButtonProps={{
children: submitLabel,
className: "self-start",
disabled: isLeaving,
}}
/>
);
};
```
- `format="json"` makes the editor produce the ProseMirror document a [rich text field](/docs/content-engine/rich-text) stores. `isRichTextEmpty` catches a document with no text before the API does.
- The client schema repeats the server's `minLength` and `maxLength` so members see mistakes before they submit. The server still validates everything.
- Each issue has a `code`. Zod's own codes, such as `too_small`, and the public write codes `not_writable` and `unavailable` are stable, so translate by `code`. The `message` is English developer text.
### Add the create page [#add-the-create-page]
Create `src/pages/discussion-new-page.tsx`. It sends `{ values }`, redirects to the new discussion on `201` and keeps the submit button busy while the page changes:
```tsx title="plugins/example/src/pages/discussion-new-page.tsx"
import {
type AutoFormOnSubmit,
setFormFieldError,
} from "@vitnode/core/components/form/auto-form";
import { definePluginRoute } from "@vitnode/core/routing";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { useState } from "react";
import { useTranslations } from "use-intl";
import { CONFIG_PLUGIN } from "@/const";
import {
DiscussionForm,
type DiscussionFormSchema,
useDiscussionRejection,
} from "./discussion-form";
const DiscussionNewBreadcrumb = () => {
const t = useTranslations("@vitnode/example.discussions.new");
return {t("title")};
};
export const route = definePluginRoute({
head: ({ t }) => ({
description: t("@vitnode/example.discussions.new.desc"),
robots: "noindex, nofollow",
title: t("@vitnode/example.discussions.new.title"),
}),
breadcrumb: DiscussionNewBreadcrumb,
});
const DiscussionNewPage = () => {
const t = useTranslations("@vitnode/example.discussions");
const rejectDiscussion = useDiscussionRejection();
const [isLeaving, setIsLeaving] = useState(false);
const onSubmit: AutoFormOnSubmit = async (
values,
form,
) => {
const res = await fetcher({
plugin: CONFIG_PLUGIN.pluginId,
method: "post",
module: "content/discussions",
path: "/",
args: { body: { values } },
});
if (res.status === 201) {
const record = await res.json();
setIsLeaving(true);
window.location.assign(
record.slug === null
? `/discussions/${record.id}/edit`
: `/discussions/${encodeURIComponent(record.slug)}?created=true`,
);
return;
}
// The slug is derived from the title, so a unique conflict is the title's.
if (res.status === 409) {
setFormFieldError(form, "title", t("errors.unique"));
return;
}
await rejectDiscussion(res, form);
};
return (
{t("new.title")}
{t("new.desc")}
);
};
export default DiscussionNewPage;
```
`AutoForm` shows a spinner on the submit button while `onSubmit` runs, and `isLeaving` keeps it disabled until the next page loads. The example's discussion page reads `?created=true` and shows the success toast there, because a full page load would drop a toast raised here.
### Add the edit page [#add-the-edit-page]
Create `src/pages/discussion-edit-page.tsx`. Its loader reads the record with `GET /{id}/edit`, which answers only for the owner and moderators and includes drafts. Saving sends `PATCH /{id}` with the changed values and the record's `version`:
```tsx title="plugins/example/src/pages/discussion-edit-page.tsx"
import {
type AutoFormOnSubmit,
setFormFieldError,
} from "@vitnode/core/components/form/auto-form";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@vitnode/core/components/ui/alert";
import { Button, buttonVariants } from "@vitnode/core/components/ui/button";
import {
definePluginRoute,
type PluginRoutePageProps,
} from "@vitnode/core/routing";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "use-intl";
import { CONFIG_PLUGIN } from "@/const";
import {
DiscussionForm,
type DiscussionFormSchema,
useDiscussionRejection,
} from "./discussion-form";
const loadEditableDiscussion = async (param: string) => {
const id = Number(param);
if (!Number.isSafeInteger(id) || id < 1) {
return { state: "notFound" as const };
}
const res = await fetcher({
plugin: CONFIG_PLUGIN.pluginId,
method: "get",
module: "content/discussions",
path: "/{id}/edit",
args: { params: { id } },
});
if (res.status === 200) {
return { record: await res.json(), state: "ready" as const };
}
if (res.status === 401) return { id, state: "unauthenticated" as const };
if (res.status === 403) return { state: "forbidden" as const };
if (res.status === 404) return { state: "notFound" as const };
throw new Error(`Loading discussion ${id} for editing failed.`);
};
type DiscussionEditData = Awaited>;
type EditableDiscussion = Extract<
DiscussionEditData,
{ state: "ready" }
>["record"];
const DiscussionEditBreadcrumb = () => {
const t = useTranslations("@vitnode/example.discussions.edit");
return {t("title")};
};
export const route = definePluginRoute({
load: async ({ params }) => await loadEditableDiscussion(params.id),
head: ({ t }) => ({
robots: "noindex, nofollow",
title: t("@vitnode/example.discussions.edit.title"),
}),
breadcrumb: DiscussionEditBreadcrumb,
});
const DiscussionEditForm = ({ record }: { record: EditableDiscussion }) => {
const t = useTranslations("@vitnode/example.discussions");
const rejectDiscussion = useDiscussionRejection();
// Every save bumps the version, so the next save has to send the new one.
const [version, setVersion] = useState(record.version);
const [hasConflict, setHasConflict] = useState(false);
const onSubmit: AutoFormOnSubmit = async (
values,
form,
) => {
const res = await fetcher({
plugin: CONFIG_PLUGIN.pluginId,
method: "patch",
module: "content/discussions",
path: "/{id}",
args: {
params: { id: record.id },
body: { values, expectedVersion: version },
},
});
if (res.status === 200) {
const updated = await res.json();
setVersion(updated.version);
setHasConflict(false);
form.reset(values);
toast.success(t("edit.success.title"), {
description: t("edit.success.desc", { title: updated.values.title }),
});
return;
}
if (res.status === 409) {
const conflict = await res.json();
if (conflict.code === "CONTENT_VERSION_CONFLICT") {
setHasConflict(true);
} else {
setFormFieldError(form, "title", t("errors.unique"));
}
return;
}
await rejectDiscussion(res, form);
};
return (
<>
{hasConflict && (
{t("edit.conflict.title")}{t("edit.conflict.desc")}
);
const DiscussionEditPage = ({
loaderData,
}: PluginRoutePageProps) => {
const t = useTranslations("@vitnode/example.discussions");
if (loaderData.state === "unauthenticated") {
const returnTo = encodeURIComponent(`/discussions/${loaderData.id}/edit`);
return (
{t("state.unauthenticated.action")}
);
}
if (loaderData.state === "forbidden" || loaderData.state === "notFound") {
const key = loaderData.state === "forbidden" ? "forbidden" : "not_found";
return (
);
}
return (
);
};
export default DiscussionEditPage;
```
- The loader turns `401`, `403` and `404` into page states instead of errors. A stranger gets `403` for a published discussion and `404` for a draft or a hidden one, so the page never confirms that a draft exists.
- `expectedVersion` exists only on [editorial](/docs/content-engine/editorial#safe-concurrent-edits) content types. When someone saved in between, the API answers `409` with `CONTENT_VERSION_CONFLICT` and writes nothing. The page keeps the member's text and offers a reload.
- `PATCH` changes only the fields you send. A field you leave out keeps its stored value.
## Check the result [#check-the-result]
Restart `pnpm dev` and sign in with a member account:
1. Open `/discussions/new`, type a two-letter title and submit. The title shows "Use at least 3 characters" without a request.
2. Write a real title and message and click **Post discussion**. You land on the new discussion with a **Discussion posted** toast.
3. Open `/discussions/1/edit`, change the title and click **Save changes**. A **Changes saved** toast appears.
4. Open the same edit page in a second tab, save in one tab, then save in the other. The second tab shows **Someone else edited this discussion**.
5. Sign in as a different member and open the edit page. It says **You can't edit this discussion**. Grant that member the moderator permission (see [Permissions](/docs/content-engine/public-write#permissions)) and it opens.
{/* Image prompt: The /discussions/new page of the example plugin: "Start a discussion" heading, a Title input and a Message rich text editor, and a "Post discussion" button. Light theme, 1440x900. */}
## Ask for a captcha [#ask-for-a-captcha]
With `create.captcha: true`, every create needs a captcha token, as long as a [captcha provider](/docs/dev/captcha) is configured. Pass the deployment's captcha config to `AutoForm` and forward the token it hands to `onSubmit`:
```tsx title="plugins/example/src/pages/discussion-new-page.tsx"
import { useMiddlewareConfigQuery } from "@vitnode/core/tanstack/auth"; // [!code ++]
const DiscussionNewPage = () => {
const { data: config } = useMiddlewareConfigQuery(); // [!code ++]
const onSubmit: AutoFormOnSubmit = async (
values,
form,
{ captchaToken }, // [!code ++]
) => {
const res = await fetcher({
plugin: CONFIG_PLUGIN.pluginId,
method: "post",
module: "content/discussions",
path: "/",
captchaToken, // [!code ++]
args: { body: { values } },
});
// ...handle the response as before
};
// ...
};
```
Then give `DiscussionForm` a `captcha` prop and pass `captcha={config.captcha}` to its `AutoForm`. A missing or failed token is a plain-text `400` from the captcha check, before the request reaches the write route.
## Custom UI [#custom-ui]
The routes do not care how the form is built. Any form library, or none, works as long as you:
1. Send `{ values }` to `POST /`, or `{ values, expectedVersion }` to `PATCH /{id}`, with only the fields in the allowlist. A rich text field takes a ProseMirror document, not HTML.
2. On `400`, `401`, `403` or `404`, read the body with `parseContentPublicWriteRejection` from `@vitnode/core/content`. It takes the parsed JSON and returns the rejection, or `null` for any other shape.
3. On `409`, check `code` for `CONTENT_VERSION_CONFLICT` or `CONTENT_UNIQUE_CONFLICT`.
This plain React form turns a textarea into paragraphs and puts each issue under its field:
```tsx title="plugins/example/src/pages/quick-discussion-form.tsx"
import {
parseContentPublicWriteRejection,
type RichTextDocument,
} from "@vitnode/core/content";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { useState } from "react";
import { useTranslations } from "use-intl";
import { CONFIG_PLUGIN } from "@/const";
const toDocument = (text: string): RichTextDocument => ({
type: "doc",
content: text
.split(/\n{2,}/)
.filter(part => part.trim() !== "")
.map(part => ({
type: "paragraph",
content: [{ type: "text", text: part }],
})),
});
export const QuickDiscussionForm = () => {
const t = useTranslations("@vitnode/example.discussions");
const [errors, setErrors] = useState>({});
const [isPending, setIsPending] = useState(false);
const onSubmit = async (event: React.SubmitEvent) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const text = (name: string) => {
const value = data.get(name);
return typeof value === "string" ? value : "";
};
setIsPending(true);
const res = await fetcher({
plugin: CONFIG_PLUGIN.pluginId,
method: "post",
module: "content/discussions",
path: "/",
args: {
body: {
values: {
title: text("title"),
body: toDocument(text("body")),
},
},
},
});
if (res.status === 201) {
const record = await res.json();
window.location.assign(`/discussions/${record.id}/edit`);
return;
}
setIsPending(false);
if (res.status === 409) {
setErrors({ title: t("errors.unique") });
return;
}
const rejection = parseContentPublicWriteRejection(
await res.json().catch(() => null),
);
if (rejection?.code !== "CONTENT_INVALID_INPUT") {
setErrors({
form:
rejection?.code === "CONTENT_UNAUTHENTICATED"
? t("errors.unauthenticated")
: t("errors.unknown"),
});
return;
}
setErrors(
Object.fromEntries(
rejection.issues.map(issue => [
issue.field ?? "form",
issue.code === "unavailable"
? t("errors.issues.unavailable")
: t("errors.invalid"),
]),
),
);
};
return (
);
};
```
Every status, body and error code is listed in the [public write API reference](/docs/content-engine/public-write-api).
# Public write (/docs/content-engine/public-write)
`publicWrite` lets any signed-in member create records of a content type, and edit their own, through generated API routes on the public site. Staff keep the full AdminCP. Use it for discussions, reviews, listings or anything else your members write themselves.
This guide follows `example.discussion` from the example plugin: members start discussions at `/api/@vitnode/example/content/discussions`, edit their own, and moderators can edit anyone's.
## What public writing is [#what-public-writing-is]
The AdminCP and public writing are two doors into the same table. They differ in who may walk through them and what they may carry:
| | AdminCP routes | Public write routes |
| --------------------- | ----------------------------------------------- | ---------------------------------------------------------------- |
| Who | Staff with the content type's admin permissions | Any signed-in user creates; the owner or a moderator edits |
| Session | AdminCP session | The user's normal session on the public site |
| Fields | Every field | Only the fields listed in `publicWrite` |
| Publish, hide, delete | Yes | No. The server decides the status, and there is no public delete |
| Path | `/api/{pluginId}/admin/content/{type}` | `/api/{pluginId}/content/{publicApi.path}` |
## Enable public writing [#enable-public-writing]
You need a content type with [publication and a public API](/docs/content-engine/public-api), because the write routes live beside the public read routes and a member has to be able to find what they wrote.
### Define the content type [#define-the-content-type]
Create `src/content/discussion.ts`. The `publicWrite` block is the new part:
```ts title="plugins/example/src/content/discussion.ts"
import { defineContentType, field } from "@vitnode/core/content";
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(),
},
publication: true,
visibility: true,
editorial: { revisions: { retention: 20 } },
publicApi: {
path: "discussions",
fields: ["id", "title", "slug", "body", "publishedAt"],
},
// [!code ++:5]
publicWrite: {
ownership: { field: "author" },
create: { fields: ["title", "body"], status: "published" },
update: { fields: ["title", "body"] },
},
admin: {
path: "example/discussions",
titleField: "title",
},
});
```
- `ownership.field` names a single `field.user()`. The server fills it with the signed-in user on create.
- `create.fields` and `update.fields` are allowlists with no wildcard. A field you add later stays private until you list it. `slug` is derived from `title` on the server, so you list `title` instead.
- `create.status` is what a new record starts as. It defaults to `"draft"`; discussions go live straight away.
- `update.own` defaults to `true`, so owners can edit their own records.
Every option and rule is in the [public write reference](/docs/content-engine/public-write-api#publicwrite-options).
### Create the model [#create-the-model]
Create `src/database/discussions.ts` and export the table so it reaches a migration:
```ts title="plugins/example/src/database/discussions.ts"
import { createContentModel } from "@vitnode/core/content/server";
import { discussionContentType } from "@/content/discussion";
export const discussionContent = createContentModel(discussionContentType);
export const example_discussions = discussionContent.table;
```
### Register the public module [#register-the-public-module]
Add the model to `buildContentPublicModule` in your API plugin. The module mounts the read routes and, because of `publicWrite`, the write routes:
```ts title="plugins/example/src/config.api.ts"
import { discussionContent } from "@/database/discussions"; // [!code ++]
export const exampleApiPlugin = () =>
buildApiPlugin({
pluginId: CONFIG_PLUGIN.pluginId,
modules: [
adminModule,
buildContentPublicModule({
pluginId: CONFIG_PLUGIN.pluginId,
contentTypes: [articleContent, discussionContent], // [!code highlight]
}),
],
});
```
The example plugin also adds `discussionContent` to its `admin` module and its AdminCP navigation, so staff can manage every discussion. That part is the same as in [Create a content type](/docs/content-engine/create-a-content-type#add-the-staff-api-routes).
### Label the permissions [#label-the-permissions]
A content type with `publicWrite.update` gets a `can_edit` **moderator** permission next to its usual admin permissions. Both share one label key:
```json title="plugins/example/src/locales/en.json"
{
"@vitnode/example:discussion": "Discussions",
"@vitnode/example:discussion:can_view": "View discussions",
"@vitnode/example:discussion:can_create": "Create discussions",
"@vitnode/example:discussion:can_edit": "Edit discussions",
"@vitnode/example:discussion:can_delete": "Delete discussions",
"@vitnode/example:discussion:can_publish": "Publish and unpublish discussions",
"@vitnode/example:discussion:can_restore": "Restore an earlier version of a discussion",
"@vitnode/example:discussion:can_hide": "Hide and unhide discussions"
}
```
### Build, migrate and restart [#build-migrate-and-restart]
Run this from the workspace root, then restart `pnpm dev` so the API registers the new routes:
```bash tab="bun"
bun run build:plugins && bun run db:migrate
```
```bash tab="pnpm"
pnpm build:plugins && pnpm db:migrate
```
```bash tab="npm"
npm run build:plugins && npm run db:migrate
```
The migration creates `example_discussions` with an `author` column that references `core_users`. [Database](/docs/content-engine/database) covers migrations in detail.
## Check the result [#check-the-result]
Sign in on the site, copy the value of your `vitnode_auth` cookie from the browser's developer tools, and create a discussion:
```bash
curl -i -X POST http://localhost:3000/api/@vitnode/example/content/discussions \
-b "vitnode_auth=" \
-H "Content-Type: application/json" \
-d '{"values":{"title":"How do I add a widget zone?","body":{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"I want one under my article."}]}]}}}'
```
The API answers `201` with the new record. `version` is `2` because the create and the publish are each a version:
```json title="201"
{
"id": 1,
"slug": "how-do-i-add-a-widget-zone",
"status": "published",
"publishedAt": "2026-10-13T09:12:44.512Z",
"version": 2,
"values": {
"title": "How do I add a widget zone?",
"body": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "I want one under my article." }
]
}
]
}
}
}
```
Now try to claim someone else's name. Send a short title and an `author`:
```bash
curl -i -X POST http://localhost:3000/api/@vitnode/example/content/discussions \
-b "vitnode_auth=" \
-H "Content-Type: application/json" \
-d '{"values":{"title":"Hi","author":2,"body":{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Nice try."}]}]}}}'
```
Nothing is written, and each problem names its field:
```json title="400"
{
"code": "CONTENT_INVALID_INPUT",
"contentTypeId": "example.discussion",
"issues": [
{
"code": "too_small",
"field": "title",
"message": "Too small: expected string to have >=3 characters"
},
{
"code": "not_writable",
"field": "author",
"message": "This field cannot be written."
}
]
}
```
Without the cookie, every write route answers `401` with `"code": "CONTENT_UNAUTHENTICATED"` before it reads the body. Validation failures are `400`, the status every VitNode input error uses, not `422`.
## Ownership [#ownership]
Every publicly written record has exactly one owner, and only the server sets it. On create, the routes take the user from the session and write it into `ownership.field` after validating the payload. The field can never appear in `create.fields` or `update.fields`, and a payload that sends it gets the `not_writable` issue above.
The owner never changes through the public routes, not even when a moderator edits the record. If the owner's account is deleted, the field becomes empty and nobody owns the record, so only moderators can edit it.
This is why `ownership` is required and not configurable: if a client could choose the owner, it could write as somebody else or take over their records.
## Permissions [#permissions]
Creating needs a signed-in user and nothing else. Editing is allowed for two kinds of people:
- **The owner**, while `update.own` is `true` (the default) and the record is not [hidden](/docs/content-engine/content-visibility). A record a moderator hid is the moderator's to restore, not the author's to keep editing. Set `update.own: false` to let only moderators edit.
- **Moderators** who hold the content type's `can_edit` moderator permission, directly or through a role.
To make someone a discussion moderator:
1. Open **AdminCP → Staff → Moderators** and click **Add Moderator**.
2. Pick a **Role** or a **User** and click **Add member**. The permission editor opens.
3. Choose **Restricted**, turn on **Edit discussions** under the example plugin, and click **Save changes**.
A moderator entry set to **Unrestricted**, and every root role, holds all moderator permissions, including this one.
Being staff is not enough on its own. The admin `can_edit` permission only opens the AdminCP routes, and an AdminCP session never adds powers on the public site: the public routes always act as the user's normal session. Moderators edit with the same forms and routes as members, so you build one edit page instead of two.
## Security [#security]
Each layer below holds even if the one before it has a bug:
- **Separate allowlists.** `publicWrite` fields are independent of `publicApi.fields`. A field can be writable without being public, and public without being writable. Server-managed columns (`id`, `status`, `publishedAt`, `version`, `hiddenAt` and friends), the owner field, file, user, group, repeatable and block fields can never be listed. See [writable fields](/docs/content-engine/public-write-api#writable-fields).
- **Strict schemas.** The request schemas are built from the allowlists alone. An unknown key is a `400`, never silently dropped.
- **Authentication.** An anonymous request gets `401` before its body is read.
- **Ownership** is stamped by the server, as described above.
- **Authorization under a row lock.** An edit reads the record with `SELECT ... FOR UPDATE`, checks the owner or moderator permission, and writes in the same transaction, so the owner that was checked is the owner that is written.
- **Relation targets.** A relation field is writable only when its target content type has a `publicApi`. On every write, each referenced record must be published and visible, otherwise the request is a `400` with the `unavailable` issue code. Missing, draft and hidden targets get the same answer.
- **No existence leaks.** `403` is only for records the public can already read. A draft or a hidden record answers `404`, like a record that does not exist.
- **Rate limit and captcha.** `POST` and `PATCH` requests under `/content/` share a budget of 20 per minute per IP, on top of the global limit. Set `create.captcha: true` to require a [captcha](/docs/dev/captcha) token on every create.
- **Session and CSRF.** The routes use the same session cookie, CORS and CSRF origin checks as every other VitNode API route, and they accept JSON bodies only.
Tune the write budget with `rateLimiter.contentWrite`. Like every rate limit, it is off when `NODE_ENV=development`:
```ts title="apps/api/src/vitnode.api.config.ts"
export const vitNodeApiConfig = buildApiConfig({
rateLimiter: {
// [!code ++]
contentWrite: { points: 10, duration: 60 },
},
});
```
## Events, search and revisions [#events-search-and-revisions]
Public writes trigger the same side effects as AdminCP writes, so nothing new needs wiring:
- [Events](/docs/content-engine/events): `created` on create, then `published` when `create.status` is `"published"`, and `updated` with `changedFields` on an edit that changed something. There are no separate public-write events.
- [Search](/docs/content-engine/search) is synced after the transaction commits, so a discussion published on create is searchable straight away.
- On an [editorial](/docs/content-engine/editorial#revisions) content type, each create and edit stores a revision attributed to the signed-in user, and staff can restore it from the AdminCP.
## Containers [#containers]
A content type organized into [containers](/docs/content-engine/containers), such as discussions in forum boards, lets members pick the container when they create a record: list the `field.container()` in `create.fields`. It cannot be in `update.fields`, so moving a record to another container stays a staff edit. The server resolves the container inside the write's transaction and refuses a missing or foreign one with a `400` on that field.
### Future container policies [#future-container-policies]
Per-container publication policies are planned, not shipped. Today every
public create uses `publicWrite.create.status`.
Whether a new record starts as a draft or goes live is decided in one server-side function, `resolveContentPublicationPolicy`, and never by the payload. It already receives the signed-in user, the operation and the resolved container, so each container will be able to choose its own policy, such as "publish immediately" in one board and "wait for a moderator's approval" in another, without changing the routes or your forms.
Next, build the create and edit pages in [Public write frontend](/docs/content-engine/public-write-frontend).
# Options (/docs/content-engine/reference)
`defineContentType` from `@vitnode/core/content` takes one object that describes a content type and returns the resolved definition, with every default filled in. Use this page to look up an option. For a guided first build, follow [Create your first content type](/docs/content-engine/create-a-content-type).
## Minimal example [#minimal-example]
This is `example.article` from the example plugin, trimmed to the options most content types start with:
```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" }),
excerpt: field.textarea({ maxLength: 500, nullable: true }),
},
publication: true,
publicApi: {
path: "articles",
fields: ["title", "slug", "excerpt", "publishedAt"],
},
admin: {
path: "example/articles",
titleField: "title",
},
});
```
## When options are checked [#when-options-are-checked]
Validation happens in two places, and both stop the app from starting:
- **`defineContentType`** checks one content type when its module is imported. Anything wrong in that object throws a `ContentEngineError`.
- **Plugin registration** checks every installed content type together, for things only the whole set can show: two types sharing a table or an AdminCP path. See [Unique across content types](#unique-across-content-types).
Every message starts with `[Content Engine] :` and names the option at fault:
```txt
[Content Engine] example.article: publicApi needs `publication: true`. A public API without a draft state would put every row on the internet the moment it is created.
```
Many of these rules are type errors too, so your editor often complains before the app does.
## Top-level options [#top-level-options]
| Name | Type | Required | Default | Description |
| -------------- | --------------------------------------- | -------- | ---------- | --------------------------------------------------------------------------------- |
| `id` | `string` | Yes | | `plugin.entity`, such as `example.article`. See [Names](#names) |
| `tableName` | `string` | Yes | | Postgres table name, such as `example_articles` |
| `fields` | `Record` | Yes | | At least one field built with a [`field.*` helper](/docs/content-engine/fields) |
| `idStrategy` | `"serial"` \| `"uuid"` \| `"bigint"` | No | `"serial"` | Type of the primary key. See [`idStrategy`](#idstrategy) |
| `admin` | [`admin`](#admin) | No | `{}` | AdminCP path, list, form and title |
| `publication` | `boolean` | No | off | Draft and published states |
| `publicApi` | [`publicApi`](#publicapi) | No | off | Read-only public routes |
| `publicWrite` | [`publicWrite`](#publicwrite) | No | off | Create and edit routes for signed-in users. Needs `publicApi` |
| `delivery` | `true` \| [`delivery`](#delivery) | No | off | Public page URLs, SEO metadata, redirects and sitemap |
| `editorial` | `true` \| [`editorial`](#editorial) | No | off | Revisions, preview links and scheduling |
| `liveEditing` | `boolean` | No | `false` | [Live editing](#liveediting) in the AdminCP. Needs `editorial` |
| `localization` | [`localization`](#localization) | No | off | A translation table for `localized: true` fields |
| `search` | [`search`](#search) | No | off | Site search indexing |
| `visibility` | `boolean` | No | off | [Hide and unhide](#visibility) records. Needs `publication` |
| `duplication` | `true` \| [`duplication`](#duplication) | No | off | A [Duplicate](#duplication) action that copies a record into a new draft |
| `reports` | `true` \| [`reports`](#reports) | No | off | Readers can [report](#reports) published records to moderators. Needs `publicApi` |
| `indexes` | [`indexes`](#indexes) | No | `[]` | Extra database indexes |
A feature block is on when it is present. `publicApi`, `publicWrite`, `localization` and `search` take an options object. `publication`, `liveEditing` and `visibility` are `true`, and `editorial`, `delivery` and `duplication` take `true` or an options object. Leave a block out, or set it to `false`, to keep it off. Write `true` or the object directly rather than a `boolean` variable, because the generated types read the literal to decide which columns and routes exist.
### Names [#names]
| Name | Rule |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | Matches `/^[a-z0-9]+(?:\.[a-z0-9-]+)+$/`: lowercase, at least two dot-separated segments, dashes allowed after the first. At most 100 characters with `editorial` or `search` |
| `tableName` | snake_case, starts with a letter, at most 63 characters |
| Field names | camelCase, start with a lowercase letter |
| Reserved fields | `id`, `createdAt` and `updatedAt` always. `status` and `publishedAt` with `publication`. `version` with `editorial`. `hiddenAt`, `hiddenBy` and `visibility` with `visibility` |
| Reserved for the list route | `cursor`, `first`, `last`, `order`, `orderBy` and `search`, because they are query parameters of the generated list route. Checked at plugin registration |
| Localized fields | Cannot be named `itemId`, `languageId`, `version`, `createdAt` or `updatedAt` (translation table columns). A localized type cannot expose a public field called `locale` |
Two names are derived from the id:
- The **entity key** is the id without its first segment, with further dots turned into `_`. `example.article` becomes `article`. Strings live under `{pluginId}.content.{entityKey}`, for example `@vitnode/example.content.article`.
- The **permission module** is the entity key with anything outside `a-z0-9` turned into `_`, so `example.localized-article` becomes `localized_article`. Permissions are named `@vitnode/example:article:can_view`. Override it with `admin.permissionModule`.
Every content type gets `can_view`, `can_create`, `can_edit` and `can_delete`. `publication` adds `can_publish`, `editorial` adds `can_restore` and `visibility` adds `can_hide`. Duplicating needs `can_create` and `can_view`, so `duplication` adds no permission.
## `admin` [#admin]
Controls the generated AdminCP screens. Guide: [Customize the AdminCP](/docs/content-engine/admincp).
| Name | Type | Default | Description |
| ----------------------- | -------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `path` | `string` | The id with dots as slashes (`example/article`) | Screen URL under `/admin/content/`. Lowercase segments of letters, digits and dashes, starting with a letter, joined by `/`. The last segment cannot be `create` or `edit` |
| `permissionModule` | `string` | Derived from the id, see [Names](#names) | Middle part of the permission names. snake_case |
| `titleField` | field name \| `null` | First shared text or textarea field, then first localized one | Names a record in headings, pickers and toasts. A localized field is shown in the reader's language. `null` means no title |
| `colorField` | field name \| `null` | `null` | A shared field holding a color, shown as a swatch beside the title |
| `create.mode` | `"dialog"` \| `"page"` | `"dialog"` | Open the create form in a dialog or on its own page |
| `edit.mode` | `"dialog"` \| `"page"` | `"dialog"` | Same, for the edit form |
| `navigation` | `boolean` | `true` | `false` hides the AdminCP sidebar entry |
| `list.columns` | field names | `status` (with publication), every shared single-column field, `updatedAt` | Table columns, in order. Accepts localized fields, system columns and to-many relation or user fields |
| `list.searchableFields` | field names | Shared text and textarea fields | Fields the search box matches: text, textarea, richText or slug |
| `list.orderableFields` | field names | `[]` | Sortable shared fields. System columns are always sortable and need no entry. No localized or file fields |
| `list.defaultOrderBy` | column name | `"updatedAt"` | A system column or one of `list.orderableFields` |
| `list.defaultOrder` | `"asc"` \| `"desc"` | `"desc"` | |
| `list.thumbnailField` | field name | | A shared single `field.file()` drawn in the title cell. `titleField` must be one of `list.columns` |
| `form.fields` | field names | Every field except `field.blocks()`, in declaration order | Fields in the generated form |
| `form.sections` | `{ name: string; fields: string[] }[]` | `[]` | Groups the form into titled sections. Cannot be combined with `form.fields` |
A section `name` is lowercase `a-z0-9_` and starts with a letter, because it is the key its heading is read from. Each field goes in one section only, and no section can be empty.
```ts title="plugins/blog/src/content/post.ts"
admin: {
permissionModule: "posts",
path: "blog/articles",
titleField: "title",
create: { mode: "page" },
edit: { mode: "page" },
list: {
columns: ["title", "authorId", "status", "publishedAt", "updatedAt"],
searchableFields: ["title"],
thumbnailField: "coverImage",
},
},
```
## `publication` [#publication]
`publication: true` adds the `status` (`"draft"` or `"published"`) and `publishedAt` columns, and the `can_publish` permission. It has no other options.
Publication on its own never makes anything public. Only [`publicApi`](#publicapi) does. Guide: [Publish content with a public API](/docs/content-engine/public-api).
```ts title="plugins/example/src/content/article.ts"
publication: true,
```
## `publicApi` [#publicapi]
Generates read-only routes at `/api/{pluginId}/content/{path}` that return published records only. Guide: [Publish content with a public API](/docs/content-engine/public-api).
| Name | Type | Required | Default | Description |
| ------------------ | ------------------- | -------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `path` | `string` | Yes | | One URL segment matching `/^[a-z][a-z0-9-]*$/`, at most 64 characters, not `admin` |
| `fields` | field names | Yes | | The allowlist, with no wildcard. Must contain exactly one top-level slug field. No duplicates |
| `searchableFields` | field names | No | `[]` | Matched by `?search=`. Text, textarea, richText or slug fields. Rich text matches its words only |
| `filterableFields` | field names | No | `[]` | Each becomes an equality query parameter. Boolean, container, enum, number, relation, slug or text fields. A container also takes `includeDescendants=true` |
| `orderableFields` | field names | No | `[]` | Accepted by `?orderBy=`. `publishedAt` is always added |
| `defaultOrderBy` | field name | No | `"publishedAt"` | Must be orderable |
| `defaultOrder` | `"asc"` \| `"desc"` | No | `"desc"` | |
What `fields` accepts:
- Fields of kind text, textarea, richText, slug, number, boolean, enum, dateTime, file, relation, container and blocks. A file is returned as its public descriptor, never as the file id. A container is returned as its id.
- The columns `id`, `createdAt`, `updatedAt` and `publishedAt`.
- Group and repeatable leaves one at a time, as `"seo.title"`. Naming the whole group throws, so a leaf added later stays private.
- Not `status` (every public row is published), and not `field.user()`, so an author is never published by accident.
`searchableFields`, `filterableFields` and `orderableFields` must all be in `fields`. Repeatable leaves fit none of them. `orderableFields` also refuses file fields, to-many relations and localized fields.
```ts title="plugins/example/src/content/article.ts"
publicApi: {
path: "articles",
fields: ["title", "slug", "excerpt", "featured", "category", "noIndex", "publishedAt"],
searchableFields: ["title", "excerpt"],
orderableFields: ["publishedAt", "title"],
filterableFields: ["category", "featured"],
},
```
## `publicWrite` [#publicwrite]
Adds create and edit routes for signed-in users beside the `publicApi` routes, with an owner stamped by the server and a `can_edit` moderator permission. Guide: [Public write](/docs/content-engine/public-write). Every option, route and error: [Public write API](/docs/content-engine/public-write-api).
```ts title="plugins/example/src/content/discussion.ts"
publicWrite: {
ownership: { field: "author" },
create: { fields: ["title", "body", "board"], status: "published" },
update: { fields: ["title", "body"] },
},
```
A [container field](/docs/content-engine/fields#container), such as `board`, may be in `create.fields` only. Moving a record to another container is a staff edit.
## `delivery` [#delivery]
Gives each published record a public page: canonical URL, SEO metadata, redirects from old slugs and a sitemap. Guides: [Public pages](/docs/content-engine/public-pages) and [SEO](/docs/content-engine/seo).
| Name | Type | Default | Description |
| -------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `path` | `string` | `/{publicApi.path}/:slug` | The page route. Exactly one `:slug`, no other parameters or `*`, not under `/admin` or `/api`, at most 512 characters |
| `redirects` | `boolean` | off | Old slugs answer `308` with the current URL |
| `seo.titleField` | text field | | Page title |
| `seo.fallbackTitleField` | text field | | Used when `titleField` is empty. Needs `titleField` |
| `seo.descriptionField` | text, textarea or richText field | | Meta description. Rich text becomes plain text, and the page trims it to 160 characters |
| `seo.fallbackDescriptionField` | text, textarea or richText field | | Used when `descriptionField` is empty. Needs `descriptionField` |
| `seo.noIndexField` | shared boolean field | | `true` marks the page `noindex` and leaves it out of the sitemap |
| `seo.openGraph.titleField` | text field | | `og:title` |
| `seo.openGraph.descriptionField` | text, textarea or richText field | | `og:description` |
| `sitemap` | `true` \| `{ changeFrequency, priority }` | off | Lists published URLs |
| `sitemap.changeFrequency` | `"always"` \| `"hourly"` \| `"daily"` \| `"weekly"` \| `"monthly"` \| `"yearly"` \| `"never"` | | |
| `sitemap.priority` | `number` | | From 0 to 1 inclusive |
| `hreflang.xDefault` | `"defaultLocale"` | | Adds an `x-default` alternate pointing at the default language |
Every SEO field must be in `publicApi.fields`, may be a group leaf such as `"seo.title"`, and cannot be a repeatable leaf. `noIndexField` must be shared, not localized.
```ts title="plugins/example/src/content/article.ts"
delivery: {
redirects: true,
seo: {
titleField: "title",
descriptionField: "excerpt",
noIndexField: "noIndex",
openGraph: { titleField: "title", descriptionField: "excerpt" },
},
sitemap: { changeFrequency: "weekly", priority: 0.7 },
},
```
## `editorial` [#editorial]
Adds a `version` column, revision history and the `can_restore` permission. [`liveEditing`](#liveediting) builds on it. Guide: [Revisions, previews and scheduling](/docs/content-engine/editorial).
| Name | Type | Default | Description |
| -------------------------- | ---------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------- |
| `revisions.retention` | `number` | `50` | Newest revisions kept per record. A whole number from 1 to 500 |
| `preview` | `true` \| `{ expiresInMinutes, pathTemplate }` | off | Signed [preview links](/docs/content-engine/editorial#preview-links) for drafts |
| `preview.expiresInMinutes` | `number` | `15` | Link lifetime. A whole number from 1 to 1440 |
| `preview.pathTemplate` | `string` | | Your own preview URL. Starts with `/`, contains `{token}` exactly once and no other placeholder, at most 512 characters |
| `scheduling` | `boolean` | off | Publish or unpublish at a chosen time |
```ts title="plugins/example/src/content/article.ts"
editorial: {
revisions: { retention: 20 },
preview: { expiresInMinutes: 30 },
scheduling: true,
},
```
## `localization` [#localization]
Creates a `{tableName}_translations` table for fields marked `localized: true`. Guide: [Translate content](/docs/content-engine/localization).
| Name | Type | Default | Description |
| --------------- | ----------------------- | -------- | ----------------------------------------------------------------------------------------------------- |
| `defaultLocale` | `string` | | Required. The language every record is created in, such as `"en"` or `"pt-BR"`. At most 32 characters |
| `fallback` | `"none"` \| `"default"` | `"none"` | `"default"` serves the default language when a translation is missing |
Only text, textarea, richText, slug, blocks and group fields can be localized. A localized slug needs a localized source field, and a shared slug needs a shared one.
```ts title="plugins/example/src/content/localized-article.ts"
localization: {
defaultLocale: "en",
fallback: "default",
},
```
## `search` [#search]
Keeps published records in the [site search index](/docs/dev/search). Drafts never show up in public results. Guide: [Search](/docs/content-engine/search).
| Name | Type | Required | Description |
| ------------------ | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `titleField` | field name | Yes | The result heading. A text field that is not nullable |
| `contentFields` | field names | Yes | The indexed body, at least one, no duplicates. Text, textarea, richText or slug. Repeatable leaves such as `"faq.answer"` are allowed |
| `pathTemplate` | `string` | Yes | The result URL. Starts with `/`, contains `{slug}` exactly once, at most 512 characters |
| `descriptionField` | field name | No | Shown in result excerpts. Text, textarea or richText |
| `authorField` | field name | No | A top-level `field.user()` credited in the index. It does not have to be public |
| `imageField` | field name | No | A shared single `field.file()` shown as the result image. Must be in `publicApi.fields` |
Every field except `authorField` must be in `publicApi.fields`, or a result snippet would leak a private value. Leave the language out of `pathTemplate`, because your [i18n settings](/docs/dev/i18n) add the locale prefix.
```ts title="plugins/example/src/content/article.ts"
search: {
titleField: "title",
descriptionField: "excerpt",
contentFields: ["title", "excerpt"],
pathTemplate: "/articles/{slug}",
authorField: "author",
},
```
## `visibility` [#visibility]
`visibility: true` adds the nullable `hiddenAt` and `hiddenBy` columns, the `can_hide` permission, the `POST /{id}/hide` and `POST /{id}/unhide` staff routes and a **Visibility** filter in the AdminCP list. A hidden record is left out of every public read while it keeps its `status` and `publishedAt`. It has no other options. Guide: [Hide content](/docs/content-engine/content-visibility).
```ts title="plugins/blog/src/content/post.ts"
publication: true,
visibility: true,
```
## `reports` [#reports]
`reports: true` lets signed-in readers report a published record. It registers a report target, adds `can_view_reports` and `can_manage_reports` to the moderator catalog, plus `can_view_hidden` and `can_hide` with `visibility`, and needs no migration. Pass `{ titleField, snapshotFields }` to choose what the report's snapshot keeps. Guide: [Enable reports](/docs/dev/moderation/enable-reports). Options: [Moderation reference](/docs/dev/moderation/reference#reports-on-a-content-type).
```ts title="plugins/blog/src/content/post.ts"
publicApi: { path: "blog", fields: ["id", "title", "friendlyUrl", "content"] },
reports: true,
```
## `duplication` [#duplication]
Adds a **Duplicate** action and the `POST /{id}/duplicate` staff route, which copy a record and its translations into a new draft. Pass `true`, or an object to change the title suffix. Guide: [Duplicate content](/docs/content-engine/duplicate-content).
| Name | Type | Default | Description |
| ------------- | --------- | ---------------------------------------------- | --------------------------------------------------------------------------------------- |
| `titleSuffix` | `boolean` | On when `admin.titleField` is a `field.text()` | Adds a localized "(Copy)" to the copy's title. `true` with any other title field throws |
```ts title="plugins/blog/src/content/post.ts"
duplication: true,
```
## `idStrategy` [#idstrategy]
The type of the primary key. Every foreign key, junction column and translation `itemId` that points at the record takes the same type. Guide: [Record ID strategies](/docs/content-engine/id-strategy).
| Value | Postgres key | In JSON |
| -------------------- | ----------------------------------------- | ------------------ |
| `"serial"` (default) | `serial` | A number |
| `"uuid"` | `uuid`, `DEFAULT gen_random_uuid()` | A lowercase string |
| `"bigint"` | `bigint GENERATED BY DEFAULT AS IDENTITY` | A decimal string |
Any other value throws. Changing the strategy of a content type that already has records needs a [hand-written data migration](/docs/content-engine/id-strategy#change-the-strategy-later).
```ts title="plugins/example/src/content/event.ts"
idStrategy: "bigint",
```
## `indexes` [#indexes]
Extra database indexes on the base table. The engine already indexes slugs, `unique: true` text fields, foreign keys, `createdAt`, `updatedAt` and `(status, publishedAt)`. A declared index on the same columns replaces the generated one instead of adding a second. Guide: [Database and migrations](/docs/content-engine/database).
| Name | Type | Required | Default | Description |
| -------- | ---------- | -------- | -------------------------------------------------- | --------------------------------- |
| `on` | `string[]` | Yes | | At least one column, no repeats |
| `unique` | `boolean` | No | `false` | |
| `name` | `string` | No | `{tableName}_{columns}_idx`, or `_key` when unique | snake_case, at most 63 characters |
`on` accepts shared fields, group leaves such as `"syndication.priority"`, and system columns (`id`, `createdAt`, `updatedAt`, plus `status`, `publishedAt` and `version` when their feature is on). It refuses localized fields, `field.blocks()`, repeatable leaves, to-many relations and file collections, and whole groups. Two indexes on the same columns throw.
```ts title="plugins/example/src/content/advanced-article.ts"
indexes: [{ on: ["syndication.priority"] }],
```
## `liveEditing` [#liveediting]
`liveEditing: true` lets several editors work on one record at once in the AdminCP edit form: field locks, an autosaved shared draft, presence and rich text co-editing. It needs `editorial`, because locks and drafts are measured against the record version. It adds the `/{id}/locks` and `/{id}/draft` staff routes. Guide: [Live editing](/docs/content-engine/live-editing).
```ts title="plugins/example/src/content/article.ts"
editorial: { revisions: { retention: 20 } },
liveEditing: true,
```
AI suggestions are not a `defineContentType` option. They are set per field with the `ai` option of `field.text()` and `field.textarea()`. See [Fields](/docs/content-engine/fields) and [AI actions](/docs/dev/ai/actions).
## Which options need which [#which-options-need-which]
Each row is a rule `defineContentType` enforces, with the start of the message it throws (after the `[Content Engine] :` prefix).
| Option | Needs | Error |
| ------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `publicApi` | `publication` | ``publicApi needs `publication: true`.`` |
| `publicApi.fields` | Exactly one top-level slug field | `publicApi.fields must expose exactly one slug field` |
| `publicWrite` | `publicApi` | ``publicWrite needs `publicApi`.`` |
| `publicWrite` | No localized fields | `publicWrite is not available on a localized content type yet.` |
| `search` | `publication` | ``search needs `publication: true`.`` |
| `search` | `publicApi` | ``search needs `publicApi: { path, fields }`.`` |
| `search.*Field`, `search.contentFields` | The field in `publicApi.fields` | `search.titleField names "x", which is not in publicApi.fields.` |
| `delivery` | `publicApi` | ``delivery needs `publicApi: { path, fields }`.`` |
| `delivery` on a localized type | `"id"` in `publicApi.fields` | `delivery on a localized content type needs "id" in publicApi.fields.` |
| `liveEditing` | `editorial` | ``liveEditing needs `editorial`.`` |
| `delivery.redirects` | `editorial` | ``delivery.redirects needs `editorial`.`` |
| `delivery.redirects` on a localized type | A localized slug field | `delivery.redirects needs a localized slug field on a localized content type, but "slug" is shared.` |
| `delivery.hreflang` | `localization` | ``delivery.hreflang needs `localization: { defaultLocale }`.`` |
| `delivery.seo.*` | The field in `publicApi.fields` | `delivery.seo.titleField names "x", which is not in publicApi.fields.` |
| `delivery.seo.fallbackTitleField` | `delivery.seo.titleField` | ``delivery.seo.fallbackTitleField is set without `titleField`.`` |
| `delivery.seo.fallbackDescriptionField` | `delivery.seo.descriptionField` | ``delivery.seo.fallbackDescriptionField is set without `descriptionField`.`` |
| `editorial.preview` | `publicApi` | ``editorial.preview needs `publicApi: { path, fields }`.`` |
| `editorial.scheduling` | `publication` | ``editorial.scheduling needs `publication: true`.`` |
| `visibility` | `publication` | ``visibility needs `publication: true`.`` |
| `duplication.titleSuffix: true` | `admin.titleField` pointing at a `field.text()` | ``duplication.titleSuffix needs `admin.titleField` to be a text field`` |
| A `localized: true` field | `localization` | ``Field "title" is `localized: true` but the content type has no `localization: { defaultLocale }` block`` |
| `localization` | At least one `localized: true` field | ``localization is enabled but no field is marked `localized: true`,`` |
| Any block with an `enabled` key | Nothing: a block is on when present | `publicApi.enabled is not an option.` |
| `publication` or `liveEditing` as an object | `true` or `false` | ``publication is `true` or `false`.`` |
| `visibility` as an object | `true` or `false` | ``visibility is `true` or `false`.`` |
| `admin.list.thumbnailField` | `admin.titleField` in `admin.list.columns` | `admin.list.thumbnailField is drawn in the title column, but admin.titleField` |
| `admin.form.sections` | No `admin.form.fields` | ``admin.form declares both `fields` and `sections`.`` |
| `admin.list.defaultOrderBy` | A system column or an `admin.list.orderableFields` entry | `admin.list.defaultOrderBy is "x", which is not in admin.list.orderableFields.` |
| `publicApi.defaultOrderBy` | An orderable field | `publicApi.defaultOrderBy is "x", which is not in publicApi.orderableFields.` |
| `publicApi.searchableFields`, `filterableFields`, `orderableFields` | The field in `publicApi.fields` | `publicApi.searchableFields includes "x", which is not in publicApi.fields.` |
### Unique across content types [#unique-across-content-types]
Plugin registration checks these across every installed content type:
| Value | Unique within | Error starts with |
| ------------------------------------- | ----------------------------- | ------------------------------------------ |
| `id` | The app | `Duplicate content type id` |
| Table names, including generated ones | The database | `Table "x" is claimed by both` |
| Index names, including generated ones | The database | `Index name "x" is used by both` |
| `admin.path` | The app | `AdminCP path "x" is claimed by both` |
| `permissionModule` | The plugin | `Permission module "x" is derived by both` |
| `publicApi.path` | The plugin | `Public path "x" is claimed by both` |
| `publicApi.path` with `delivery` | The app, among delivery types | `Delivery path "x" is claimed by both` |
# Relations (/docs/content-engine/relations)
Relations connect one record to others, and two related helpers shape a record's own data. Everything lands in real tables, not a JSON blob: a relation is a foreign key, a to-many relation is a junction table, a group is a set of columns and a repeatable field is a child table. This guide walks through `example.advanced-article` from the example plugin, which uses all four.
## Before you begin [#before-you-begin]
You need a working content type to link to, such as `example.category` from [Create a content type](/docs/content-engine/create-a-content-type).
### Link to one record [#link-to-one-record]
Point an article at a category with `field.relation`. `target` is a function, so two content types can refer to each other without an import cycle:
```ts title="plugins/example/src/content/article.ts"
import { categoryContentType } from "./category";
fields: {
category: field.relation({ // [!code ++:5]
required: true,
onDelete: "restrict",
target: () => categoryContentType,
}),
},
```
Then tell `createContentModel` which column the foreign key references:
```ts title="plugins/example/src/database/articles.ts"
import { example_categories } from "./categories";
export const articleContent = createContentModel(articleContentType, {
references: { category: () => example_categories.id }, // [!code ++]
});
```
`references` needs one entry per relation field. A missing or extra key is a type error. `field.user()`, `field.file()` and `self: true` relations find their table on their own and need no entry.
### Link to many records [#link-to-many-records]
Add `multiple: true`. The advanced article has two to-many relations: a set of categories and an ordered "read next" list that points back at articles:
```ts title="plugins/example/src/content/advanced-article.ts"
fields: {
categories: field.relation({
multiple: true,
onDelete: "restrict",
target: () => categoryContentType,
}),
relatedArticles: field.relation({
multiple: true,
onDelete: "cascade",
ordered: true,
self: true,
}),
},
```
`ordered: true` keeps the order editors arrange. Without it, the set is stored in ascending id order. `onDelete: "cascade"` on a to-many relation drops the link when the target is deleted, and `"restrict"` refuses the delete. A to-many relation cannot be `required` or `nullable`; use `min: 1` to demand at least one target.
Each to-many field gets a junction table with `itemId`, `relatedItemId`, `position` and `createdAt`. Give `categories` its reference and export both junction tables, or Drizzle Kit leaves them out of the migration:
```ts title="plugins/example/src/database/advanced-articles.ts"
export const advancedArticleContent = createContentModel(
advancedArticleContentType,
{ references: { categories: () => example_categories.id } },
);
export const example_advanced_articles = advancedArticleContent.table;
export const example_advanced_articles_categories = // [!code ++:4]
advancedArticleContent.advancedTables.junctions.categories;
export const example_advanced_articles_related_articles =
advancedArticleContent.advancedTables.junctions.relatedArticles;
```
### Group related fields [#group-related-fields]
`field.group` keeps fields that belong together under one key:
```ts title="plugins/example/src/content/advanced-article.ts"
syndication: field.group({
fields: {
indexable: field.boolean({ defaultValue: true }),
noIndex: field.boolean({ defaultValue: false }),
priority: field.number({ integer: true, min: 0, max: 10, defaultValue: 5 }),
},
}),
```
A group adds one column per leaf to the main table: `syndicationIndexable`, `syndicationNoIndex` and `syndicationPriority`. The API reads and writes it as `{ syndication: { indexable, noIndex, priority } }`, and other options refer to a leaf by path, such as `indexes: [{ on: ["syndication.priority"] }]`. Leaves can be `text`, `textarea`, `number`, `boolean`, `enum` or `dateTime` fields. An optional group needs leaves that are nullable or have a default.
### Add repeatable rows [#add-repeatable-rows]
`field.repeatable` stores an ordered list of small rows, such as FAQ entries:
```ts title="plugins/example/src/content/advanced-article.ts"
faq: field.repeatable({
max: 20,
fields: {
question: field.text({ required: true, minLength: 3, maxLength: 200 }),
answer: field.textarea({ required: true }),
},
}),
```
The rows go to a child table, `example_advanced_articles_faq`, with `id`, `itemId`, `position`, `createdAt`, `updatedAt` and one column per leaf. Deleting the article deletes its rows. Export the child table too:
```ts title="plugins/example/src/database/advanced-articles.ts"
export const example_advanced_articles_faq =
advancedArticleContent.advancedTables.repeatables.faq;
```
### Build and migrate [#build-and-migrate]
```bash tab="bun"
bun run build:plugins && bun run db:migrate
```
```bash tab="pnpm"
pnpm build:plugins && pnpm db:migrate
```
```bash tab="npm"
npm run build:plugins && npm run db:migrate
```
The generated migration creates one table per junction and repeatable field.
## Relations across id strategies [#relations-across-id-strategies]
A relation can point at a content type with a different [`idStrategy`](/docs/content-engine/id-strategy). The foreign key always takes the type of the key it references. The example plugin's `example.event` uses `bigint` and links to `uuid` tags and `serial` categories:
```ts title="plugins/example/src/content/event.ts"
import type { ContentRelationTarget } from "@vitnode/core/content";
import { categoryContentType } from "./category";
import { tagContentType } from "./tag";
fields: {
primaryTag: field.relation({
nullable: true,
onDelete: "set null",
target: (): ContentRelationTarget<"uuid"> => tagContentType,
}),
category: field.relation({
nullable: true,
onDelete: "set null",
target: () => categoryContentType,
}),
tags: field.relation({ multiple: true, ordered: true, target: () => tagContentType }),
relatedEvents: field.relation({ multiple: true, onDelete: "cascade", self: true }),
},
```
| Field | Column or junction table |
| --------------- | ------------------------------------------------------------------- |
| `primaryTag` | `uuid` column referencing `example_tags.id` |
| `category` | `integer` column referencing `example_categories.id` |
| `tags` | `example_events_tags` with `itemId bigint` and `relatedItemId uuid` |
| `relatedEvents` | `example_events_related_events` with both columns `bigint` |
`field.relation()` reads the target's strategy from `target`, so the API takes and returns tag ids as strings and category ids as numbers. When two content type modules import each other, TypeScript cannot infer a type through the cycle and falls back to serial. Annotate the thunk with `ContentRelationTarget<"uuid">` or `ContentRelationTarget<"bigint">`, as `primaryTag` does. At runtime, a `references` entry whose column type disagrees with the target's strategy throws and names the field.
## Write related values [#write-related-values]
A relation takes ids. A to-many relation and a repeatable take the whole list, which replaces what the record had:
```json
{
"categories": [2, 4],
"syndication": { "priority": 7 },
"faq": [
{
"id": 1,
"question": "Can I freeze the batter?",
"answer": "Yes, for up to a month."
},
{ "question": "Do I need buttermilk?", "answer": "No, regular milk works." }
]
}
```
A repeatable row with an `id` updates that row, a row without one is created, and rows you leave out are deleted. The API refuses an `id` that belongs to another record with `CONTENT_REPEATABLE_UNKNOWN_CHILD`. To change one link or one row without sending the list, use the [content service](/docs/content-engine/services): `relations.categories.add()`, `.remove()`, `.set()`, `.reorder()` and `.get()`, or `repeatable.faq.create()`, `.update()`, `.delete()`, `.reorder()`, `.set()` and `.list()`.
## Check the result [#check-the-result]
In the AdminCP form, a single relation is a search picker. This is the article's **Category** field:
A to-many relation shows each target as a chip you can remove. Here is the blog plugin's **Categories** field, a `field.relation({ multiple: true })`:
A group renders as a fieldset with its leaf controls, and a repeatable as a list of rows with move up, move down and remove buttons plus an **Add** button. The example plugin gives the advanced article API routes but no AdminCP screen, so test it through the API. Its public API returns the related values like this:
```bash
curl "http://localhost:3000/api/@vitnode/example/content/advanced-articles/weekend-pancakes?locale=en"
```
```json
{
"id": 1,
"title": "Weekend pancakes",
"slug": "weekend-pancakes",
"categories": [2, 4],
"seo": {
"title": "Fluffy weekend pancakes",
"description": "Ready in 20 minutes."
},
"syndication": { "priority": 7, "noIndex": false },
"faq": [
{
"id": 1,
"question": "Can I freeze the batter?",
"answer": "Yes, for up to a month."
},
{
"id": 2,
"question": "Do I need buttermilk?",
"answer": "No, regular milk works."
}
],
"publishedAt": "2026-10-10T10:57:42.767Z",
"locale": "en"
}
```
A to-many relation comes back as a list of ids, and a single relation as `{ "id": 2 }`. Only the leaves listed in `publicApi.fields`, such as `"faq.question"`, are public. The example plugin's page at `/advanced-articles/weekend-pancakes` renders the FAQ rows:
Every option of these helpers is in the [Fields](/docs/content-engine/fields#relation) reference.
# Rich text (/docs/content-engine/rich-text)
`field.richText()` is the Content Engine field for formatted text: headings, lists, links, tables, task lists and panels. Editors write it in the AdminCP with the Tiptap editor. Readers get plain HTML from `RichTextContent`, a small React component that never loads the editor, so the public page stays light. Your readers came for the article, not for an editor they will never type in.
## Define a rich text field [#define-a-rich-text-field]
Add the field to a content type. The blog plugin's article body is the typical case:
```ts title="plugins/blog/src/content/post.ts"
import { defineContentType, field } from "@vitnode/core/content";
export const blogPostContentType = defineContentType({
id: "blog.post",
tableName: "blog_posts",
fields: {
title: field.text({
localized: true,
required: true,
minLength: 3,
maxLength: 255,
}),
content: field.richText({ localized: true, required: true }), // [!code highlight]
},
});
```
The excerpt leaves out the blog's `localization` block, which `localized: true` needs; see [Translations](/docs/content-engine/localization).
The column is `jsonb`, and the value is a ProseMirror document, the same JSON that Tiptap's `editor.getJSON()` returns:
```json
{
"type": "doc",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Hello!" }] }
]
}
```
The Content Engine validates every write. The value must be a `doc`, nest at most 64 levels deep, and fit in `maxBytes` as UTF-8 JSON (1 MB by default, up to 16 MB). With `required: true`, a document without any text or media is refused, so an empty editor cannot be saved. A rich text field has no default, so set `required` or `nullable`.
The AdminCP form shows the editor for the field, and it loads the editor only when the form has a rich text field:
## Render it on a public page [#render-it-on-a-public-page]
Import `RichTextContent` from `@vitnode/core/components/rich-text` and pass the document. It works during SSR and in the browser. Validate the API response with `zodRichTextDocument`:
```tsx title="plugins/blog/src/pages/post-page.tsx"
import { RichTextContent } from "@vitnode/core/components/rich-text";
import { zodRichTextDocument } from "@vitnode/core/content";
import { z } from "zod";
const zodBlogPost = z.object({
content: zodRichTextDocument,
title: z.string(),
});
const BlogPostPage = ({ item }: { item: z.infer }) => (
{item.title}
);
```
The output is wrapped in `
` and uses the same classes as the editor, such as `list-disc`, `tableWrapper` and `tiptap-panel`. Import `@vitnode/core/styles/tiptap.css` in your app stylesheet to style it, as `apps/web/src/styles.css` does.
`RichTextContent` treats the document as untrusted input:
- Links keep only `http:`, `https:`, `mailto:`, `tel:`, relative and `#` targets. A `javascript:` or `data:` link renders as plain text.
- Images and other media keep only `http:`, `https:` and relative sources.
- A link that opens a new tab gets `rel="noopener noreferrer nofollow"`.
- Inline styles come from an allowlist: text colors, font sizes with a unit and text alignment.
- An unknown node renders its children, and an unknown mark renders plain text.
## Replace a renderer [#replace-a-renderer]
Pass `nodes` or `marks` to change how one type renders. The keys are ProseMirror type names, and each renderer receives the node (or mark) and its already rendered children:
```tsx
node.attrs?.level === 2 ? (
{children}
) : (
{children}
),
}}
/>
```
A custom renderer is your code, so the built-in link and style allowlists do not run for it. Check anything it reads from `attrs`; `sanitizeRichTextHref` from `@vitnode/core/components/rich-text` cleans a link.
## Get plain text or HTML [#get-plain-text-or-html]
Three helpers work on the server and in the browser without React. Import them from `@vitnode/core/content` or `@vitnode/core/components/rich-text`:
| Helper | Returns | Use it for |
| -------------------------- | --------- | ------------------------------------------------------------------------ |
| `richTextToPlainText(doc)` | `string` | Search, excerpts, AI prompts, word counts. One line per block, no markup |
| `richTextToHtml(doc)` | `string` | Emails and feeds. Escaped, with the same allowlists as `RichTextContent` |
| `isRichTextEmpty(doc)` | `boolean` | Checking whether a document has any text or media |
```ts
import { richTextToPlainText } from "@vitnode/core/content";
const words = richTextToPlainText(post.content).split(/\s+/).length;
```
The Content Engine already uses them. Site search indexes the words of a rich text field listed in `search.contentFields`. A rich text field named in `delivery.seo.descriptionField` or `fallbackDescriptionField` becomes a plain text description, which is how the blog fills its meta description from `content` when an article has no excerpt. The revision history compares documents as text.
## Use the editor in your own form [#use-the-editor-in-your-own-form]
Outside the Content Engine, `AutoFormEditor` from `@vitnode/core/components/form/fields/editor` stores an HTML string by default. Add `format="json"` to store a ProseMirror document instead:
```tsx
```
The [Editor](/docs/ui/editor) page covers the component itself.
## Check the result [#check-the-result]
Open **AdminCP → Blog → Articles → Create Article**, write a heading and a list in **Content** and save. The public article shows the same structure, rendered by `RichTextContent` without loading Tiptap, ProseMirror or Yjs.
Several editors can type into the same rich text field at once; [Live editing](/docs/content-engine/live-editing) covers co-editing and presence.
# Search (/docs/content-engine/search)
Add a `search` block to a content type and its published records show up on the site search page at `/search`. VitNode writes a search document after every save, publish and delete, so you never call the search engine yourself. This guide turns search on for `example.article` from the example plugin.
## Before you begin [#before-you-begin]
The content type needs [publication and a public API](/docs/content-engine/public-api), because a search result is a link to a public page and its text has to be public already. Without them, the definition throws when it loads: ``search needs `publication: true`.`` or ``search needs `publicApi: { path, fields }`.``
It also needs a page that serves the result URL. [Public pages](/docs/content-engine/public-pages) shows how to add one.
### Add the search block [#add-the-search-block]
Add `search` to the definition. This excerpt leaves out the fields and blocks search does not read:
```ts title="plugins/example/src/content/article.ts"
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" }),
excerpt: field.textarea({ maxLength: 500, nullable: true }),
author: field.user(),
},
publication: true,
publicApi: {
path: "articles",
fields: ["title", "slug", "excerpt", "publishedAt"],
},
// [!code ++:7]
search: {
titleField: "title",
descriptionField: "excerpt",
contentFields: ["title", "excerpt"],
pathTemplate: "/articles/{slug}",
authorField: "author",
},
});
```
- `titleField` is the result heading. It must be a `field.text()` that is not nullable.
- `descriptionField` is optional and leads the result excerpt. Text, textarea or rich text.
- `contentFields` is the searchable body. It takes at least one field and no duplicates. Text, textarea, rich text or slug fields, plus repeatable leaves such as `"faq.answer"`. Rich text is indexed as its words, never as JSON. The title always ranks higher than the body, so listing it here as well only repeats it in the excerpt.
- `pathTemplate` is the result URL. It starts with `/` and contains `{slug}` exactly once. Leave the language out, because your i18n settings add it (see [Localized content types](#localized-content-types)).
- `authorField` is optional and credits a top-level `field.user()` on [member timelines](/docs/dev/search#member-timelines).
- `imageField` is optional and shows a `field.file()` image next to each result. See [Credit several authors and add an image](#credit-several-authors-and-add-an-image).
Every field except `authorField` must be in `publicApi.fields`. Otherwise a result snippet would leak a private value, so the definition throws instead: `search.titleField names "x", which is not in publicApi.fields.` `authorField` may stay private, which is why `author` is not in the public list above. All options are in the [reference](/docs/content-engine/reference#search).
### Index the records you already have [#index-the-records-you-already-have]
New writes are indexed from now on, but records saved before the `search` block existed are not. Open **AdminCP → Advanced → Search** (`/admin/core/advanced/search`), type `example.article` into **Search collections…**, and click **Reindex** on that row. **Rebuild index** at the top rebuilds every collection.
Both buttons need the **Rebuild and clear the search index** staff permission (`system` → `can_manage_search`). A rebuild clears the collection and refills it in pages of 200 records. It is a queued job, so it only runs while [cron](/docs/dev/cron) is running.
**Items** compares indexed documents with records in the table, drafts included. Content Engine collections are all labelled **Content** for now, so filter by the content type id to find yours.
## Credit several authors and add an image [#credit-several-authors-and-add-an-image]
A result can show everyone who wrote the record and a picture of it, on `/search`, `/discover` and member timelines. The blog does both: an article lists all its authors and its cover image.
{/* Image prompt: A VitNode search result card in light theme. Top left, three small overlapping round avatars followed by "admin, devloop_tester and aXen · Aug 15, 4:14 PM". Below, a bold title "Hello Content Engine" and two lines of grey excerpt. On the right, a rounded 16:9 cover photo thumbnail. 1200x400. */}
Make the author field hold several users, add a single image field, and point `search` at both:
```ts title="plugins/blog/src/content/post.ts"
export const blogPostContentType = defineContentType({
fields: {
title: field.text({ required: true }),
friendlyUrl: field.slug({ source: "title" }),
content: field.richText(),
// [!code ++:6]
authorId: field.user({ min: 1, multiple: true, ordered: true }),
coverImage: field.file({
allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"],
allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/avif"],
}),
},
publication: true,
publicApi: {
path: "blog",
fields: ["id", "title", "friendlyUrl", "content", "coverImage"], // [!code highlight]
},
search: {
titleField: "title",
contentFields: ["title", "content"],
pathTemplate: "/blog/{slug}",
// [!code ++:2]
authorField: "authorId",
imageField: "coverImage",
},
});
```
- **Authors.** With `multiple: true`, every user in the field is credited, and `ordered: true` keeps the order you pick in the editor. A result shows up to three overlapping avatars and the names in that order, such as **admin, devloop_tester and aXen**. Past three it says **admin, devloop_tester and 2 others**. Every co-author gets the record on their own timeline.
- **Image.** `imageField` names one shared `field.file()`, not a list and not a localized field, and it must be in `publicApi.fields`, because everybody who finds the result sees it. It shows as a small 16:9 thumbnail on the right of the result, and full width above the text on phones. Records without an image show none, and a file that is not an image is skipped.
The definition throws when either one points at the wrong kind of field, for example `search.imageField names "gallery", which is not a shared single field.file() on the record itself.`
Adding `authorField` or `imageField` to content that already exists needs a **Reindex** of the collection, so older records pick them up. After that, changing the authors or the image re-indexes the record on save.
In the API, each result carries `authors` (id, name, `nameCode`, avatar) in order, `authorIds`, and `image` with `url`, `width` and `height`, or `null`.
## What gets indexed and when [#what-gets-indexed-and-when]
Every record is in the index, but only public ones show up on `/search`. A draft is stored as a private document without a URL. A [hidden](/docs/content-engine/content-visibility) record's document turns private the moment it is hidden, and public again when it is unhidden. With `authorField`, it appears on its author's own timeline and on the AdminCP user page, but never in public results.
| Write | Search index |
| ------------------------------------------------------------------------------------------- | ----------------------------- |
| Create, edit, publish, unpublish, restore, hide, unhide, duplicate or delete in the AdminCP | Updated right after the write |
| A scheduled publish or unpublish | Updated when the job runs |
| Your own route using the editorial service and `contentEditorialEffects` | Updated |
| The plain `service` from `model.service(c)` | Not updated |
An edit rewrites the document only when an indexed field changed: the title, description, content fields, slug or author. Unpublishing keeps the document but makes it private, so the record drops out of `/search` at once. Deleting the record removes its document.
The plain service writes rows and nothing else. To change records from your own code and keep search in sync, follow [a "Feature this article" button](/docs/content-engine/services#example-3-a-feature-this-article-button).
If the search engine fails, the save still succeeds. The failure is logged with a `[content-search]` prefix and listed under **Recent sync failures** on the AdminCP Search screen. Reindex the collection to repair it.
## Localized content types [#localized-content-types]
A [localized](/docs/content-engine/localization) content type such as `example.localized-article` gets one document per translation, with the language stored on it. A translation is public only when both the record and that translation are published. Deleting a translation removes only that language's document. Deleting the record removes all of them.
`/search` shows documents in the visitor's language. Documents of content types that are not localized match every language. With `localization.fallback: "default"`, a visitor whose language has no published translation sees the default language's document instead.
Each document gets the URL of its own language. `pathTemplate` stays the English route, and VitNode adds the prefix, domain and translated segments from your [localized URL settings](/docs/dev/i18n/localized-urls). The table in [Result URLs for Content Engine types](/docs/dev/search#result-urls-for-content-engine-types) shows what each setting produces. URLs are stored at indexing time, so reindex after changing `pathTemplate` or those settings.
## Check the result [#check-the-result]
1. Publish an article in **AdminCP → Example → Articles**.
2. Open `/search` and type a whole word or two from its title or excerpt. The result links to `/articles/{slug}`.
The same results come from the public search API. `types` limits them to one content type:
```bash
curl "http://localhost:3000/api/@vitnode/core/search?search=hello&types=example.article"
```
```json
{
"edges": [
{
"pluginId": "@vitnode/example",
"itemType": "example.article",
"itemId": 3,
"languageCode": "",
"title": "Hello Content Engine",
"content": "Hello Content Engine A first article from the example plugin, served by the public API and rendered on its own page.",
"url": "/articles/hello-content-engine",
"isPublic": true
}
]
}
```
Unpublish the article and run the request again. This time `edges` is empty. If a record you published before adding `search` is missing, reindex the collection.
The index runs on Postgres full-text search by default. To switch engines, or to index data that is not a content type, see [Search & Discovery](/docs/dev/search) and [Elasticsearch](/docs/dev/search-elasticsearch).
# SEO (/docs/content-engine/seo)
Once a content type has [public pages](/docs/content-engine/public-pages), `delivery.seo` decides which fields fill each page's ``, meta description and `robots` tag, and `delivery.sitemap` lists the pages for crawlers. You name fields; the Content Engine reads them from every published record, so editors never fill in a separate SEO form unless you give them one.
The examples use `example.article` from the example plugin and `blog.post` from the blog plugin, which is localized.
## Pick the SEO fields [#pick-the-seo-fields]
`example.article` uses its title and excerpt, plus a boolean that hides a record from search engines:
```ts title="plugins/example/src/content/article.ts"
fields: {
title: field.text({ required: true, minLength: 3, maxLength: 200 }),
excerpt: field.textarea({ maxLength: 500, nullable: true }),
noIndex: field.boolean({ nullable: true }),
},
publicApi: {
path: "articles",
fields: ["title", "slug", "excerpt", "noIndex", "publishedAt"],
},
delivery: {
redirects: true,
seo: {
titleField: "title",
descriptionField: "excerpt",
noIndexField: "noIndex",
openGraph: { titleField: "title", descriptionField: "excerpt" },
},
sitemap: { changeFrequency: "weekly", priority: 0.7 },
},
```
| Option | Field kinds | Used for |
| -------------------------------- | ------------------------ | ------------------------------------------------------------ |
| `titleField` | text | Page title |
| `fallbackTitleField` | text | Page title when `titleField` is empty. Needs `titleField` |
| `descriptionField` | text, textarea, richText | Meta description |
| `fallbackDescriptionField` | text, textarea, richText | Description when `descriptionField` is empty |
| `noIndexField` | boolean, not localized | `robots` tag and sitemap, see [noindex](#hide-a-record-from-search-engines) |
| `openGraph.titleField` | text | Open Graph title, falls back to the page title |
| `openGraph.descriptionField` | text, textarea, richText | Open Graph description, falls back to the page description |
Every field you name must also be in `publicApi.fields`, because the page shows it to anyone. Leave one out and the definition throws at startup with `... which is not in publicApi.fields`. A field inside a group works (`"seo.title"`). A field inside a repeatable does not, since a page has one title, not a list of them.
Values are trimmed, and an empty one counts as missing, so the fallback kicks in. The blog uses that to describe posts that have no excerpt:
```ts title="plugins/blog/src/content/post.ts"
delivery: {
seo: {
titleField: "title",
descriptionField: "excerpt",
fallbackDescriptionField: "content",
},
},
```
`content` is rich text. The Content Engine turns it into plain text, and the page cuts it to 160 characters at a word boundary and adds `…`. No markup ever lands in a meta tag.
The blog plugin's own article editor checks the fields this rule reads in its **SEO** sheet. The sheet belongs to the blog plugin, not to every Content Engine form. Here the post has no excerpt, so the **Excerpt** row is marked as missing, and search results would fall back to the content.
## What reaches the page head [#what-reaches-the-page-head]
The page's `head` calls `contentDeliveryPageHead` with the resolve route's answer, as shown in [Write the page](/docs/content-engine/public-pages#write-the-page). This is what the HTML gets today:
| Tag | Rendered |
| ------------------------------------------- | ------------------------------------------- |
| `` | Yes, followed by the site title (`- VitNode`) |
| `` | Yes, at most 160 characters |
| `` | Only for a no-index record |
| `` and `hreflang` links | Only for a localized content type |
| `og:title`, `og:description` | No |
The resolve route returns `openGraph` and `canonicalPath` for every record, but nothing turns them into tags yet. A nonlocalized type such as `example.article` gets no canonical link, and no content type gets Open Graph tags. A plugin page cannot add them either, because its `head` only accepts `title`, `description`, `robots` and `alternates`. Keep `openGraph` in your definition so the data is ready, and read it from the resolve route if you render pages outside VitNode.
## Hide a record from search engines [#hide-a-record-from-search-engines]
`noIndexField` names one boolean that drives two things together:
- the page gets ``;
- the record leaves the [sitemap](#sitemap).
Only `true` hides a record. `null` and `false` both mean "index it", which is why `example.article` can make the field nullable without emptying its sitemap. The resolve route reports the decision as `"robots": { "follow": true, "index": false }`.
The field must be shared, not localized. A record has one indexing decision, and a per-language value would let the Polish page say `noindex` while the sitemap lists the English one. The definition refuses a localized `noIndexField` for that reason.
## Localized alternates and hreflang [#localized-alternates-and-hreflang]
For a [localized](/docs/content-engine/localization) content type, the page head links every published translation of the record. The blog post `english-title` is published in English and Polish, so `/blog/english-title` gets:
```html
```
- The canonical link is the page's own language.
- Each URL follows the app's [localized URL rules](/docs/dev/i18n/localized-urls), which is how the Polish post ends up at `/pl/wpisy/...`.
- Links are absolute, built from `VITNODE_WEB_URL` (`http://localhost:3000` when unset) unless a language has its own domain.
- A translation that is not published is not an alternate.
- Delivery on a localized type needs `"id"` in `publicApi.fields`, because alternates are looked up by record id. The definition throws without it.
`hreflang: { xDefault: "defaultLocale" }` adds `hreflang.xDefault` to the resolve route's answer, pointing at the record's default-language URL when that translation is published. It needs `localization`. The page head does not read this option. It adds its `x-default` link whenever the app's default language is among the alternates, with or without it, so the option only matters when you read the resolve route yourself.
```ts title="plugins/blog/src/content/post.ts"
delivery: {
hreflang: { xDefault: "defaultLocale" },
},
```
## Sitemap [#sitemap]
`delivery.sitemap` adds a public route that lists every published URL of the content type, oldest record first. Pass `true` or `{ changeFrequency, priority }`. It needs `publication: true`, since a sitemap lists only what readers can reach.
```bash
curl "http://localhost:3000/api/@vitnode/example/content/articles/delivery/sitemap?cursor=1&limit=1"
```
```json
{
"entries": [
{
"changeFrequency": "weekly",
"itemId": 3,
"lastModified": "2026-10-10T11:06:30.518Z",
"locale": null,
"path": "/articles/hello-content-engine",
"priority": 0.7
}
],
"nextCursor": null
}
```
- A page holds 1,000 entries by default and up to 50,000 with `?limit=`. `cursor` is the `itemId` to continue after, so pass `nextCursor` back as `?cursor=` until it is `null`.
- `lastModified` is the record's `updatedAt`, or the translation's when that is newer.
- Records whose `noIndexField` is `true` are left out, and so are [hidden](/docs/content-engine/content-visibility) records.
- Entries come in id order. For a [uuid](/docs/content-engine/id-strategy) content type that order is lexical, not creation order, and every record still appears once.
- A localized type returns one language per request. Ask for another with `?locale=`:
```bash
curl "http://localhost:3000/api/@vitnode/blog/content/blog/delivery/sitemap?locale=pl&limit=1"
```
```json
{
"entries": [
{
"changeFrequency": "weekly",
"itemId": 4,
"lastModified": "2026-08-12T18:00:43.308Z",
"locale": "pl",
"path": "/pl/wpisy/polish-title",
"priority": 0.7
}
],
"nextCursor": 4
}
```
The app's `/sitemap.xml` does not read these routes for you, so add the entries where you build it. `contentSitemapXml` from `@vitnode/core/content` writes the XML:
```ts
import {
contentSitemapXml,
isContentSitemapChangeFrequency,
} from "@vitnode/core/content";
const response = await fetch(
"http://localhost:3000/api/@vitnode/example/content/articles/delivery/sitemap",
);
const { entries } = await response.json();
const xml = contentSitemapXml({
origin: "https://example.com",
entries: entries.map(entry => ({
...entry,
changeFrequency: isContentSitemapChangeFrequency(entry.changeFrequency)
? entry.changeFrequency
: null,
lastModified: new Date(entry.lastModified),
})),
});
```
With the example plugin's two published articles, `xml` is:
```xml
https://example.com/articles/jbbjk-12026-08-30T09:18:25.398Zweekly0.7https://example.com/articles/hello-content-engine2026-10-10T11:06:30.518Zweekly0.7
```
`contentSitemapIndexXml` writes a `` once one file is not enough, for example one file per content type and language.
## Retired slugs [#retired-slugs]
With `delivery.redirects`, an old slug answers a permanent `308` redirect to the record's current URL, so links and search results that still use the old address land on the new page. See [Keep old URLs working](/docs/content-engine/public-pages#keep-old-urls-working).
## Check the result [#check-the-result]
Print the SEO tags of an article and of a localized blog post:
```bash
curl -s http://localhost:3000/articles/hello-content-engine | grep -oE '[^<]*|]*name="(description|robots)"[^>]*>|]*rel="(canonical|alternate)"[^>]*>'
```
```html
Hello Content Engine - VitNode
```
Run the same command for `http://localhost:3000/blog/english-title` and you get its title, description and the four links from [Localized alternates](#localized-alternates-and-hreflang). The SVG logo's `Logo VitNode` may show up too; it is not page metadata.
Then ask the resolve route what the page was given, `openGraph` and `robots` included:
```bash
curl http://localhost:3000/api/@vitnode/example/content/articles/delivery/resolve/hello-content-engine
```
Set `noIndex` on a test article, publish it, and run both commands again. The page gains the `robots` tag, and the [sitemap](#sitemap) route stops listing it.
# Services (/docs/content-engine/services)
The Content Engine already generates routes for creating, reading, updating, deleting and publishing records. A **service** is what you use when you need something those routes don't do, such as "the three newest articles in one category" or "create an article from a cron job". It is a typed object with methods like `findMany`, `create` and `publish`, and you get it from the model `createContentModel` returns.
Every example on this page uses the example plugin's article model:
```ts title="plugins/example/src/database/articles.ts"
import { createContentModel } from "@vitnode/core/content/server";
import { articleContentType } from "@/content/article";
import { example_categories } from "./categories";
export const articleContent = createContentModel(articleContentType, {
references: { category: () => example_categories.id },
});
```
## The short version [#the-short-version]
Inside any Hono handler, call a service with the request context `c`:
```ts
const article = await articleContent.service(c).findById(1);
```
`article` is fully typed: `article.title` is a `string`, `article.featured` is a `boolean`, and a typo is a compile error. There are three services, and the only question is which one fits your task:
| You want to | Use |
| ------------------------------------------------------------ | ------------------------------------------ |
| Show content to visitors: published records, public fields | `articleContent.publicService(c)` |
| Read or write anything, in a staff route, cron job or script | `articleContent.service(c)` |
| Save a change the way the AdminCP does, with history | `articleContent.editorialService(c, opts)` |
The three sections below build one real example for each.
## Example 1: the newest articles in a category [#example-1-the-newest-articles-in-a-category]
**Goal:** a public endpoint, `GET /api/@vitnode/example/articles/by-category/1`, that returns the three newest published articles in category `1`. Drafts must never appear, and neither may private fields.
`publicService` guarantees exactly that. It returns what the [public API](/docs/content-engine/public-api) would, so you cannot leak a draft by accident.
### Write the route [#write-the-route]
```ts title="plugins/example/src/api/modules/articles/by-category.route.ts"
import { buildRoute } from "@vitnode/core/api/lib/route";
import { z } from "zod";
import { CONFIG_PLUGIN } from "@/const";
import { articleContent } from "@/database/articles";
export const articlesByCategoryRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
route: {
method: "get",
path: "/by-category/{categoryId}",
description: "Published articles in one category, newest first",
request: {
params: z.object({ categoryId: z.coerce.number().int().positive() }),
},
responses: {
200: {
content: {
"application/json": {
schema: z.array(
z.object({
publishedAt: z.date().nullable(),
slug: z.string(),
title: z.string(),
}),
),
},
},
description: "Up to three articles",
},
},
},
handler: async c => {
const { publicService } = articleContent;
if (!publicService) throw new Error("Articles have no public API.");
const { categoryId } = c.req.valid("param");
const { edges } = await publicService(c).findMany({
filters: { category: categoryId },
orderBy: { column: "publishedAt", order: "desc" },
query: { first: "3" },
});
return c.json(
edges.map(({ publishedAt, slug, title }) => ({
publishedAt,
slug,
title,
})),
200,
);
},
});
```
The handler does three things:
1. **Gets the service.** `publicService` is `undefined` for a content type without `publicApi`, so the `if` makes that explicit once.
2. **Asks for records.** `filters` keeps only category `1`, `orderBy` puts the newest first and `query.first` limits it to three. `query` takes the same strings as the URL of a list route, which is why `first` is `"3"` and not `3`.
3. **Shapes the answer.** `edges` is the list of records. The route picks three fields so the response stays small.
`filters` only accepts fields listed in `publicApi.filterableFields`, and `orderBy.column` only fields in `publicApi.orderableFields`. Asking for anything else is a type error.
### Register it [#register-it]
Put the route in a module and add the module to your API plugin:
```ts title="plugins/example/src/api/modules/articles/articles.module.ts"
import { buildModule } from "@vitnode/core/api/lib/module";
import { CONFIG_PLUGIN } from "@/const";
import { articlesByCategoryRoute } from "./by-category.route";
export const articlesModule = buildModule({
pluginId: CONFIG_PLUGIN.pluginId,
name: "articles",
routes: [articlesByCategoryRoute],
});
```
```ts title="plugins/example/src/config.api.ts"
modules: [adminModule, articlesModule],
```
### Call it [#call-it]
```bash
curl http://localhost:8000/api/@vitnode/example/articles/by-category/1
```
```json
[
{
"publishedAt": "2026-08-03T08:47:00.000Z",
"slug": "jbbjk-1",
"title": "old article, example"
}
]
```
A category with no published articles answers `[]`. A draft in category `1` is not in the list, even though it exists.
Need one record instead of a list? `publicService(c).findBySlug("jbbjk-1")` and `findById(1)` return one published record, or `null`.
## Example 2: create and publish an article from a job [#example-2-create-and-publish-an-article-from-a-job]
**Goal:** a [cron job](/docs/dev/cron) that publishes a "Weekly digest" article every Monday at 8:00.
There is no visitor and no staff member here, so you want the plain `service`. It sees every record and every field, including drafts, and it checks no permissions. That makes it the right tool for jobs and scripts, and the wrong one for a public route.
```ts title="plugins/example/src/api/cron/weekly-digest.cron.ts"
import { buildCron } from "@vitnode/core/api/lib/cron";
import { articleContent } from "@/database/articles";
export const weeklyDigestCron = buildCron({
name: "weekly-digest",
description: "Publish the weekly digest article",
schedule: "0 8 * * 1",
handler: async c => {
const service = articleContent.service(c);
const article = await service.create({
title: "Weekly digest",
code: `digest-${Date.now()}`,
category: 1,
gallery: [21],
});
await service.publish(article.id);
},
});
```
Add it to a module's `cronJobs`, for example `cronJobs: [weeklyDigestCron]` in the `articles` module from Example 1.
`create` checks the values against the same rules as the AdminCP form: `title` is required and at least 3 characters, `gallery` needs at least one image, and so on. Values that break a rule throw a `ZodError` before anything is written. `slug` is filled from the title automatically.
To make several writes succeed or fail together, run them in one transaction by passing `{ tx }` as the last argument:
```ts
await c.get("db").transaction(async tx => {
const article = await service.create(
{ title: "Weekly digest", code: "digest-41", category: 1, gallery: [21] },
{ tx },
);
await service.publish(article.id, { tx });
});
```
The plain service is quiet. It emits no [events](/docs/content-engine/events),
writes no revisions and does not update the search index. If a listener or
the site search should notice the change, use the editorial service from
Example 3.
## Example 3: a "Feature this article" button [#example-3-a-feature-this-article-button]
**Goal:** a staff endpoint, `POST /articles/1/feature`, that marks an article as featured, exactly as if someone had ticked **Featured** in the AdminCP and pressed **Save**. That means a revision in the history, the `updated` event, a search index update and protection against overwriting someone else's save.
That is the `editorialService` (available with [`editorial`](/docs/content-engine/editorial)). It needs one extra input, `expectedVersion`: the version of the article the button's page loaded. If somebody saved the article in the meantime, the versions differ and the request fails with `409` instead of silently overwriting their work.
```ts title="plugins/example/src/api/modules/admin/routes/feature.route.ts"
import { buildRoute } from "@vitnode/core/api/lib/route";
import {
contentEditorialEffects,
resolveContentActor,
withHttpErrors,
} from "@vitnode/core/content/server";
import { HTTPException } from "hono/http-exception";
import { z } from "zod";
import { CONFIG_PLUGIN } from "@/const";
import { articleContent } from "@/database/articles";
export const featureArticleRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
adminStaffPermission: { module: "article", permission: "can_edit" },
route: {
method: "post",
path: "/articles/{id}/feature",
description: "Mark an article as featured",
request: {
params: z.object({ id: z.coerce.number().int().positive() }),
body: {
required: true,
content: {
"application/json": {
schema: z.object({ expectedVersion: z.number().int().positive() }),
},
},
},
},
responses: {
200: {
content: {
"application/json": { schema: articleContent.schemas.select },
},
description: "The featured article",
},
404: { description: "Article not found" },
409: { description: "Someone saved the article first" },
},
},
handler: async c => {
const { editorialService } = articleContent;
if (!editorialService) throw new Error("Articles have no editorial.");
const { id } = c.req.valid("param");
const { expectedVersion } = c.req.valid("json");
const pluginId = CONFIG_PLUGIN.pluginId;
const outcome = await withHttpErrors(
"update",
async () =>
await editorialService(c, { pluginId }).update(
id,
{ featured: true },
{ actor: resolveContentActor(c), expectedVersion },
),
{
contentTypeId: articleContent.definition.id,
itemId: id,
structured: true,
},
);
if (!outcome) throw new HTTPException(404);
await contentEditorialEffects(c, articleContent.definition, outcome, {
model: articleContent,
pluginId,
});
return c.json(outcome.row, 200);
},
});
```
Reading the handler top to bottom:
1. **`adminStaffPermission`** lets in only staff who may edit articles, the same check as the AdminCP's own edit route. Put the route in your plugin's `admin` module.
2. **`editorialService(...).update`** saves `featured: true` and writes a revision. `resolveContentActor(c)` puts the signed-in staff member's name on that revision.
3. **`withHttpErrors`** turns a version mismatch into a `409` response the AdminCP understands, instead of a crash.
4. **`contentEditorialEffects`** does everything that should happen after a save: it emits `content.example.article.updated`, updates the search index and handles URL changes. Skip it and the save is still stored, but nothing else hears about it.
The route lives in the `admin` module, so it answers at `POST /api/@vitnode/example/admin/articles/1/feature` and only to a signed-in AdminCP session. The body carries the version the page loaded:
```json
{ "expectedVersion": 3 }
```
If someone saved the article after version 3 was loaded, the answer is a `409`:
```json
{
"code": "CONTENT_VERSION_CONFLICT",
"contentTypeId": "example.article",
"currentVersion": 4,
"expectedVersion": 3,
"itemId": 1
}
```
Load the article again, take the new version, and retry. A job without a signed-in user passes `CONTENT_SYSTEM_ACTOR` instead of `resolveContentActor(c)`.
## Hide, duplicate and content IDs [#hide-duplicate-and-content-ids]
A content type with [`visibility`](/docs/content-engine/content-visibility) or [`duplication`](/docs/content-engine/duplicate-content) adds methods to `service` and `editorialService`. They exist in the types only when the option is on, so calling `hide` on a content type without visibility is a compile error.
| Method | `service` options | `editorialService` options |
| ------------------------------------------ | ------------------------------------ | ------------------------------------------- |
| `hide(id, options)`, `unhide(id, options)` | `{ actorUserId?, tx? }` | `{ actor, expectedVersion?, tx? }` |
| `duplicate(sourceId, options)` | `{ overrides?, translations?, tx? }` | `{ actor, overrides?, translations?, tx? }` |
Hiding a record that is already hidden returns `changed: false` and writes nothing. The editorial `hide` and `unhide` raise the version and store a revision, and the editorial `duplicate` stores a first revision for the copy and for each copied translation. All of them return `null` when the record does not exist.
Like the other service methods, these emit no events. Run the effects after the transaction commits, as the generated routes do:
```ts
import { contentVisibilityEffects, resolveContentActor } from "@vitnode/core/content/server";
const { editorialService } = postContent;
if (!editorialService) throw new Error("Articles have no editorial.");
const pluginId = CONFIG_PLUGIN.pluginId;
const outcome = await editorialService(c, { pluginId }).hide(703, {
actor: resolveContentActor(c),
});
if (outcome) {
await contentVisibilityEffects(c, postContent, outcome, { pluginId });
}
```
`contentVisibilityEffects` emits `hidden` or `unhidden`, updates the search index and the delivery data, and [notifies front ends](/docs/content-engine/caching#notify-a-separate-front-end). It does nothing for an outcome with `changed: false`. For a copy, call `contentDuplicateEffects`, shown in [Duplicate content](/docs/content-engine/duplicate-content#duplicate-from-your-own-code).
### Content IDs [#content-ids]
Service methods take and return ids typed by the content type's [`idStrategy`](/docs/content-engine/id-strategy): `ContentIdOf` is `number` for a serial type and `string` for a uuid or bigint one. Never pass an id through `Number()` in code that may meet a bigint type.
A custom route reads an id from the URL as text. Parse it with the content type's strategy before calling a service, so a malformed id answers `400` instead of reaching the database. The example plugin's events use bigint ids:
```ts title="plugins/example/src/api/modules/events/title.route.ts"
import { buildRoute } from "@vitnode/core/api/lib/route";
import { parseContentIdOf } from "@vitnode/core/content";
import { HTTPException } from "hono/http-exception";
import { z } from "zod";
import { CONFIG_PLUGIN } from "@/const";
import { eventContentType } from "@/content/event";
import { eventContent } from "@/database/events";
export const eventTitleRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
route: {
method: "get",
path: "/{id}/title",
description: "The title of one published event",
responses: {
200: {
content: {
"application/json": {
schema: z.object({ id: z.string(), title: z.string() }),
},
},
description: "The event title",
},
400: { description: "Invalid event id" },
404: { description: "Event not found" },
},
},
handler: async c => {
const id = parseContentIdOf(eventContentType, c.req.param("id"));
if (id === null) {
throw new HTTPException(400, { message: "Invalid event id." });
}
const { publicService } = eventContent;
if (!publicService) throw new Error("Events have no public API.");
const event = await publicService(c).findById(id);
if (!event) throw new HTTPException(404, { message: "Event not found." });
return c.json({ id, title: event.title }, 200);
},
});
```
`id` is a `string` here, because `example.event` uses bigint. For a route that validates its parameters with Zod, `contentIdParamSchema(eventContentType.idStrategy)` accepts exactly what `parseContentIdOf` accepts. Code that handles content types it does not know statically uses `ContentId` (`number | string`) and `contentAnyIdSchema`. Every helper is listed in [Record ID strategies](/docs/content-engine/id-strategy#helpers).
## Reference [#reference]
### Service methods [#service-methods]
`publicService` only reads: `findMany({ filters, orderBy, query })`, `findById(id)` and `findBySlug(slug)`, each limited to published records and public fields. `editorialService` only writes: `create`, `update`, `delete`, `publish`, `unpublish`, `restore`, plus `relations` and `repeatable`, and `hide`, `unhide` and `duplicate` when their option is on. `service` has everything below:
| Method | Returns |
| ---------------------------------------------- | ------------------------------------------------------------------------------------- |
| `findMany({ filters, orderBy, query, where })` | `{ edges, pageInfo }`, drafts included. `where` takes a Drizzle `SQL` condition |
| `findById(id)` | The row, or `null` |
| `findRowById(id)` | The row with display labels for relations and users, or `null` |
| `findDetail(id)` | The row with relation, repeatable and file values, or `null` |
| `create(values)` | The new row |
| `update(id, values)` | `{ row, changedFields }`, or `null` when the id does not exist |
| `delete(id)` | The deleted row, or `null` |
| `publish(id)`, `unpublish(id)` | `{ changed, publishedAt, row }`, or `null`. Only with `publication` |
| `hide(id)`, `unhide(id)` | `{ changed, hiddenAt, hiddenBy, row }`, or `null`. Only with `visibility` |
| `duplicate(sourceId)` | `{ row, sourceId, translations, skippedLocales }`, or `null`. Only with `duplication` |
| `advanced(id)`, `advancedFields(id, fields)` | Values of to-many relations, repeatables and galleries |
| `options(field, search?, ids?)` | `{ value, label, color? }[]` for a relation or user picker |
| `relations.` | `add`, `remove`, `set`, `reorder`, `get` for a to-many relation |
| `repeatable.` | `create`, `update`, `delete`, `reorder`, `set`, `list` for a repeatable |
On `service`, every write and every single-record read accepts `{ tx }` as its last argument. On `editorialService`, every write takes an options object with `actor`; `update`, `delete` and `restore` also take `expectedVersion`, which `publish` and `unpublish` treat as optional. A [localized](/docs/content-engine/localization) content type also has `localizedService` for writing translations.
### Schemas [#schemas]
`articleContent.schemas` holds the Zod schemas the generated routes validate with. Reuse them so a custom route accepts and returns exactly what the built-in ones do, as Example 3 does with `select`:
| Schema | Validates |
| ------------------------------------ | -------------------------------------------------------------- |
| `create` | A create payload, with required fields and defaults |
| `update` | A partial update payload, never empty |
| `updateEnvelope` | `{ expectedVersion, values }` for an editorial update |
| `select`, `selectObject` | A full row (`selectObject` stays a `ZodObject` you can extend) |
| `publicSelect`, `publicSelectObject` | A row as the public API returns it |
| `filters`, `publicFilters` | Staff and public list filters |
| `order`, `publicOrder` | The `orderBy` allowlist plus direction |
| `params`, `publicParams` | `{ id }` and `{ slug }` path parameters |
| `form` | The AdminCP form values |
To react to writes instead of making them, see [Events](/docs/content-engine/events).
# Authorization & Sessions (/docs/dev/advanced/auth)
VitNode implements two isolated sessions: a **public session** for the community site and a separate **admin session** for the AdminCP. Both use opaque random tokens stored as SHA-256 hashes in PostgreSQL.
## Quick start [#quick-start]
Customize session parameters in `apps/api/src/vitnode.api.config.ts`:
```ts title="apps/api/src/vitnode.api.config.ts"
import { buildApiConfig } from "@vitnode/core/vitnode.config"
export const vitNodeApiConfig = buildApiConfig({
// [!code ++:6]
authorization: {
cookieExpires: 1000 * 60 * 60 * 24 * 30, // 30 days for public users
adminCookieExpires: 1000 * 60 * 30, // sign staff out after 30 idle minutes
cookieDomain: ".yourdomain.com", // Optional cross-subdomain sharing
},
})
```
---
## The Two-Session Model [#the-two-session-model]
| Session Type | Cookie Name | Default Lifetime | Storage Table | Purpose |
| :--- | :--- | :--- | :--- | :--- |
| **Public Session** | `vitnode_auth` | 90 days | `core_sessions` | Frontend member authentication |
| **Admin Session** | `vitnode_auth_admin` | 1 hour of inactivity | `core_admin_sessions` | High-privilege AdminCP access |
| **Known Device** | `vitnode_device` | 1 year | `core_sessions_known_devices` | Device authorization tracking |
Signing out of the AdminCP does not terminate the user's public session, and vice versa. An administrator compromised in a public context cannot access the AdminCP without re-authenticating with staff credentials - a password or a user-verified passkey, checked against live staff access. Neither a public session nor social SSO ever becomes an AdminCP session.
---
## AdminCP Session Timeout [#admincp-session-timeout]
The AdminCP is the keys to the kingdom, so its session is deliberately short-lived. Staff are asked to sign in again when:
- **They go quiet for an hour.** Every AdminCP request pushes the expiry an hour ahead, so an admin who keeps working is never interrupted. Leave the tab alone for an hour and it signs itself out and lands on the sign-in page, with a toast explaining why. Tune the window with `adminCookieExpires`.
- **They close every AdminCP tab.** Open AdminCP tabs keep a heartbeat going. Open the AdminCP again after every tab was closed and VitNode ends the old session instead of letting it back in. A page reload, or opening one more AdminCP tab while another is still open, is not affected. This check is skipped when `cookieDomain` is set: a browser can only see tabs on its own origin, so an AdminCP tab open on another subdomain would look closed.
- **They close the browser.** The `vitnode_auth_admin` cookie has no expiry date, so the browser drops it when it shuts down.
Moving from the AdminCP to the public site in your only AdminCP tab stops the heartbeat. Come back within about 15 seconds and you are still signed in; any later and you sign in again. Open the public site in a new tab to keep your AdminCP session.
---
## Security Guarantees [#security-guarantees]
- **SHA-256 Token Storage**: The raw token is stored only in the user's `HttpOnly` cookie. The database stores only its cryptographic hash. Sign-in, SSO and passkey answers carry the user id only - page scripts never see the token.
- **Device Pinning**: Tokens are bound to a verified device ID. Tokens copied to another device without the matching device cookie are rejected.
- **Fast 60-Second Caching**: Active sessions are cached in Redis for up to 60 seconds, eliminating database query overhead on repeated requests.
- **Confirmed Email Addresses**: With an email adapter, members confirm their address before their first sign-in. See [Email Verification](/docs/dev/working-with-users/email-verification).
- **Canonical Email Identity**: Addresses are stored in their canonical form, so Gmail dot, `googlemail.com` and `+tag` variants cannot become a second account. See [One Mailbox, One Account](#one-mailbox-one-account).
---
## One Mailbox, One Account [#one-mailbox-one-account]
`jankowalski@gmail.com` and `jan.kowalski@gmail.com` look like two addresses. Gmail ignores the dots, so they are one mailbox - and a site that treats them as two lets the same person hold two accounts, dodge a ban, or farm their own referral link.
VitNode stores the **canonical** form of an address, so those tricks collapse into a single account:
| Typed at sign-up | Stored in `core_users.email` |
| :--- | :--- |
| `Jan.Kowalski@Gmail.com` | `jankowalski@gmail.com` |
| `jankowalski@googlemail.com` | `jankowalski@gmail.com` |
| `jankowalski+shop@gmail.com` | `jankowalski@gmail.com` |
| `piotr+news@outlook.com` | `piotr@outlook.com` |
| `zofia.nowak@company.com` | `zofia.nowak@company.com` |
The rules are per-provider, because they are only true per-provider:
- **Dots are removed for `gmail.com` and `googlemail.com` only.** Everywhere else a dot is part of the mailbox, so `jan.kowalski@company.com` is left alone.
- **`googlemail.com` folds into `gmail.com`.** Google treats them as one domain.
- **`+tag` suffixes are dropped** for Gmail and for the providers that document sub-addressing: Outlook, Hotmail, Live, MSN, iCloud, Proton, Fastmail, GMX, Yandex and Zoho. Mail sent to the canonical address still lands in the same inbox.
- **Everything else is kept as typed**, lowercased. A `+` can be a real character in a corporate address, so guessing there would lock people out.
- **Yahoo's `-` aliases are deliberately left alone**, because `jan-kowalski@yahoo.com` is a perfectly ordinary name.
Sign-up, sign-in, password reset, the SSO callback and AdminCP user management all resolve an address the same way. Someone who registered with a password at `jankowalski@gmail.com` and later clicks *Sign in with Google* - which hands back `jan.kowalski@gmail.com` - lands on their own account instead of a second one.
The `20260906174535_canonicalize_user_emails` migration rewrites stored addresses in place. A row whose canonical form is already taken by another row is left untouched, so nobody is locked out and no two accounts are silently merged. Run this before upgrading to see whether your install has such pairs:
```sql
SELECT
replace(split_part(split_part(lower("email"), '@', 1), '+', 1), '.', '') AS mailbox,
array_agg("email")
FROM "core_users"
WHERE lower("email") LIKE '%@gmail.com' OR lower("email") LIKE '%@googlemail.com'
GROUP BY 1
HAVING count(*) > 1;
```
## `authorization` Options Reference [#authorization-options-reference]
## Turning off password sign-in [#turning-off-password-sign-in]
Running a passkeys-only or social-login-only community? Switch passwords off:
```ts title="apps/api/src/vitnode.api.config.ts"
export const vitNodeApiConfig = buildApiConfig({
// [!code ++:3]
authorization: {
password: false,
},
})
```
With passwords off:
- `/login` hides the email + password form and **Forgot password?**. It keeps [SSO](/docs/dev/sso) buttons and **Sign in with a passkey** - if neither is configured, members see a "Signing in is unavailable" notice.
- `/register` hides the sign-up form. New accounts can still come in through SSO; with no SSO provider, the page says registration is unavailable and the "Sign up" link on `/login` goes away.
- `/login/reset-password` answers "not found".
- The API refuses `POST /users/sign_in` (for members), `/users/sign_up`, `/users/reset-password`, `/users/change-password` and password-based SSO linking with `403`.
- A stored password no longer counts as a way into the account, so members can't remove their last [passkey](/docs/dev/passkeys) unless they have another passkey or a linked SSO provider.
The AdminCP sign-in at `/admin` keeps its email + password form, so switching member passwords off never locks your staff out. With passkeys enabled, staff can also use [AdminCP passkey sign-in](/docs/dev/passkeys/admincp). Signing out works exactly as before.
Password hashes already in the database are left alone, so switching `enabled` back to `true` restores password sign-in for everyone who had one.
## Learn More [#learn-more]
# Queue Tasks (/docs/dev/advanced/queue)
Queue tasks handle asynchronous, one-off background work (e.g. sending bulk emails, processing media, or scheduling posts). Tasks are persisted in the `core_queue` table and drained periodically by VitNode's background worker.
## Quick start [#quick-start]
### 1. Define a Queue Task [#1-define-a-queue-task]
```ts title="plugins/blog/src/api/tasks/send-newsletter.task.ts"
import { buildQueueTask } from "@vitnode/core/api/lib/queue"
export interface NewsletterPayload {
postId: number
}
export const sendNewsletterTask = buildQueueTask({
name: "send-newsletter",
handler: async (c, payload) => {
await c.get("log").info(`Processing newsletter for post #${payload.postId}`)
},
})
```
---
### 2. Register in an API Module [#2-register-in-an-api-module]
Attach the task to your module's `queueTasks` list:
```ts title="plugins/blog/src/api/modules/posts/posts.module.ts"
import { buildModule } from "@vitnode/core/api/lib/module"
import { sendNewsletterTask } from "../../tasks/send-newsletter.task"
export const postsModule = buildModule({
name: "posts",
routes: [createPostRoute],
queueTasks: [sendNewsletterTask], // [!code ++]
})
```
---
### 3. Dispatch Tasks [#3-dispatch-tasks]
Dispatch jobs from any Hono route or service:
```ts
// Dispatch immediately
await c.get("queue").dispatch({
name: "send-newsletter",
payload: { postId: 42 },
})
// Or schedule for future execution
await c.get("queue").dispatch({
name: "send-newsletter",
payload: { postId: 42 },
executeAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour from now
priority: 10, // Higher numbers run first
})
```
`dispatch` returns `{ id }` as soon as the task row is written to PostgreSQL.
---
## Retries and Error Handling [#retries-and-error-handling]
VitNode tasks automatically retry on failure using exponential backoff:
```ts
export const riskyTask = buildQueueTask({
name: "sync-external-api",
maxAttempts: 5, // Default is 3
handler: async (c, payload) => {
// If an error is thrown, the task retries automatically
},
})
```
| Attempt | Delay Before Next Retry |
| :--- | :--- |
| 1st failure | ~10 seconds |
| 2nd failure | ~20 seconds |
| Each next failure | Doubles, capped at 1 hour |
| Final failure | Marked as `failed` in `core_queue` |
### Stale Tasks Are Reclaimed [#stale-tasks-are-reclaimed]
A task still `processing` after 15 minutes (`QUEUE_STALE_PROCESSING_MINUTES`) belonged to a worker that died mid-run. The queue worker puts it back to `pending`, or marks it `failed` if it has used every attempt, so a crash never strands a task forever.
A handler that runs longer would be reclaimed and run again while it is still going. Long work should save its progress and dispatch a follow-up task instead - the way [notification fan-out](/docs/dev/notifications/queue-and-scale) does.
---
## AdminCP Queue Monitor [#admincp-queue-monitor]
{/* Image prompt: VitNode AdminCP Queue Management screen at /admin/core/advanced/queue. Table displaying queued jobs with status badges (pending, processing, completed, failed), attempts count, payload JSON preview, and a retry action button. Dark theme, 1440x900. */}
Inspect and debug tasks at **Core → Advanced → Queue** (`/admin/core/advanced/queue`):
- Monitor pending, active, and failed jobs in real time.
- View failure error traces and retry failed tasks with one click.
---
## `buildQueueTask` Options [#buildqueuetask-options]
Promise",
},
maxAttempts: {
default: "3",
description: "Maximum retry attempts before permanently failing.",
type: "number",
},
}}
/>
## Learn More [#learn-more]
# Rate Limiter (/docs/dev/advanced/rate-limiter)
VitNode includes automated IP-based rate limiting middleware. Exceeding the request budget immediately returns `429 Too Many Requests` with a `Retry-After` header, protecting authentication and public endpoints from brute-force attacks.
## Quick start [#quick-start]
Customize the rate limiter budget in `apps/api/src/vitnode.api.config.ts`:
```ts title="apps/api/src/vitnode.api.config.ts"
import { buildApiConfig } from "@vitnode/core/vitnode.config"
export const vitNodeApiConfig = buildApiConfig({
// [!code ++:4]
rateLimiter: {
points: 60, // Max requests allowed
duration: 60, // Window in seconds
},
})
```
The default configuration allows **80 requests per 60 seconds** per IP address.
IPv6 visitors are counted per `/64` network, not per address - an ISP usually hands one person a whole `/64`, and we'd rather not give them 18 quintillion fresh budgets.
## Stricter budget for sign-in [#stricter-budget-for-sign-in]
Routes that check a password or send an email to whoever is named in the request get a second, much smaller budget on top of the global one: **10 requests per 60 seconds** per IP address. That covers:
- `POST /users/sign_in` and `POST /users/sign_up`
- `POST /users/reset-password` and `POST /users/change-password`
- `POST /users/verify-email` and `POST /users/verify-email/resend`
- `POST /users/passkeys/sign-in` and `POST /users/passkeys/admin-sign-in`
Tune it with `rateLimiter.auth`:
```ts title="apps/api/src/vitnode.api.config.ts"
export const vitNodeApiConfig = buildApiConfig({
rateLimiter: {
// [!code ++:4]
auth: {
points: 5,
duration: 60,
},
},
})
```
## Budget for public content writes [#budget-for-public-content-writes]
Public [Content Engine writes](/docs/content-engine/public-write) - `POST` and `PATCH` requests under a plugin's `/content/` routes, outside `/admin/` - get their own budget on top of the global one: **20 requests per 60 seconds** per IP address. It keeps one member from flooding a forum with new discussions.
Tune it with `rateLimiter.contentWrite`:
```ts title="apps/api/src/vitnode.api.config.ts"
export const vitNodeApiConfig = buildApiConfig({
rateLimiter: {
// [!code ++:4]
contentWrite: {
points: 10,
duration: 60,
},
},
})
```
Rate limiting is automatically bypassed when `NODE_ENV=development` to prevent interruptions during local coding.
---
## 429 Error Response Format [#429-error-response-format]
When a client exhausts their request allowance, the API returns a structured JSON error:
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 24
Content-Type: application/json
{
"error": "Too Many Requests",
"retryAfter": 24
}
```
---
## Distributed Rate Limiting (Redis) [#distributed-rate-limiting-redis]
- **Without Redis**: Counters are tracked in local memory per server instance.
- **With Redis**: Counters are synchronized across all API containers using a distributed sliding window.
Configure Redis in `vitnode.api.config.ts`:
```ts
export const vitNodeApiConfig = buildApiConfig({
redis: process.env.REDIS_URL
? { url: process.env.REDIS_URL, password: process.env.REDIS_PASSWORD }
: undefined,
})
```
---
## Real visitor IPs for server-rendered pages [#real-visitor-ips-for-server-rendered-pages]
When your web app renders a page on the server, it calls the API itself - so the API sees the **web server's** address, not your visitor's. Every server-rendered request then lands in the same bucket, and 80 requests a minute disappears fast on a busy site.
The API never believes a plain `X-Forwarded-For` header (anyone can type one). Instead, the web server works out the visitor's address, signs it with a key derived from your `CRON_SECRET` - the secret the web app and the API already share - and the API checks that signature before trusting it.
- Set the same `CRON_SECRET` in both apps (you need it there anyway).
- Tell each app how many proxies sit in front of it with `VITNODE_TRUSTED_PROXY_HOPS`. It skips exactly that many hops from the right of `X-Forwarded-For` and ignores everything further left, because a visitor can write whatever they like there. The API reads it too, for browser calls that reach it through your proxy - in a split deployment, set it to the proxies in front of the **API**, and leave it at `0` if the API is only reachable from the web server.
- The API trusts the forwarded address only when the signature matches and is less than 5 minutes old.
- While `CRON_SECRET` is unset or still a published placeholder, nothing is trusted: the API keys every request on the connection address.
| Setup | `VITNODE_TRUSTED_PROXY_HOPS` |
| --------------------------------------------------------- | ---------------------------- |
| Web app exposed directly, no proxy (default) | `0` |
| One reverse proxy (Nginx, Caddy, a load balancer, Vercel) | `1` |
| A CDN in front of your reverse proxy | `2` |
A number higher than the proxies you actually run lets visitors choose their own address again. With the default `0`, every server-rendered request behind a proxy shares the proxy's bucket, so the limit is safe but tight.
Anyone holding `CRON_SECRET` can trigger cron jobs and pick any IP address the rate limiter will believe. Use a long random value and rotate it in both apps together.
---
## `rateLimiter` Options [#ratelimiter-options]
## Learn More [#learn-more]
# Redis (/docs/dev/advanced/redis)
[Redis](https://redis.io) serves as VitNode's shared cache and pub/sub broker across horizontally scaled instances. Redis is optional: without it, VitNode degrades gracefully to in-memory counters and local delivery.
## Quick start [#quick-start]
### 1. Configure Environment Variables [#1-configure-environment-variables]
```bash title=".env"
REDIS_URL=redis://localhost:6379 // [!code ++]
REDIS_PASSWORD=root // [!code ++]
```
### 2. Connect in API Configuration [#2-connect-in-api-configuration]
```ts title="apps/api/src/vitnode.api.config.ts"
import { buildApiConfig } from "@vitnode/core/vitnode.config"
export const vitNodeApiConfig = buildApiConfig({
// [!code ++:5]
redis: process.env.REDIS_URL
? { url: process.env.REDIS_URL, password: process.env.REDIS_PASSWORD }
: undefined,
})
```
---
## What Redis Powers [#what-redis-powers]
| Feature | Without Redis | With Redis |
| :--- | :--- | :--- |
| **API Domain Cache** | No-op (always executes DB query) | Shared key-value store via `c.get("cache")` |
| **Rate Limiter** | In-memory counters per instance | Distributed sliding-window across cluster |
| **WebSocket Realtime** | Single instance only | Cross-instance broadcasts via Redis Pub/Sub |
| **Sessions** | Database queries on every request | Instant cache hits with DB fallback |
---
## Local Development (Docker) [#local-development-docker]
Start a Redis container with Docker Compose:
```yaml title="docker-compose.yml"
services:
redis:
image: redis:7-alpine
restart: always
ports:
- "6379:6379"
command: redis-server --requirepass root
```
---
## Using the Cache in API Routes [#using-the-cache-in-api-routes]
Use `remember` with a key, a TTL in seconds, and a loader to cache expensive database lookups:
```ts
const posts = await c.get("cache").remember(
"featured_posts",
60,
async () => await fetchFeaturedPostsFromDB(),
)
```
See [Cache API data](/docs/dev/cache/api) for invalidation and the full method list.
---
## Verifying Redis in AdminCP [#verifying-redis-in-admincp]
{/* Image prompt: VitNode AdminCP System -> Diagnostics screen at /admin/core/advanced/debug. System health card shows Redis status "Connected" with latency graph, memory usage, and ping metrics. Dark theme, 1440x900. */}
Check Redis connection health anytime under **Core → Advanced → Debug** (`/admin/core/advanced/debug`).
## Learn More [#learn-more]
# AI Actions (/docs/dev/ai/actions)
An **AI action** is one AI feature a plugin offers, such as "write an excerpt", "translate a field" or "suggest tags". The plugin describes the prompt and the input and output shapes. VitNode Core runs it: it picks the model, checks permissions, enforces limits, holds budget, calls the provider, validates the answer and records what it cost.
```ts
const { output } = await c.get('ai').run({
action: EXCERPT_AI_ACTION,
input: { title, content, locale },
})
```
## Why actions exist [#why-actions-exist]
Calling `generateText` with `c.get("ai").model()` works, but every plugin would then need its own answers to the same questions: who may use this, how much can they spend, what happens when the provider times out, and where did the money go?
Actions answer those once, in Core:
- **One place for models.** Admins choose the model, fallback and limits per action in the AdminCP. Plugins never hard-code a model.
- **One place for limits.** Permissions, daily limits, rate limits and concurrency apply to every action the same way.
- **One place for accounting.** Every run and every provider call is recorded with tokens and cost, and charged to the right [budget](/docs/dev/ai/costs-and-limits).
Direct SDK calls with `c.get("ai").model()` bypass budgets, limits and
history. Keep them for internal tooling you fully control.
## Create an action [#create-an-action]
This example adds a "summarize topic" button to a forum plugin's AdminCP.
### Define the action [#define-the-action]
```ts title="plugins/forum/src/api/ai/actions.ts"
import { defineAiAction } from '@vitnode/core/api/lib/ai/action'
import { aiActionRef } from '@vitnode/core/api/lib/ai/registry'
import { checkStaffPermission } from '@vitnode/core/api/lib/check-staff-permission'
import { z } from 'zod'
import { CONFIG_PLUGIN } from '@/const'
export const zodSummarizeTopicSchema = z.object({
title: z.string().trim().min(1).max(255),
content: z.string().trim().min(1).max(50_000),
})
export const summarizeTopicAiAction = defineAiAction({
id: 'topic.summarize',
title: 'ai_actions.@vitnode/forum.topic_summarize.title',
description: 'ai_actions.@vitnode/forum.topic_summarize.description',
icon: 'text-quote',
promptVersion: 1,
permission: { key: 'summarize', defaultGranted: true },
requiredCapabilities: ['text'],
defaults: {
maxInputCharacters: 50_000,
maxOutputTokens: 300,
timeoutMs: 30_000,
},
inputSchema: zodSummarizeTopicSchema,
output: 'text',
outputSchema: z.string().min(1).max(600),
parseText: (text) => text.trim(),
buildPrompt: (input, { instructions }) => ({
system: ['Summarize the forum topic in two plain sentences.', instructions]
.filter(Boolean)
.join('\n'),
prompt: `${input.title}\n\n${input.content}`,
}),
// The AI permission never replaces access to the content itself.
authorize: async ({ c }) =>
await checkStaffPermission(c, {
type: 'admin',
plugin: CONFIG_PLUGIN.pluginId,
module: 'topics',
permission: 'can_view',
}),
})
export const forumAiActions = [summarizeTopicAiAction]
export const SUMMARIZE_TOPIC_AI_ACTION = aiActionRef(
CONFIG_PLUGIN.pluginId,
summarizeTopicAiAction,
)
```
`title` and `description` are message keys. Add their text to the plugin's
messages in the next step.
`defineAiAction()` validates the definition when the module loads. A bad id, a missing `authorize()` or an out-of-range limit throws an `AiActionDefinitionError` at boot, not in production traffic.
### Add the title and description [#add-the-title-and-description]
Add the text to the plugin's messages, under the shared `ai_actions` key and
your plugin id. Every AI page loads `ai_actions` as one namespace, so all
plugins' action names arrive together.
```json title="plugins/forum/src/locales/en.json"
{
"ai_actions": {
"@vitnode/forum": {
"topic_summarize": {
"title": "Summarize topic",
"description": "Writes a two-sentence summary of a forum topic."
}
}
}
}
```
### Register it in the plugin [#register-it-in-the-plugin]
```ts title="plugins/forum/src/config.api.ts"
import { buildApiPlugin } from '@vitnode/core/api/lib/plugin'
import { forumAiActions } from '@/api/ai/actions'
import { adminModule } from '@/api/modules/admin/admin.module'
import { CONFIG_PLUGIN } from '@/const'
export const forumApiPlugin = () =>
buildApiPlugin({
pluginId: CONFIG_PLUGIN.pluginId,
aiActions: forumAiActions, // [!code ++]
modules: [adminModule],
})
```
### Run it from a route [#run-it-from-a-route]
```ts title="plugins/forum/src/api/modules/admin/ai/routes/summarize.route.ts"
import { buildRoute } from '@vitnode/core/api/lib/route'
import { z } from 'zod'
import {
SUMMARIZE_TOPIC_AI_ACTION,
zodSummarizeTopicSchema,
} from '@/api/ai/actions'
import { CONFIG_PLUGIN } from '@/const'
export const summarizeTopicRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
adminStaffPermission: { module: 'topics', permission: 'can_view' },
route: {
method: 'post',
description: 'Summarize a forum topic with the configured AI model.',
path: '/summarize',
request: {
body: {
required: true,
content: { 'application/json': { schema: zodSummarizeTopicSchema } },
},
},
responses: {
200: {
content: {
'application/json': { schema: z.object({ text: z.string() }) },
},
description: 'The summary',
},
403: { description: 'No access to topics or to this AI feature' },
429: { description: 'A personal AI limit was reached' },
502: { description: 'The AI provider failed or answered unusably' },
503: { description: 'AI is switched off or the site budget is used up' },
},
},
handler: async (c) => {
// [!code ++:4]
const { output } = await c.get('ai').run({
action: SUMMARIZE_TOPIC_AI_ACTION,
input: c.req.valid('json'),
})
return c.json({ text: output }, 200)
},
})
```
TypeScript infers `output` as `string` from the action's `outputSchema`. When the run fails, `run()` throws an `AiError` and the route answers with its [error code](#error-codes).
For a complete, real example, read the `@vitnode/blog` plugin: `plugins/blog/src/api/ai/actions.ts` and its `admin/ai` routes.
## Action ids [#action-ids]
An action has a **local id**, unique inside its plugin: lowercase, dot-separated words such as `topic.summarize`.
Core stores it under a **canonical key**, `:`:
```text
@acme/forum:topic.summarize
@vitnode/blog:excerpt.generate
```
Two plugins may both have an `excerpt.generate`; their canonical keys differ. The same canonical key twice throws at boot. Settings, history and limits all use the canonical key.
`aiActionRef(pluginId, action)` builds the canonical key **and** remembers the action's input and output types. Pass it to `run()` and TypeScript checks `input` and infers `output`. A plain string key also works, but its output is `unknown`.
## Title, description and icon [#title-description-and-icon]
Every action appears as a row on **Artificial Intelligence (AI) → Actions**, as a feature on the role form's **Artificial intelligence (AI)** tab and in a member's AI usage. Each of those needs a human name in every language:
- **`title`** (required): the message key of a short name, e.g. `ai_actions.@vitnode/forum.topic_summarize.title` for "Summarize topic".
- **`description`** (required): the message key of one sentence on what it does.
- **`icon`** (optional): a [Lucide](https://lucide.dev/icons) icon name in kebab case, e.g. `languages` or `wand-sparkles`. Without one, the row shows a sparkles icon.
The API returns both keys untranslated. The AdminCP and the member's settings translate them into the page's language. A language without the key falls back to the default language's text. Keep the keys in your plugin's `src/locales/en.json` under `ai_actions` → your plugin id, and translate them like any other [plugin message](/docs/dev/i18n/messages).
A `title` or `description` that is not a message key is shown as written. That
is handy while prototyping, but it never gets translated.
A blank title or description, or an icon that isn't a kebab-case name, throws at boot.
## Prompt versions and instructions [#prompt-versions-and-instructions]
`promptVersion` is stored with every run. Bump it whenever the prompt changes meaningfully, so the history shows which prompt produced which result.
`buildPrompt(input, { instructions })` returns either `{ prompt, system? }` or `{ messages, system? }`. `instructions` is optional extra guidance an admin set for this action in the AdminCP, or `null`. Append it to the system prompt.
## Input and output [#input-and-output]
- **`inputSchema`** (Zod) validates the input before anything else happens. Invalid input fails with `AI_INVALID_INPUT`.
- **`measureInput(input)`** returns the number of characters counted against `maxInputCharacters`. By default every string in the input counts. Override it when only part of the input reaches the prompt. The blog excerpt, for example, counts only the text it extracts from the HTML.
- **`output: "text"`** makes the model return text. `parseText(text, input)` turns it into your output, then `outputSchema` checks it. Throw from `parseText()` to reject an answer.
- **`output: "object"`** makes the model return structured JSON, which `outputSchema` validates. The action must require the `structured-output` capability, and it cannot be streamed.
An empty answer, a `parseText()` that throws or a schema mismatch fails with `AI_INVALID_OUTPUT`. The user is not charged for it.
```ts title="An object action"
export const suggestTagsAiAction = defineAiAction({
id: 'topic.tags',
title: 'ai_actions.@vitnode/forum.topic_tags.title',
description: 'ai_actions.@vitnode/forum.topic_tags.description',
promptVersion: 1,
permission: 'tags',
actors: ['system'],
requiredCapabilities: ['text', 'structured-output'],
defaults: {
maxInputCharacters: 20_000,
maxOutputTokens: 200,
timeoutMs: 30_000,
maxRetries: 1,
},
inputSchema: z.object({ content: z.string().min(1) }),
output: 'object',
outputSchema: z.object({ tags: z.array(z.string().max(32)).max(5) }),
buildPrompt: (input) => ({
system: 'Suggest up to five short, lowercase tags for this forum topic.',
prompt: input.content,
}),
})
export const TOPIC_TAGS_AI_ACTION = aiActionRef(
CONFIG_PLUGIN.pluginId,
suggestTagsAiAction,
)
```
`actors: ["system"]` makes this a background-only action, so it needs no `authorize()`. See [Actors](#actors-users-and-the-system).
### Images [#images]
For a vision action, require `image-input` and pass the image **bytes** as a `file` part. Read them server-side, for example with `c.get("storage").readBytes(key, maxBytes)`, so no private file ever has to be made public for the provider:
```ts
buildPrompt: (input) => ({
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Describe this image in one sentence.' },
{ type: 'file', data: input.image, mediaType: input.mediaType },
],
},
],
}),
```
`maxImages` defaults to `1` for these actions and bounds how many images one run may send. Only models that declare `image-input` are used. See [Model capabilities](/docs/dev/ai#model-capabilities).
## Permissions and authorize() [#permissions-and-authorize]
Every action names a **permission**. Admins grant it per role on the **Artificial intelligence (AI)** tab of the role form. Several actions may share one key. They then share one switch and one daily counter.
```ts
permission: 'summarize' // same as { key: 'summarize', defaultGranted: false }
permission: { key: 'summarize', defaultGranted: true }
```
`defaultGranted` applies while no role has a setting for the key. The canonical permission is `:`. Two actions sharing a key must agree on `defaultGranted`, or boot fails.
`authorize()` is **required** for actions users can run. The AI permission says "this role may use AI summaries". `authorize()` says "this user may read _this_ topic". Both must pass:
```ts
// canReadTopic() is the forum plugin's own access check
authorize: async ({ c, input, userId }) =>
await canReadTopic(c, { topicId: input.topicId, userId }),
```
`resource` is the optional `{ type, id }` the caller passed to `run()`. It is also stored with the run, so history can link back to the content.
## Actors: users and the system [#actors-users-and-the-system]
`actors` decides who may run an action. The server sets the actor; the request never does.
| Actor | Runs with | Charged to |
| :------- | :-------------------------- | :--------------------------------------------- |
| `user` | `c.get("ai").run()` | The site budget and the user's AI points |
| `system` | `c.get("ai").runAsSystem()` | The site budget and the optional system budget |
The default is `["user"]`. `run()` uses the signed-in AdminCP user first, then the site user. Without one, it fails with `AI_UNAUTHORIZED`.
Use `runAsSystem()` only from trusted server code: cron jobs, queue tasks, event listeners. No route should pass a client's request into it.
```ts title="plugins/forum/src/api/cron/tag-topics.cron.ts"
import { isAiError } from '@vitnode/core/api/lib/ai/errors'
import { buildCron } from '@vitnode/core/api/lib/cron'
import { TOPIC_TAGS_AI_ACTION } from '@/api/ai/actions'
export const tagTopicsCron = buildCron({
name: 'tag-new-topics',
description: 'Suggest tags for new forum topics',
schedule: '*/15 * * * *',
handler: async (c) => {
for (const topic of await loadUntaggedTopics(c)) {
try {
const { output } = await c.get('ai').runAsSystem({
action: TOPIC_TAGS_AI_ACTION,
input: { content: topic.content },
resource: { type: 'forum_topic', id: topic.id },
})
await saveTopicTags(c, topic.id, output.tags)
} catch (error) {
// Out of budget: stop now and try again on the next tick.
if (isAiError(error) && error.code === 'AI_BUDGET_EXHAUSTED') return
throw error
}
}
},
})
```
## Defaults and limits [#defaults-and-limits]
`defaults` sets an action's limits. Admins can override most of them per action in the AdminCP. Values outside the allowed range throw at boot.
| Field | Required | Default | Range |
| :------------------- | :------- | :------------------------------- | :-------------- |
| `maxInputCharacters` | Yes | - | 1 – 1,000,000 |
| `maxOutputTokens` | Yes | - | 1 – 200,000 |
| `timeoutMs` | Yes | - | 1,000 – 600,000 |
| `maxRetries` | No | `0` | 0 – 3 |
| `maxSteps` | No | `1` | 1 – 10 |
| `maxImages` | No | `1` with `image-input`, else `0` | 0 – 10 |
| `dailyLimit` | No | `null` (no limit) | 0 – 100,000 |
- `timeoutMs` applies to each attempt.
- `maxRetries` is extra attempts on the same model after a failed provider call.
- `maxSteps` is model round trips inside one attempt (tool loops). Each step is billed as its own call.
- `dailyLimit` counts runs per user per day for the action's permission key. A role's daily limit takes precedence over the admin's action setting, which takes precedence over this default.
- Admins cannot change `maxImages`, because it bounds the budget reserved for images.
Admins can also, per action: switch it off, assign a model, assign a fallback model, and add `instructions`.
## Run options [#run-options]
`run()` and `runAsSystem()` resolve to:
```ts
{
output: Output, // validated by outputSchema
runId: number,
usage: {
chargedPoints: string, // points charged to the user, full precision; "0" for system runs
costKnown: boolean,
modelId: string, // the configured model that answered
},
}
```
You can safely return `usage` to the browser: it never contains prices or provider details.
## What happens during a run [#what-happens-during-a-run]
1. Find the action, check the actor, validate `input`.
2. Check the global AI switch and the action's switch.
3. For users: check the role permission, then `authorize()`.
4. Measure the input and the number of images.
5. Pick the model: the admin's choice, or the first configured model with every required capability. Resolve the optional fallback, skipping an incompatible one.
6. **Reserve** budget for the worst case and check rate and concurrency limits, in one short database transaction.
7. Call the provider outside any transaction. Retry up to `maxRetries` times, then try the fallback model once.
8. Validate the output.
9. **Settle**: charge the real cost and release the rest of the reservation.
10. Emit `ai.run.completed` or `ai.run.failed`.
A canceled run stops immediately. An invalid answer is not retried on the same model, but the fallback model still gets one try. Everything before step 6 costs nothing. [Costs, Points & Budgets](/docs/dev/ai/costs-and-limits) explains steps 6 and 9.
## Stream a text action [#stream-a-text-action]
`c.get("ai").stream()` streams a `text` action for the signed-in user. Object actions cannot be streamed.
```ts
handler: async (c) => {
const { textStream } = await c.get('ai').stream({
action: SUMMARIZE_TOPIC_AI_ACTION,
input: c.req.valid('json'),
signal: c.req.raw.signal, // stop paying when the reader leaves
})
return new Response(textStream.pipeThrough(new TextEncoderStream()), {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
})
}
```
The stream also returns `result`, a promise that resolves to the validated output once the stream ends, and rejects with an `AiError` if the full text is invalid. A stream makes one attempt on the primary model, with no retries and no fallback. If the reader cancels, the run settles as `canceled` and the user is not charged.
## Error codes [#error-codes]
Every refusal and failure is an `AiError` with a stable `code`. Routes answer with this JSON body. Key the browser's messages on `code`, never on the English `message`:
```json
{
"code": "AI_USER_LIMIT_REACHED",
"message": "You used all your AI points for this period.",
"resetsAt": "2026-11-01T00:00:00.000Z",
"runId": 42
}
```
`resetsAt` is set for limit and budget errors. `runId` is set when a run was recorded.
| Code | Status | When |
| :----------------------- | :----- | :---------------------------------------------------------------------- |
| `AI_NOT_CONFIGURED` | 400 | No `ai.models` are configured. |
| `AI_INVALID_INPUT` | 400 | The input failed `inputSchema`, or an object action was streamed. |
| `AI_INPUT_TOO_LARGE` | 413 | The input exceeds `maxInputCharacters` or `maxImages`. |
| `AI_DISABLED` | 503 | An admin switched AI off for the whole site. |
| `AI_ACTION_DISABLED` | 403 | An admin switched this action off. |
| `AI_ACTION_UNKNOWN` | 404 | No installed plugin registers this key. |
| `AI_UNAUTHORIZED` | 403 | Not signed in, no AI permission, `authorize()` refused, or wrong actor. |
| `AI_MODEL_INCOMPATIBLE` | 409 | No model has the required capabilities. |
| `AI_PRICING_MISSING` | 409 | The model has no pricing while a budget cap applies. |
| `AI_USER_LIMIT_REACHED` | 429 | The user's monthly AI points are used up. |
| `AI_DAILY_LIMIT_REACHED` | 429 | The user reached today's limit for this permission. |
| `AI_RATE_LIMITED` | 429 | Too many runs started in the last minute. |
| `AI_CONCURRENCY_LIMITED` | 429 | Too many runs in flight. |
| `AI_BUDGET_EXHAUSTED` | 503 | The site budget (or the system budget) for this month is used up. |
| `AI_DUPLICATE_REQUEST` | 409 | The `idempotencyKey` was already used. |
| `AI_PROVIDER_FAILED` | 502 | The provider returned an error. |
| `AI_INVALID_OUTPUT` | 502 | The answer was empty or failed `parseText()` / `outputSchema`. |
| `AI_TIMEOUT` | 504 | The provider took longer than `timeoutMs`. |
| `AI_CANCELED` | 408 | The `signal` aborted the run. |
Import the helpers from `@vitnode/core/api/lib/ai/errors`: `AiError`, `isAiError()`, `AI_ERROR_CODES` and `AI_ERROR_STATUS`.
## Events [#events]
Each run that got past the reservation emits one event after it settles:
| Event | Payload |
| :----------------- | :------------------------------------------------------- |
| `ai.run.completed` | `actionKey`, `actorType`, `runId`, `userId` |
| `ai.run.failed` | `actionKey`, `actorType`, `errorCode`, `runId`, `userId` |
`userId` is `null` for system runs. Refusals before the reservation, such as a missing permission or a reached limit, emit nothing. A listener that throws never breaks the run.
```ts title="plugins/forum/src/api/lib/ai-events.ts"
import { buildEventListener } from '@vitnode/core/api/lib/events'
export const aiFailureListener = buildEventListener({
event: 'ai.run.failed',
name: 'log-ai-failures',
handler: async (c, payload) => {
if (!payload.actionKey.startsWith('@acme/forum:')) return
await c
.get('log')
.warn(`AI run ${payload.runId} failed: ${payload.errorCode}`)
},
})
```
See [Events](/docs/dev/events) for registering listeners.
## Learn more [#learn-more]
# AdminCP & Account Usage (/docs/dev/ai/admin)
The AdminCP **Artificial Intelligence (AI)** section (`/admin/core/ai`) is where admins decide how much the site may spend on AI, which models run which features, and who may use them. Members see their own allowance at **Settings → AI usage** (`/settings/ai`).
| Page | URL | What it is for |
| :--------------------------------------- | :----------------------------- | :----------------------------------------------------------- |
| Overview | `/admin/core/ai` | Spend against the budget, a forecast, trends and breakdowns. |
| Settings sheet | `/admin/core/ai?settings=open` | Global switch, budgets, rate limits, retention, ALT text. |
| Actions | `/admin/core/ai/actions` | Every registered AI feature: switch, model, limits. |
| History | `/admin/core/ai/history` | Every run: who, which model, tokens and cost. |
| Roles → Artificial intelligence (AI) tab | `/admin/core/users/roles` | A role's AI permissions, monthly points and daily limits. |
| User → AI card | `/admin/core/users/{id}` | One member's exception: blocked, unlimited or own points. |
| AI usage | `/settings/ai` (member) | A member's own points and what each AI feature cost them. |
Models and their prices are not edited in the AdminCP. They live in `vitnode.api.config.ts`, next to the provider keys they need. See [Model pricing](/docs/dev/ai/costs-and-limits#model-pricing).
## First-time setup [#first-time-setup]
[AI Setup](/docs/dev/ai) covers the models and prices you set in code. Everything after that happens in the AdminCP.
### Set a monthly site budget [#set-a-monthly-site-budget]
Open **Artificial Intelligence (AI) → Overview**, click **Settings** and, under **Budget**, set **Monthly site budget** in USD. Without it, nothing caps what the whole site spends. Automatic ALT text refuses to turn on until a budget exists.
### Give every model a price [#give-every-model-a-price]
Add `pricing` to each model in `vitnode.api.config.ts`. A model without a price makes every run's cost unknown, and under a budget cap it is refused with `AI_PRICING_MISSING`.
### Grant roles access and points [#grant-roles-access-and-points]
Open **Users → Roles**, edit a role and switch to the **Artificial intelligence (AI)** tab. Give the role a monthly allowance (or **Unlimited points**) and allow the features it should use. The default monthly points are `0`, so only root roles can run priced features until you do this.
### Optionally turn on automatic ALT text [#optionally-turn-on-automatic-alt-text]
In the same **Settings** sheet, under **ALT text**, switch on **Write ALT text automatically**. You need a vision model (`image-input`) with pricing and a running cron. See [Automatic Image ALT Text](/docs/dev/ai/automatic-alt).
## Staff permissions [#staff-permissions]
The AI screens use two staff permissions in the `ai` module of `@vitnode/core`. Grant them in **Staff → Administrators** like any other [staff permission](/docs/dev/working-with-users/staff-permissions).
| Permission | Allows |
| :-------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
| `ai:can_view` | Open every AI page, read overview, actions, history and settings, and see a member's AI exception. |
| `ai:can_manage` | Change settings and action settings, the **Artificial intelligence (AI)** tab on roles and a member's exception. Depends on `can_view`. |
Without `can_manage`, the pages are read-only: forms are disabled, write buttons are hidden and the role form has no **Artificial intelligence (AI)** tab.
`ai:can_manage` lets an admin _configure_ AI. Whether that admin may _use_ a
feature, such as an excerpt button or Quick Ask, depends on the **Artificial
intelligence (AI)** tab of their roles, the same as for everyone else.
## Settings [#settings]
The **Settings** button on **Artificial Intelligence (AI) → Overview** opens a sheet with the rules every run follows. Changes apply from the next run, after you press **Save settings**. The sheet has its own link, `/admin/core/ai?settings=open`, so you can send it to another admin.
Switch **AI features** off and the rest of the sheet hides, because none of it applies while AI is off.
| Setting | Default | Meaning |
| :----------------------- | :--------- | :----------------------------------------------------------------------------------- |
| AI features | On | The global switch. Off refuses every new run, for members and background jobs alike. |
| Monthly site budget | No limit | USD the whole site may spend per month. When it runs out, every AI feature pauses. |
| Background jobs budget | No limit | An extra USD cap for system work such as ALT text. It counts inside the site budget. |
| Default monthly points | `0` | A member's allowance when no role gives more. 1 point = 0.001 USD. |
| Requests per minute | `10` | Runs one member may start per minute. |
| Parallel runs per member | `2` | Runs one member may have in flight. |
| Parallel background runs | `2` | System runs in flight across the site. |
| Keep history for | `365` days | How long run records are kept (7 – 3,650). |
Budgets and allowances are calendar months in the **site's time zone** (the default language's). The settings sheet shows which one.
Budgets are in **USD**, because that is what providers bill. Members are charged in **AI points**. [Costs, Points & Budgets](/docs/dev/ai/costs-and-limits) explains the conversion and what happens on failure.
## Actions [#actions]
**Artificial Intelligence (AI) → Actions** lists every [AI action](/docs/dev/ai/actions) installed plugins register, grouped by the plugin it comes from. Each row shows the action's icon, title and description in your language. Badges appear only when something differs from the default: a chosen model, a daily limit, or a warning that no configured model can run the action. Search by title, description or key, or filter by plugin.
The switch on each row turns the action on or off straight away. A disabled action refuses to run for anyone, and its row fades.
The **Configure** button (the gear) opens a sheet with the rest:
- **Enabled**: the same switch as on the row.
- **Model** and **Fallback model**: only models with every capability the action needs are listed. **Site default** is the first compatible model.
- **Editorial instructions**: up to 2,000 characters appended to the prompt, e.g. house style.
- **Limits**: runs per member per day, max input characters, max output tokens, retries, steps and timeout. Each one can fall back to the plugin's default.
Press **Save action** to apply the changes from the next run. An action marked _No compatible model, so it can't run_ needs a model with the right capabilities in `vitnode.api.config.ts` first.
## AI access [#ai-access]
A member's run must pass four separate controls. They answer different questions, so check the right one when somebody is refused:
| Control | Question | Where | Error when refused |
| :------------- | :----------------------------------------- | :---------------------------------------------------------------- | :----------------------- |
| Permission | May this role use this feature at all? | Role form → Artificial intelligence (AI) tab | `AI_UNAUTHORIZED` |
| Monthly points | How much may this member spend this month? | Role form → Artificial intelligence (AI) tab, or a user exception | `AI_USER_LIMIT_REACHED` |
| Daily count | How often may this feature run per day? | Role form → Artificial intelligence (AI) tab, or the action limit | `AI_DAILY_LIMIT_REACHED` |
| Site spending | Can the whole site still afford it? | Settings → Monthly site budget | `AI_BUDGET_EXHAUSTED` |
The permission does not replace content access. An excerpt action still checks that the admin may edit the article.
### Roles [#roles]
Creating or editing a role in **Users → Roles** shows an **Artificial intelligence (AI)** tab to admins with `ai:can_manage`. It sets:
- **Allowance**: monthly points, **Unlimited points**, or the site default.
- **Features**: one row per AI permission, showing the icon, title and description of every action that uses it. Pick _Default_, _Allow_ or _Deny_; _Allow_ opens an optional daily limit in the same row. Icons turn grey when the role can't use the feature.
The AI access is saved together with the role, right after it.
A member with several roles gets the **largest** allowance (never the sum), the broadest permissions and the largest daily limit. Root roles always have access and no point cap, but the site budget still applies to them.
### User exceptions [#user-exceptions]
The **AI access** card on a member's AdminCP page (**Users → a member**) gives them their own rule, ahead of their roles: **Block AI features**, **Unlimited points**, or a personal monthly allowance. It replaces what their roles give until you remove it.
## History [#history]
**Artificial Intelligence (AI) → History** lists every run with its action, status, origin (Members or Background jobs), model, cost and points. Filter by status, origin, action or model. **Details** shows:
- tokens in and out, cost, cost source and what was charged or reserved;
- every provider call, with retries and fallbacks listed as separate calls;
- cost corrections made by reconciliation;
- whether an editor accepted the suggestion (tracking only, never billing).
History records what a run took and what it cost. It never keeps the prompt,
the input text or the generated text. The same is true on the member's page.
Run statuses: `reserved`, `running`, `succeeded`, `failed`, `canceled` and `uncertain` (the process died mid-call; the maintenance cron settles it). The `ai-maintenance` cron prunes history older than **Keep history for**.
## Overview [#overview]
**Artificial Intelligence (AI) → Overview** answers two questions: _will we stay inside the budget?_ and _where does the money go?_
- **Date range** (top right): This month (the default), Last month, the last 7, 30 or 90 days, or any range picked on the calendar. Every number below follows it, and each is compared with the range before it: this month so far with the same days of last month, a whole month with the month before, anything else with the same number of days just before it.
- **Budget**: what the month has charged to the budget and the share of the limit used, with a mark where an even pace would be today. The bar turns amber at 80% of the limit (or when the forecast goes over it) and red at 90%. The arrows next to the month's name step through earlier months.
- For this month you get a forecast: what is already charged, plus the selected range's daily average for every day left. Pick **Last 7 days** to forecast from recent days, or **Last 90 days** for a steadier average. Going over turns the note amber.
- For an earlier month you get how it closed, and what a month would cost at the selected range's pace.
- **Six numbers**: spend, cost per run, runs, failure rate, tokens and the share of runs with a known cost, each with its change. Click one to chart it.
- **Chart**: for spend, the month's running total against the budget line, with last month dashed and the forecast to the month's end. For the other numbers, a running total (or a running average for rates) across the range, against the range before it.
- **Where it goes**: the picked number by action or by model, ranked, with a mark for the range before. A run whose model has no price shows **Unknown**, never `$0`, and the page tells you how many runs that is.
The range and the budget month live in the URL (`?range=7d`, `?from=2026-09-12&to=2026-09-25`, `?month=2026-09`), so a view can be shared with another admin. Days follow the site's time zone, shown in the **Settings** sheet.
## Member page: /settings/ai [#member-page-settingsai]
Members who can use at least one AI feature get **Settings → AI usage**. Members without any feature, or on a site with AI switched off, don't see it in the settings menu, and opening the URL shows a not-found page. The page reads only the member's own data:
- **Monthly points**: points left, the share used and the renewal date. The bar turns amber at 80% used and red at 90%. Unused points do not roll over.
- **Your AI features**: one row per feature they may use, with today's runs against its daily limit and the points it cost this month. Opening a row loads that feature's latest runs with their status and points. Prompts and generated text are never stored.
The same points bar sits in its own **AI points** group at the bottom of **Settings → Overview**, with a link to the full page.
A notice under the points bar explains why AI may not be working, checked in this order:
| Notice | When |
| :-------------------------- | :------------------------------------------------------------------------------- |
| AI features are paused | The site budget is used up. The message says it is _not_ the member's allowance. |
| You've used all your points | The member's allowance is used up. Shows the reset date. |
| Over 80% used | Used plus reserved points reached 80% of the allowance. |
## API routes [#api-routes]
Every screen is backed by an API route under `@vitnode/core`. Use them with [`fetcher`](/docs/dev/fetcher), e.g. `fetcher({ plugin: '@vitnode/core', method: 'get', module: 'admin/ai', path: '/overview' })`.
| Route | Staff permission | Purpose |
| :-------------------------------------------- | :--------------- | :-------------------------------------------------- |
| `GET /admin/ai/overview` | `ai:can_view` | Overview for `range` or `from`/`to`, budget `month` |
| `GET /admin/ai/settings`, `PUT …/settings` | view / manage | Site-wide settings |
| `GET /admin/ai/models` | `ai:can_view` | Configured models (read only) |
| `GET /admin/ai/actions`, `PUT …/actions` | view / manage | Action list and per-action settings |
| `GET /admin/ai/access/roles?roleId=` | `ai:can_view` | AI permissions and one role's access |
| `PUT /admin/ai/access/roles` | `ai:can_manage` | A role's allowance and permissions |
| `GET /admin/ai/access/users/{userId}` | `ai:can_view` | One member's exception |
| `PUT`, `DELETE /admin/ai/access/users` | `ai:can_manage` | A user exception |
| `GET /admin/ai/history`, `GET …/history/{id}` | `ai:can_view` | Runs and one run's details |
| `GET /ai/usage` | signed in | The member's own allowance, notices and features |
| `GET /ai/history?action=&limit=` | signed in | The member's own runs, optionally for one feature |
## Learn more [#learn-more]
# Automatic Image ALT Text (/docs/dev/ai/automatic-alt)
Automatic ALT text describes images in Core Files with a vision model, once per image, and translates that description into each site language. It runs in the background as the **system actor**, so it never spends a member's AI points. It is **off by default**.
```tsx title="plugins/blog/src/pages/post-page.tsx"
import { resolveImageAlt } from '@vitnode/core/lib/files/resolve-alt'
;
```
## Turn it on [#turn-it-on]
### Configure a vision model with pricing [#configure-a-vision-model-with-pricing]
At least one model must declare `image-input` (the description) and `text` (the translations), and both need [pricing](/docs/dev/ai#model-pricing). The feature always runs under a budget, so VitNode refuses an unpriced model with `AI_PRICING_MISSING`.
```ts title="apps/api/src/vitnode.api.config.ts"
{
id: 'default',
name: 'GPT-4o Mini',
model: openai('gpt-4o-mini'),
capabilities: ['text', 'image-input', 'structured-output', 'streaming'], // [!code highlight]
pricing: { rates: { inputPerMillion: '0.15', outputPerMillion: '0.60' } },
}
```
### Set a site budget [#set-a-site-budget]
On **Artificial Intelligence (AI) → Overview**, click **Settings** and, under **Budget**, set **Monthly site budget**. Automatic ALT cannot be switched on without one: the API answers `400` and the form shows _"Set a monthly site budget before turning on automatic ALT text."_ You can also set **Background jobs budget** to cap system work separately. It counts inside the site budget.
### Switch it on [#switch-it-on]
In the same **Settings** sheet, under **ALT text**:
- **Write ALT text automatically.** Turns the feature on.
- **Images queued per sweep.** How many images one hourly sweep queues (default `10`, max `100`).
The job writes ALT text in every language in `core_languages`. Add a language and the next sweep fills it in.
### Make sure the cron runs [#make-sure-the-cron-runs]
Nothing happens until the host runs VitNode's cron. See [Running the cron](#running-the-cron).
## How it works [#how-it-works]
1. **Upload.** A new JPEG, PNG, GIF or WebP in `core_files` is queued right away when the feature is on. A failure to queue never fails the upload.
2. **Sweep.** The `ai-alt-detect` cron (hourly) looks for images still missing ALT in a configured language and queues them.
3. **Work.** The `ai-queue` cron (every minute) takes up to **2** queued images and describes them.
4. **Describe once.** The image gets one base description in English, per file fingerprint.
5. **Translate.** Each missing language gets a translation of that base description. English uses it as-is.
6. **Write.** Each language goes to `core_files_alt`, but only if no person wrote it and the file is still the same version.
7. **Notify.** `files.alt.updated` is emitted with the languages that were written.
### One description per image, translations per language [#one-description-per-image-translations-per-language]
The base description is stored in `core_files_alt_analysis`, keyed by the file and its **fingerprint** (SHA-256 of the stored bytes). When you add a language later, the sweep notices the gap and only the translation runs. The image does not go back to the vision model.
When a file's bytes change, its fingerprint changes. AI texts written for the old fingerprint count as missing, so the image is described again. The file ALT API marks such texts as `stale` until then.
### People always win [#people-always-win]
The job never overwrites a row with `origin: "human"`, whether on a new run, on a retry or after the file changes. That includes an **empty** human text: an empty string means "a person decided this image needs no description", which is different from no row at all.
To let the job write a language again, delete that language's ALT row.
### Per-file policy [#per-file-policy]
Each file has an `altPolicy`:
| Policy | Behavior |
| :---------- | :----------------------------------------------------------------------- |
| `automatic` | Default. The job may describe the image. |
| `manual` | The job skips it. People write ALT by hand. |
| `disabled` | The job skips it, and the file is never sent to an external AI provider. |
The job checks the policy again at write time, so a policy changed mid-run still wins.
### The sweep [#the-sweep]
The sweep walks `core_files` in id order from a stored cursor, at most 200 files per run, and queues up to **Images queued per sweep** of them. When it reaches the end, it wraps to the start, so it revisits every image and finds any new language or missed upload.
Queuing is deduplicated: a file that is already pending or processing in the queue is not queued twice.
### Budget waits are not failures [#budget-waits-are-not-failures]
When the site budget, the background jobs budget or the global switch stops a run, the task is **deferred**, not failed. It goes back to `pending` without spending a retry, with the file's state set to `waiting_budget`. It wakes at the start of the next budget month or in one hour, whichever comes first. Raise the budget mid-month and work resumes within the hour.
Other failures (provider error, invalid output, timeout) use the normal queue retries: 3 attempts, then the file's state is `failed`. The next sweep wrap queues it again.
### Its own queue [#its-own-queue]
ALT tasks run on a dedicated `ai` queue. The general `process-queue` worker skips it, and `ai-queue` processes it under its own lock with a batch of 2. A slow vision model never delays e-mails or other queue work.
Each task has a 15-minute lease. A worker that dies mid-task leaves it `processing`; once the lease expires it goes back to `pending` (the attempt is spent), or to `failed` when no attempts are left. The AI run it started is settled as `uncertain` by `ai-maintenance` and charged at its reservation.
Results are saved as soon as they exist, and a retry reuses the stored base
description. A second worker waits while another one is describing the same
image version. But a crash mid-call can still repeat one provider call: the
one that was in flight.
### Image bytes stay private [#image-bytes-stay-private]
The job reads the image **server-side** and sends bytes to the provider, never a URL:
- through the storage adapter's optional `read(key, { maxBytes })` method (the Local adapter has one);
- otherwise by a bounded server-side `fetch()` of the adapter's URL.
Files over 20 MB are skipped. With `sharp` installed, the copy sent to the provider is resized to at most 1024 px and re-encoded as WebP. A private file never has to be made public for the AI provider.
## Render the ALT text [#render-the-alt-text]
For images that have ALT text, Content Engine file descriptors carry it as `alts`, a map of language code to text:
```ts
{
id: 42,
name: 'bike.jpg',
url: 'https://example.com/uploads/bike.jpg',
mimeType: 'image/jpeg',
alts: { en: 'A red bicycle leaning on a brick wall', pl: 'Czerwony rower oparty o ceglaną ścianę' },
// ...
}
```
`resolveImageAlt()` from `@vitnode/core/lib/files/resolve-alt` picks the text for one place the image appears, in this order:
| Order | Source | Result |
| :---- | :--------------------------- | :------------------------------------------------------------- |
| 1 | `occurrence.decorative` | `""`, so screen readers skip the image. `source: "decorative"` |
| 2 | `occurrence.alt` (non-empty) | That text. `source: "occurrence"` |
| 3 | `alts[locale]` | The file's ALT, even an intentional `""`. `source: "file"` |
| 4 | `alts[fallbackLocale]` | First match in `fallbackLocales`. `source: "fallback"` |
| 5 | Nothing | `""` with `source: "missing"`. Flag it to editors. |
The article's own ALT text always wins over the file's default. The blog's post page (`plugins/blog/src/pages/post-page.tsx`) is a complete example.
## Running the cron [#running-the-cron]
ALT text depends on three cron jobs registered by `@vitnode/core`:
| Job | Schedule | Does |
| :--------------- | :------------- | :--------------------------------------------------- |
| `ai-queue` | `* * * * *` | Works the `ai` queue, 2 tasks per run. |
| `ai-alt-detect` | `0 * * * *` | The sweep: queues images missing ALT text. |
| `ai-maintenance` | `*/10 * * * *` | Settles dead runs, reconciles costs, prunes history. |
Registering a cron job does not run it. The host must call VitNode's cron endpoint, in one of two ways.
**In-process.** On a long-running server, register the Node CRON adapter. It calls the endpoint every minute:
```ts title="apps/api/src/vitnode.api.config.ts"
import { NodeCronAdapter } from '@vitnode/node-cron'
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
cron: NodeCronAdapter(), // [!code ++]
// ...
})
```
**External scheduler.** On serverless hosting nothing stays alive to hold a timer, so an external scheduler must call the endpoint, ideally once a minute:
```bash
curl -X POST https://example.com/api/@vitnode/core/cron \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $CRON_SECRET"
```
Set a real `CRON_SECRET`. Each call runs every job whose schedule came due since its last run. A slower tick still runs the hourly sweep; it just works the queue less often (2 images per tick). Check **Last Run** at `/admin/core/advanced/cron`. See [Cron over REST](/docs/dev/cron/rest-api) and [Node CRON adapter](/docs/dev/cron/node-cron).
## Edit ALT text through the API [#edit-alt-text-through-the-api]
The file ALT routes live in the `admin/files` module and use the `files` staff permissions:
| Route | Permission | Purpose |
| :--------------------------------- | :------------------- | :--------------------------------------------------------------------- |
| `GET /admin/files/{id}/alt` | `files:can_view` | ALT per site language, with `origin` and `stale`. |
| `PUT /admin/files/{id}/alt` | `files:can_edit_alt` | Set one language as a human text (`""` allowed). |
| `DELETE /admin/files/{id}/alt` | `files:can_edit_alt` | Remove one language (`?languageCode=`), so the job may write it again. |
| `PUT /admin/files/{id}/alt-policy` | `files:can_edit_alt` | Set `automatic`, `manual` or `disabled`. |
```ts
await fetcher({
plugin: '@vitnode/core',
method: 'put',
module: 'admin/files',
path: '/{id}/alt',
args: {
params: { id: 42 },
body: { languageCode: 'pl', text: 'Czerwony rower' },
},
})
```
## React to new ALT text [#react-to-new-alt-text]
`files.alt.updated` fires when the job (`origin: "ai"`) or a person (`origin: "human"`) writes a file's default ALT:
```ts title="plugins/gallery/src/api/lib/events.ts"
import { buildEventListener } from '@vitnode/core/api/lib/events'
export const altUpdatedListener = buildEventListener({
event: 'files.alt.updated',
name: 'refresh-gallery-alt',
handler: async (c, { fileId, languageCodes, origin }) => {
await c
.get('log')
.info(`ALT for file ${fileId} (${languageCodes.join(', ')}) by ${origin}`)
},
})
```
See [Built-in events](/docs/dev/events/built-in-events#ai-events).
## Limits and gotchas [#limits-and-gotchas]
- **Images only.** The job reads `image/jpeg`, `image/png`, `image/gif` and `image/webp`. It never analyses other files.
- **Base language is English.** Other languages are translations of the English description.
- **Legacy `metadata.alt` is not migrated.** An ALT a plugin stored in the free-form `core_files.metadata` column has no language, and VitNode does not guess one. The job treats those images as missing ALT text. Copy such values into `core_files_alt` yourself with `PUT /admin/files/{id}/alt` if you know their language.
- **Cost.** The **Automatic ALT text** card on **Artificial Intelligence (AI) → Overview** shows images described, translations and the known cost per image and per translation.
## Learn more [#learn-more]
# Costs, Points & Budgets (/docs/dev/ai/costs-and-limits)
Every [AI action](/docs/dev/ai/actions) run is priced in **USD** and charged to the site's budget. Runs started by users are also charged in **AI points**. VitNode reserves the worst case before calling a provider, then settles the real cost afterwards. Users pay only for results they receive.
```text
1,000 input tokens × $3 / 1M = $0.003
200 output tokens × $15 / 1M = $0.003
total = $0.006 → 6 AI points
```
## Usage and cost of a call [#usage-and-cost-of-a-call]
Each provider call is stored with its token usage and its cost:
```ts
interface AiUsage {
inputTokens: number | null
outputTokens: number | null
cacheReadTokens: number | null
cacheWriteTokens: number | null
reasoningTokens: number | null // already part of outputTokens
}
type AiCost =
| { source: 'provider'; amountUsd: string }
| { source: 'pricing'; amountUsd: string; pricingVersion: string }
| { source: 'unknown'; amountUsd: null; reason: string }
```
Amounts are decimal strings with 12 decimal places, stored as `numeric(24, 12)` in PostgreSQL. Money never passes through JavaScript floats, so a thousand tiny calls add up exactly.
### Unknown is not zero [#unknown-is-not-zero]
`null` means "nobody told us". `0` means "we know it was zero". VitNode keeps them apart everywhere:
- A provider that does not report token usage gives `null` tokens and an `unknown` cost.
- A model without pricing gives an `unknown` cost, with the reason `"no pricing"`.
- An `unknown` cost is never shown or summed as `$0`. When it must be charged, it is charged as the **full reservation**: the most the call could have cost.
## How cost is resolved [#how-cost-is-resolved]
For every call, VitNode takes the first of these that is available:
1. **Provider-reported cost**: what the provider says it billed (AI Gateway, OpenRouter).
2. **Pricing**: token usage × the `pricing` of the model in `vitnode.api.config.ts`.
3. **Unknown**: never zero.
Each estimated call stores the pricing version it used (`config:` of the price). Changing a price later never rewrites history.
## Model pricing [#model-pricing]
Every model sets its own price in `vitnode.api.config.ts`. It is the only place prices live. There is no price editor in the AdminCP and no automatic sync, so what you deploy is what VitNode charges. A price that isn't a decimal string stops the server at boot instead of quietly turning every cost into `unknown`.
```ts title="apps/api/src/vitnode.api.config.ts"
ai: {
models: [
{
id: 'default',
name: 'Claude Sonnet 5',
model: 'anthropic/claude-sonnet-5',
capabilities: ['text', 'image-input', 'structured-output', 'streaming'],
// [!code ++:3]
pricing: {
rates: { inputPerMillion: '3', outputPerMillion: '15' },
},
},
],
},
```
Prices are USD **per one million tokens**, as decimal strings. The full shape:
```ts title="apps/api/src/vitnode.api.config.ts"
pricing: {
rates: {
inputPerMillion: '3',
outputPerMillion: '15',
cacheReadPerMillion: '0.30',
cacheWritePerMillion: '3.75',
},
// [!code ++:6]
tiers: [
{
aboveInputTokens: 200_000,
rates: { inputPerMillion: '6', outputPerMillion: '22.5' },
},
],
}
```
How the rates apply:
- **Cache tokens** are priced separately from uncached input. If a call reports cache tokens but the pricing has no cache rate, the cost becomes `unknown` rather than quietly cheaper.
- **Tiers** price long-context calls. When a call's input tokens exceed `aboveInputTokens`, the **whole call** uses that tier's rates. A tier's `rates` replace the base rates entirely, so repeat the cache prices inside the tier if the provider charges them.
- **Reasoning tokens** are already part of `outputTokens`. They are stored for display, never billed twice.
- **Flat units** (`perRequest`, `perImage`) are added on top of the token cost.
Here is a worked example with the pricing above, for 10,000 input tokens (2,000 cache reads, 1,000 cache writes) and 500 output tokens:
```text
7,000 uncached × $3 / 1M = $0.021
2,000 cache read × $0.30 / 1M = $0.0006
1,000 cache write × $3.75 / 1M = $0.00375
500 output × $15 / 1M = $0.0075
total = $0.03285 → 32.85 AI points
```
### Gateway vs direct pricing [#gateway-vs-direct-pricing]
`pricing` is the price **on this connection**. The AI Gateway and the direct provider are different connections, and they can bill differently. Set the price you actually pay.
| Connection | Reports its cost? | What VitNode needs |
| :------------------------------------------------ | :-------------------------- | :--------------------------- |
| AI Gateway id, e.g. `'anthropic/claude-sonnet-5'` | Often, in response metadata | `pricing` for reservations |
| OpenRouter-compatible provider | Yes, `usage.cost` | `pricing` for reservations |
| Direct provider, e.g. `openai('gpt-4o-mini')` | No | `pricing` for every estimate |
When a provider changes its prices, update `pricing` and deploy. Runs already charged keep the price version they were charged at.
The reservation happens _before_ the call, so it can only use pricing. A
gateway model that reports its cost but has no pricing at all is still refused
with `AI_PRICING_MISSING` while a budget cap applies.
### Provider-reported cost [#provider-reported-cost]
Provider adapters read cost and request ids from a provider's response metadata. The AI Gateway and OpenRouter adapters are built in. Every other provider reports no cost, so pricing estimates it.
Add an adapter for another provider that reports its cost:
```ts title="apps/api/src/vitnode.api.config.ts"
import type { AiProviderAdapter } from '@vitnode/core/api/lib/ai/usage-cost'
import { decimalFromNumber } from '@vitnode/core/api/lib/ai/decimal'
const acmeAdapter: AiProviderAdapter = {
id: 'acme',
matches: (providerId) => providerId.startsWith('acme'),
reportedCost: (metadata) => {
const cost = metadata?.acme?.costUsd
return typeof cost === 'number' ? decimalFromNumber(cost) : null
},
requestId: ({ responseId }) => responseId ?? null,
}
export const vitNodeApiConfig = buildApiConfig({
ai: {
models: [/* ... */],
providerAdapters: [acmeAdapter], // [!code ++]
},
})
```
`matches()` receives the model's provider id: `"gateway"` for AI Gateway string ids, otherwise the SDK model's `provider`. Your adapters are checked before the built-in ones. An optional `lookupCost(requestId)` lets reconciliation ask for the billed amount later.
## Runs with several calls [#runs-with-several-calls]
One run can make several provider calls: a retry, a fallback model, or extra steps in a tool loop. Each one is stored as its own call with its own usage and cost.
The run's cost is the sum of its calls, computed once at settlement. It is **known** only when every call's cost is known. If any call is unknown, the run is unknown and is charged as described in [Who pays what](#who-pays-what).
## Reconciliation and adjustments [#reconciliation-and-adjustments]
Some providers report the billed amount only after the response. The AI Gateway, for example, can look a generation up by its id. Reconciliation revisits recent calls whose cost was estimated or unknown, asks the provider adapter's `lookupCost()`, and replaces the cost with the billed amount.
Every correction:
- writes an **audit row** with the previous cost, the new cost and the difference;
- happens **once** per call, so a second attempt changes nothing;
- moves the budgets by the **difference** only, so nothing is counted twice;
- adjusts the user's points only if the user was charged for that run.
A provider-reported cost is final and is never reconciled.
## AI points [#ai-points]
Users are charged in **AI points**, not dollars:
> **1 AI point = 0.001 USD** of API cost (conversion version 1)
| API cost | AI points |
| :------- | :-------- |
| $0.0003 | 0.3 |
| $0.003 | 3 |
| $0.012 | 12 |
| $1 | 1,000 |
- Points are an internal usage unit. Nobody buys them.
- Points keep full precision when stored and charged. The UI shows at most one decimal, and `<0.1` for tiny positive amounts.
- The conversion version is stored on every run. A future rate change becomes a new version, and old runs keep theirs.
## Budgets [#budgets]
Every run holds room in each budget that applies to it:
| Budget | Unit | Applies to | Limit set by | On overflow |
| :------------ | :----- | :---------- | :------------------------------------ | :----------------------- |
| Site (global) | USD | Every run | Monthly site budget (`null` = no cap) | `AI_BUDGET_EXHAUSTED` |
| System | USD | System runs | Optional system budget (`null` = off) | `AI_BUDGET_EXHAUSTED` |
| User | Points | User runs | Role policies and user overrides | `AI_USER_LIMIT_REACHED` |
| Daily | Runs | User runs | Daily limit per permission | `AI_DAILY_LIMIT_REACHED` |
The system budget is an extra cap on background work, such as automatic image ALT text. System runs never touch a user's points or daily counters, but they still count against the site budget.
Budgets are calendar periods in the **site's time zone** (the default language's time zone). A monthly budget resets at local midnight on the 1st, wherever the server runs. Limit errors include `resetsAt`.
## Reservations [#reservations]
Before any provider call, VitNode **reserves** the most the run could possibly cost:
- every attempt (`1 + maxRetries`), every step (`maxSteps`) and the fallback model;
- each at the model's dearest input rate and the highest tier the input could reach;
- the full `maxOutputTokens` for every call;
- the prompt measured in UTF-8 bytes, plus `imageInputTokens` per image. A prompt never has more tokens than bytes.
The reservation is a conservative upper bound. Settlement charges the real cost and releases the rest.
How it stays correct with many API instances:
- Reserving is **one short database transaction**. It locks the budget rows, checks the rate, concurrency and every cap, then writes the run and its holds.
- Budget rows are always locked in the **same order** (by scope key), so two runs can never deadlock.
- **No transaction is open during the provider call.** A slow model never holds a database lock.
- Settlement is **idempotent**. A run settles exactly once, and a second settlement changes nothing.
## Who pays what [#who-pays-what]
| Outcome | Site budget (USD) | User points and daily count |
| :----------------------------------------- | :-------------------------------- | :--------------------------------- |
| Valid result delivered, cost known | Real cost | Real cost in points, +1 run |
| Valid result delivered, cost unknown | Full reservation | Full reservation in points, +1 run |
| Retry: a lost attempt, then a valid answer | Full reservation (unknown) | Only the answering call, +1 run |
| Provider returned an error | Real cost (usually $0) | Nothing |
| Invalid output (empty, failed validation) | Real cost, since tokens were used | Nothing |
| Timeout or canceled | Full reservation (unknown) | Nothing |
| Process died mid-call (lease expired) | Full reservation (unknown) | Nothing |
**A user is charged only when a valid result is delivered.** When the provider fails, times out, or the answer is unusable, the site pays for whatever the provider may have billed. Reconciliation can correct an unknown charge later.
Runs whose process died stay `running` until their lease expires, which takes `timeoutMs` per possible attempt plus one minute. The `ai-maintenance` cron (every 10 minutes) then settles them as `uncertain`. The same cron reconciles costs and prunes history, so the host must run VitNode's cron. See [Cron](/docs/dev/cron).
## Role policies and user overrides [#role-policies-and-user-overrides]
Admins control who may use AI and how much in the AdminCP:
- **AI permissions per role**: on the **Artificial intelligence (AI)** tab of the role form, grant or deny each action permission, with an optional daily limit.
- **Monthly point policies per role**: on the same tab, set an allowance in points, or unlimited.
- **Default monthly points**: the site-wide allowance when no role policy gives more (**Artificial Intelligence (AI) → Overview → Settings**).
- **User overrides**: on the **AI access** card of a member's AdminCP page, block them, make them unlimited, or set their own allowance.
How multiple roles combine:
- **Any granting role grants.** A permission no role mentions falls back to the action's `defaultGranted`.
- **The largest allowance wins.** Allowances are never summed: a user in "Editors" (500 points) and "Moderators" (2,000 points) gets 2,000, not 2,500. The default monthly points act as a floor.
- **The largest daily limit wins** among granting roles. A granting role with no daily limit lifts the role limit, and the action's own daily limit applies.
- **Root roles** always have access, with no personal point cap.
- **A user override replaces** what the roles give. A blocked user cannot run any action.
The default monthly points are `0`. Until an admin sets a role policy, an
override or a higher default, only root roles can run priced user actions.
Everyone else gets `AI_USER_LIMIT_REACHED`.
## Rate and concurrency limits [#rate-and-concurrency-limits]
| Setting | Default | Counts |
| :------------------ | :------ | :----------------------------------------- |
| Requests per minute | 10 | Runs a user started in the last 60 seconds |
| User concurrency | 2 | A user's runs still in flight |
| System concurrency | 2 | System runs still in flight |
VitNode checks both inside the reservation transaction, under the same locks as the budgets. Two requests from one user cannot both slip past the limit. A refused run fails with `AI_RATE_LIMITED` or `AI_CONCURRENCY_LIMITED` before any provider call.
## Missing pricing [#missing-pricing]
When a model has no effective pricing, VitNode cannot bound the run:
- **Under any money cap**, the run is refused with `AI_PRICING_MISSING` before any provider call. The site budget, the system budget and the user's points all count as money caps.
- **Daily run counts** need no pricing.
- **With no cap at all**, the run goes ahead. Its cost is recorded as `unknown` and counted among unknown costs in the AdminCP.
Add `pricing` to the model in `vitnode.api.config.ts` to fix it.
## History and retention [#history-and-retention]
Every run and every provider call is stored in PostgreSQL: action, actor, model, prompt version, tokens, cost source, charges, status and error code.
History is kept for **365 days** by default (adjustable from 7 to 3,650). Pruning runs on its own schedule, independent of queue cleanup, and removes only settled runs and finished budget periods with nothing reserved.
## No Redis required [#no-redis-required]
Budgets, reservations, rate limits, concurrency and idempotency keys live in PostgreSQL and change inside transactions. AI accounting is correct with any number of API instances, with or without `REDIS_URL`.
## Learn more [#learn-more]
# AI in Fields & the Editor (/docs/dev/ai/fields-and-editor)
Give a Content Engine `text` or `textarea` field an `ai` option and the AdminCP form shows an **AI** button next to it. The button runs a registered [AI action](/docs/dev/ai/actions) with the other fields as input, shows the suggestion for review, and writes nothing until the editor accepts it.
```ts title="plugins/blog/src/content/post.ts"
excerpt: field.textarea({
ai: {
action: '@vitnode/blog:excerpt.generate',
mode: 'suggestion',
sourceFields: ['title', 'content'],
},
localized: true,
nullable: true,
maxLength: 300,
}),
```
You write no route, no button and no form code. Core provides all of it.
## Add AI to a field in your plugin [#add-ai-to-a-field-in-your-plugin]
The `@vitnode/example` plugin suggests an article teaser from its title and product code. Here is the whole feature.
### Define the action [#define-the-action]
The action's input is **one string per source field, plus `locale`** for the language being edited. Match `inputSchema` to that shape.
```ts title="plugins/example/src/api/ai/actions.ts"
import { defineAiAction } from '@vitnode/core/api/lib/ai/action'
import { checkStaffPermission } from '@vitnode/core/api/lib/check-staff-permission'
import { z } from 'zod'
import { CONFIG_PLUGIN } from '@/const'
import { articleContentType } from '@/content/article'
export const articleExcerptAiAction = defineAiAction({
id: 'article.excerpt',
description: 'Suggests a teaser for an example article.',
promptVersion: 1,
permission: { key: 'article.excerpt', defaultGranted: true },
requiredCapabilities: ['text'],
defaults: {
maxInputCharacters: 400,
maxOutputTokens: 120,
timeoutMs: 20_000,
},
// [!code ++:5]
inputSchema: z.object({
code: z.string().max(100),
locale: z.string().min(2).max(16),
title: z.string().min(1).max(200),
}),
output: 'text',
outputSchema: z.string().min(1).max(500),
parseText: (text) => text.trim().replace(/^["“](.*)["”]$/su, '$1'),
buildPrompt: (input, { instructions }) => ({
prompt: `Title: ${input.title}\nProduct code: ${input.code}`,
system: [
'You write a one-sentence teaser for an article in a demo catalogue.',
`Write it in ${input.locale}, under 200 characters.`,
'Answer with the teaser only, without quotes.',
...(instructions ? [instructions] : []),
].join('\n'),
}),
// The AI permission never replaces access to the content.
authorize: async ({ c }) =>
await checkStaffPermission(c, {
module: articleContentType.permissionModule,
permission: 'can_edit',
plugin: CONFIG_PLUGIN.pluginId,
type: 'admin',
}),
})
```
The real file turns the locale code into a language name with `Intl.DisplayNames`, because models write better for "Polish" than for "pl".
### Register it [#register-it]
```ts title="plugins/example/src/config.api.ts"
export const exampleApiPlugin = () =>
buildApiPlugin({
pluginId: CONFIG_PLUGIN.pluginId,
aiActions: [articleExcerptAiAction], // [!code ++]
modules: [adminModule /* ... */],
})
```
### Point the field at it [#point-the-field-at-it]
Use the canonical key, `:`:
```ts title="plugins/example/src/content/article.ts"
excerpt: field.textarea({
// [!code ++:5]
ai: {
action: '@vitnode/example:article.excerpt',
mode: 'suggestion',
sourceFields: ['title', 'code'],
},
maxLength: 500,
nullable: true,
}),
```
### Give editors access [#give-editors-access]
The editor's role needs the action's permission and an AI points allowance. See [AI access](/docs/dev/ai/admin#ai-access). With `defaultGranted: true` the permission is on until a role says otherwise, but the default monthly points are `0`, so non-root editors still need an allowance.
### The `ai` field option [#the-ai-field-option]
:`.',
type: '`${string}:${string}`',
required: true,
},
mode: {
description: 'The editor previews the result and accepts or discards it.',
type: '"suggestion"',
required: true,
},
sourceFields: {
description:
'Fields the action reads, in the locale being edited. Each becomes a key of the input.',
type: 'readonly string[]',
required: true,
},
}}
/>
`ai` is plain JSON on the field definition, so it reaches the browser too. Nothing AI-specific is stored with the content.
### Mistakes fail early [#mistakes-fail-early]
- **When the content type is defined**, `defineContentType()` throws if the key is not `:`, `mode` is not `"suggestion"`, a source field does not exist, or the field lists itself as a source.
- **At boot**, VitNode throws if no installed plugin registers the action, or the action is system-only (`actors: ["system"]`).
The input shape is not checked at boot. A mismatch between `sourceFields` and `inputSchema` shows up as `AI_INVALID_INPUT` on the first click.
## What editors see [#what-editors-see]
The **AI** button appears only when the server says this admin may start the action: AI is on, the action is enabled, a configured model can run it, and their roles grant its permission.
1. **Generate** sends the source fields in the language being edited. It is disabled with _"Fill in title first"_ while a source field is empty.
2. The suggestion appears in a review panel: **Discard**, **Try again** or **Use suggestion**.
3. **Use suggestion** puts the text into the field. Only the edited language changes. Saving still goes through the normal form, validation and revisions.
The panel warns before stale text replaces anything:
- **The sources changed** since the suggestion was made: _"The text it was written from changed since. Try again for an up-to-date suggestion."_
- **The editor typed into the field** after asking: the accept button becomes **Replace my newer text**.
Accepting or discarding is recorded on the run for statistics only. It never changes what was charged. Each [error code](/docs/dev/ai/actions#error-codes) has its own message, such as _"The AI provider failed. Nothing was charged to you."_ or _"You reached today's limit for this AI feature."_
## Run actions from your own UI [#run-actions-from-your-own-ui]
The field button uses generic routes that run **any** registered user action. Your own AdminCP components can use them too:
| Route | Session | Purpose |
| :----------------------------------------- | :---------- | :------------------------------------------ |
| `POST /admin/ai/assist` | AdminCP | Run an action, JSON in, JSON out. |
| `GET /admin/ai/assist/available` | AdminCP | Action keys this admin may start. |
| `POST /admin/ai/assist/runs/{id}/feedback` | AdminCP | Record `{ accepted }` for one of your runs. |
| `POST /ai/run` | Site member | Run an action, JSON in, JSON out. |
| `GET /ai/available` | Site member | Action keys this member may start. |
| `POST /ai/runs/{id}/feedback` | Site member | Record `{ accepted }`. |
The body is `{ action, input, idempotencyKey?, resource?, sourceFingerprint? }`. It never carries a model, a price or an actor. The server decides those. There is no extra staff permission on these routes on purpose: the action's AI permission and its `authorize()` decide.
The browser helpers wrap them. The blog's pre-publication review is a complete example:
```tsx title="plugins/blog/src/views/admin/article/editor/ai-review.tsx"
import { useAvailableAiActions } from '@vitnode/core/components/ai/use-available-ai-actions'
import {
aiErrorCodeOf,
requestAiAssist,
} from '@vitnode/core/lib/ai/assist-client'
const { data: available = [] } = useAvailableAiActions('admin')
if (!available.includes('@vitnode/blog:article.review')) return null
try {
const { output, runId } = await requestAiAssist({
action: '@vitnode/blog:article.review',
input: { content, excerpt, locale, title },
})
} catch (error) {
setError(aiErrorCodeOf(error)) // e.g. "AI_USER_LIMIT_REACHED"
}
```
`requestAiAssist()` calls `POST /admin/ai/assist` with a fresh idempotency key and throws an `AiRequestError` on failure. The available-actions list only decides which buttons to show. The server authorizes every run again.
## Quick Ask in the rich text editor [#quick-ask-in-the-rich-text-editor]
Quick Ask is the **Quick Ask (AI)** button in the Tiptap rich text editor toolbar. It works on the selection and the text around it:
| Operation | Action key | Does |
| :--------------- | :--------------------------------------- | :--------------------------------------------------------- |
| Shorten | `@vitnode/core:editor.selection.rewrite` | Tighter text, same meaning. |
| Correct writing | `@vitnode/core:editor.selection.rewrite` | Spelling, grammar and punctuation only. |
| Simplify | `@vitnode/core:editor.selection.rewrite` | Simpler words, shorter sentences. |
| Change the tone | `@vitnode/core:editor.selection.rewrite` | Friendly, formal, confident or neutral. |
| Continue writing | `@vitnode/core:editor.quick-ask` | One or two paragraphs from where the text ends. |
| Ask anything | `@vitnode/core:editor.quick-ask` | A custom request about the selection (max 500 characters). |
Both actions share the permission `@vitnode/core:editor.assist` with
`defaultGranted: false`. Nobody but root sees Quick Ask until an admin allows
it for a role in **Artificial Intelligence (AI) → Access & limits** and gives
that role points.
How it behaves:
- **Bounded context.** It sends the selection (up to 8,000 characters) and up to 1,500 characters before and after it. It never sends the whole document.
- **Streamed and previewed.** The answer streams into a preview. **Stop** cancels it; a canceled run charges the member no points.
- **Cost shown first.** With a selection, the panel shows the reservation bound, not a price: _"One request costs at most N AI points - usually much less."_
- **Plain text only.** The answer becomes paragraphs and hard breaks. It is never parsed as HTML, so it cannot inject markup, links or scripts.
- **Stale selection.** If the selected text changed while the answer was written, **Replace selection** is disabled; **Insert below** still works.
- **One-step undo.** Applying is one editor transaction. The toast's **Undo** or Ctrl+Z restores the text exactly.
- **Nothing is saved** until the form is saved.
- **Content language.** It writes in the language of the field being edited (the multi-language tab), not the interface language.
- **Streaming models only.** Streamed runs use only models that declare the `streaming` capability; without one, Quick Ask fails with `AI_MODEL_INCOMPATIBLE`.
Quick Ask uses the AdminCP routes on `/admin` pages and the member routes everywhere else.
### Streaming and estimate routes [#streaming-and-estimate-routes]
| Route | Session |
| :------------------------------- | :---------- |
| `POST /admin/ai/assist/stream` | AdminCP |
| `POST /admin/ai/assist/estimate` | AdminCP |
| `POST /ai/stream` | Site member |
| `POST /ai/estimate` | Site member |
The stream routes accept the same body as `/ai/run` and answer `application/x-ndjson`, one JSON object per line:
```text
{"t":"A tighter "}
{"t":"version of the paragraph."}
{"done":{"runId":812,"chargedPoints":"0.42","costKnown":true,"modelId":"default"}}
```
A failure after the stream started arrives as `{"e":{"code":"AI_TIMEOUT","message":"…"}}`. Limits and budgets are checked before the first byte, so refusals are ordinary JSON error responses. Only `text` actions stream.
`/estimate` answers `{ maxPoints, maxUsd }`: the most the run could cost, or `null` when no pricing bounds it.
Use the browser helpers from `@vitnode/core/lib/ai/stream-client` in your own editor tools:
```ts
import {
aiAssistScope,
streamAiAction,
} from '@vitnode/core/lib/ai/stream-client'
const controller = new AbortController()
const summary = await streamAiAction({
action: '@vitnode/core:editor.selection.rewrite',
input: { after, before, locale, operation: 'shorten', selection },
onDelta: (delta) => setPreview((text) => text + delta),
scope: aiAssistScope(), // "admin" on /admin pages, otherwise "user"
signal: controller.signal, // controller.abort() stops paying
})
```
`estimateAiAction({ action, input, scope })` resolves to the maximum points as a string, or `null`. The editor-side helpers `takeQuickAskSnapshot()`, `isQuickAskStale()` and `applyQuickAskResult()` live in `@vitnode/core/components/tiptap/quick-ask/quick-ask-editor`.
## Rich-text translation keeps its structure [#rich-text-translation-keeps-its-structure]
Asking a model to "preserve the HTML" is not a guarantee. The blog's `field.translate` action checks it instead:
```ts title="plugins/blog/src/api/ai/actions.ts"
import { assertSameHtmlStructure } from '@vitnode/core/api/lib/ai/html-structure'
parseText: (text, input) => {
if (input.format === 'text') return unquote(text)
const translated = text.trim()
assertSameHtmlStructure(input.text, translated) // [!code highlight]
return translated
},
```
`assertSameHtmlStructure(source, translated)` compares every tag in order, with its attributes. Text and the translatable attributes `alt`, `title`, `aria-label` and `placeholder` may change; tags, links and other attributes may not. A difference throws `HtmlStructureError`, the run fails with `AI_INVALID_OUTPUT`, and the editor is not charged.
`htmlStructureDifference()` returns the first difference as a string (or `null`) when you want to report it instead of throwing.
## Translation freshness [#translation-freshness]
For each translated field, VitNode stores a fingerprint of **the source text it was translated from** in `core_ai_translation_sources`. Comparing that fingerprint with the current source shows exactly which fields are outdated. VitNode never uses timestamps for this.
| Status | Meaning |
| :---------- | :------------------------------------------------------------------------ |
| `fresh` | Translated from the source as it is now. |
| `edited` | As above, and a person changed the translation since. Their changes stay. |
| `outdated` | The source changed after the translation was made. |
| `untracked` | No record of what it was translated from, so no claim either way. |
| `missing` | Nothing translated yet. |
Use `useTranslationFreshness()` inside a Content Engine form:
```tsx title="plugins/blog/src/views/admin/article/editor/article-editor.tsx"
import {
useContentForm,
useContentFormValues,
useTranslationFreshness,
} from '@vitnode/core/content/admin-form'
const { defaultLocale } = useContentForm()
const values = useContentFormValues()
const freshness = useTranslationFreshness({
contentTypeId,
fields: ['title', 'content', 'excerpt'],
itemId, // undefined while creating
source: defaultLocale ?? 'en',
values,
})
freshness.freshnessOf('pl') // { title: 'fresh', content: 'outdated', excerpt: 'missing' }
freshness.outdatedFields('pl') // ['content']
freshness.editedByPerson('pl', 'content') // true when a person changed the AI text
// After placing an AI translation into the form:
freshness.rememberAiTranslation('pl', 'content')
// When a person confirms the translations still match the source:
freshness.markReviewed('pl', freshness.outdatedFields('pl'))
```
`rememberAiTranslation()` and `markReviewed()` record nothing immediately. They are written when the form is saved, through the form's `onSaved` hook, so a record always describes text that was actually kept. Your own form code can subscribe the same way: `useContentForm().onSaved?.(async ({ itemId }) => { … })` returns an unsubscribe function.
The blog uses this to show which languages are outdated, to update only outdated fields that no person edited, and to let an editor mark a translation as reviewed.
The records are read and written with `GET` and `PUT /admin/ai/translation-sources`. Both require `can_edit` on the content type's own permission module.
## Pre-publication review [#pre-publication-review]
The blog's `@vitnode/blog:article.review` action is an optional read-through in the article editor's side panel. It is an `output: "object"` action, so it needs a `structured-output` model. It returns:
```ts
{
summary: string,
suggestions: Array<{
area: 'clarity' | 'completeness' | 'structure' | 'tone',
message: string,
priority: 'high' | 'low',
}>, // at most 8
}
```
- It **never verifies facts**. The prompt forbids judging whether facts are true, because the model has nothing to check them against.
- It **never gates publishing**. The deterministic readiness checks decide; publishing works the same with or without a review.
It is a good template for structured, advisory AI in your own plugin.
## Learn more [#learn-more]
# AI Setup (/docs/dev/ai)
VitNode integrates the [Vercel AI SDK](https://ai-sdk.dev). Register models in `vitnode.api.config.ts` once, then use them inside any API handler via `c.get("ai")`.
## Quick start [#quick-start]
### 1. Configure models in the API config [#1-configure-models-in-the-api-config]
A model can be an AI Gateway id string or a direct provider instance:
```ts title="apps/api/src/vitnode.api.config.ts"
import { openai } from '@ai-sdk/openai'
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
// [!code ++:13]
ai: {
models: [
{
id: 'default',
name: 'GPT-4o Mini',
model: openai('gpt-4o-mini'),
capabilities: ['text', 'structured-output', 'streaming'],
pricing: {
rates: { inputPerMillion: '0.15', outputPerMillion: '0.60' },
},
},
],
},
})
```
Set `OPENAI_API_KEY` in your `.env` file.
`capabilities` and `pricing` are optional, but [AI actions](/docs/dev/ai/actions) rely on them: capabilities decide which model can run an action, and pricing turns token usage into money. See [Model capabilities](#model-capabilities) and [Costs, Points & Budgets](/docs/dev/ai/costs-and-limits).
---
### 2. Use it in a route handler [#2-use-it-in-a-route-handler]
For user-facing features, run a registered [AI action](/docs/dev/ai/actions). It checks permissions, limits and budgets, and records the cost:
```ts
handler: async (c) => {
// [!code ++:4]
const { output } = await c.get('ai').run({
action: EXCERPT_AI_ACTION,
input: c.req.valid('json'),
})
return c.json({ text: output })
}
```
For internal tooling, you can also resolve the raw model and call the SDK directly. See [AI Usage](/docs/dev/ai/usage).
---
## Model capabilities [#model-capabilities]
A capability says what a configured language model can do. VitNode never guesses it from the model's name. A model without `capabilities` is treated as `["text"]`.
| Capability | Meaning |
| :------------------ | :---------------------------------------------------------------- |
| `text` | Text in, text out. |
| `image-input` | Accepts images in the prompt (vision). |
| `structured-output` | Returns reliable JSON objects (`Output.object`). |
| `streaming` | Streams tokens. Required by `c.get("ai").stream()` and Quick Ask. |
Every [AI action](/docs/dev/ai/actions) declares the capabilities it needs. When a run starts, VitNode picks the first configured model that has all of them. If no model fits, the run fails with `AI_MODEL_INCOMPATIBLE` before anything is sent to a provider.
`image-input` means the model can _read_ images, which features such as image
ALT text need. Generating images is a different thing: register those models
under `imageModels` and resolve them with `c.get("ai").imageModel()`. Add
`image-input` only to models whose provider docs say they accept images.
Otherwise VitNode keeps images away from them.
---
## Model pricing [#model-pricing]
`pricing` is what the model costs **on this connection**, in USD per one million tokens. Prices are decimal strings, so money never goes through floating point:
```ts title="apps/api/src/vitnode.api.config.ts"
{
id: 'default',
name: 'Claude Sonnet',
model: anthropic('claude-sonnet-4-5'),
capabilities: ['text', 'image-input', 'structured-output', 'streaming'],
// [!code ++:8]
pricing: {
rates: {
inputPerMillion: '3',
outputPerMillion: '15',
cacheReadPerMillion: '0.30',
cacheWritePerMillion: '3.75',
},
},
}
```
Prices live only in this config. The AdminCP has no price editor, so a price change is a code change you deploy. A malformed price stops the server at boot.
Without pricing, a call's cost stays **unknown**, never zero. Under a budget cap, a model with no pricing is refused with `AI_PRICING_MISSING`. Tiers, flat fees and gateway vs direct pricing are covered in [Costs, Points & Budgets](/docs/dev/ai/costs-and-limits#model-pricing).
### Model options [#model-options]
`ai` also accepts `providerAdapters`: readers for providers that report their own cost. The AI Gateway and OpenRouter readers are built in. See [Provider-reported cost](/docs/dev/ai/costs-and-limits#provider-reported-cost).
---
## Multiple models & embeddings [#multiple-models--embeddings]
Configure dedicated models for fast completions, reasoning, and vector embeddings:
```ts title="apps/api/src/vitnode.api.config.ts"
ai: {
models: [
{
id: 'default',
name: 'GPT-4o Mini',
model: openai('gpt-4o-mini'),
capabilities: ['text', 'structured-output', 'streaming'],
},
{
id: 'reasoning',
name: 'Claude Sonnet',
model: 'anthropic/claude-sonnet-5', // AI Gateway id
capabilities: ['text', 'image-input', 'structured-output', 'streaming'],
},
],
embeddingModels: [
{ id: 'default', name: 'Text Embedding 3', model: openai.embedding('text-embedding-3-small') },
],
}
```
Resolve specific models by ID:
```ts
const smartModel = c.get('ai').model('reasoning')
const embedModel = c.get('ai').embeddingModel()
const capabilities = c.get('ai').capabilities('reasoning') // ['text', 'image-input', ...]
```
`model()`, `embeddingModel()`, `imageModel()` and `models()` work as before. Admins can assign a different model (and a fallback) to each action in the AdminCP; an assigned model that lacks a required capability is refused.
---
## Reading the model list in the browser [#reading-the-model-list-in-the-browser]
Provider instances, API keys and prices stay on the server. `c.get("ai").models()` returns only the public half of each entry:
| Field | Value |
| :------------- | :-------------------------------------------------------------------- |
| `id` | The configured id, e.g. `"default"`. |
| `name` | The configured label. |
| `model` | The provider's model id string, e.g. `"gpt-4o-mini"`. |
| `provider` | `"gateway"` for AI Gateway ids, otherwise the SDK model's `provider`. |
| `capabilities` | The declared capabilities, or `["text"]`. |
The middleware route publishes this list, and the browser reads it with
[`useMiddlewareConfigQuery()`](/docs/dev/fetcher#deployment-configuration):
```tsx title="plugins/writer/src/pages/model-picker.tsx"
import { useMiddlewareConfigQuery } from '@vitnode/core/tanstack/auth'
export const ModelPicker = () => {
// [!code ++:1]
const { data } = useMiddlewareConfigQuery()
if (data.ai.models.length === 0) return
AI is not configured.
return (
)
}
```
The first entry is the default, which `c.get("ai").model()` resolves when a
handler gets no id. The list is empty when no `ai.models` are configured, so
a picker can hide itself instead of offering nothing.
## Learn more [#learn-more]
# AI Usage (/docs/dev/ai/usage)
VitNode uses the [Vercel AI SDK](https://ai-sdk.dev). Resolve a model from `c.get("ai")` and call the native SDK functions with it.
Calling `generateText` with `c.get("ai").model()` bypasses permissions,
budgets, AI points and run history. Nobody watches the bill until it arrives.
For user-facing features, define an [AI action](/docs/dev/ai/actions) and run
it with `c.get("ai").run()`. Keep direct SDK calls for internal tooling you
fully control.
## Model resolvers [#model-resolvers]
| Resolver | Returned type | Used by |
| :-------------------------------- | :--------------- | :--------------------------------------------- |
| `c.get("ai").model(id?)` | `LanguageModel` | `generateText`, `streamText`, `generateObject` |
| `c.get("ai").embeddingModel(id?)` | `EmbeddingModel` | `embed`, `embedMany` |
| `c.get("ai").imageModel(id?)` | `ImageModel` | `generateImage` |
---
## 1. Text generation [#1-text-generation]
Generate text in a Hono API route:
```ts title="plugins/blog/src/api/modules/posts/routes/summarize.route.ts"
import { buildRoute } from '@vitnode/core/api/lib/route'
import { generateText } from 'ai'
import { z } from 'zod'
export const summarizeRoute = buildRoute({
pluginId: 'blog',
route: {
method: 'post',
path: '/summarize',
request: {
body: {
content: {
'application/json': { schema: z.object({ text: z.string().min(1) }) },
},
},
},
responses: {
200: { description: 'Text summary' },
},
},
handler: async (c) => {
const { text } = c.req.valid('json')
// [!code ++:6]
const { text: summary } = await generateText({
model: c.get('ai').model(),
system: 'You are a concise summary assistant.',
prompt: `Summarize the following content:
${text}`,
})
return c.json({ summary })
},
})
```
---
## 2. Streaming responses [#2-streaming-responses]
Stream the model's answer to the client as it arrives:
```ts
import { streamText } from 'ai'
handler: async (c) => {
const result = streamText({
model: c.get('ai').model(),
prompt: 'Write an introduction to WebSockets.',
})
// [!code ++:1]
return result.toDataStreamResponse()
}
```
---
## 3. Structured output (`generateObject`) [#3-structured-output-generateobject]
Get typed JSON back from the model, shaped by a Zod schema:
```ts
import { generateObject } from 'ai'
import { z } from 'zod'
const postMetadataSchema = z.object({
title: z.string(),
tags: z.array(z.string()),
estimatedReadingMinutes: z.number(),
})
// [!code ++:6]
const { object } = await generateObject({
model: c.get('ai').model(),
schema: postMetadataSchema,
prompt: 'Generate SEO metadata for an article on Postgres indexing.',
})
```
---
## 4. Generating embeddings [#4-generating-embeddings]
Turn text into a vector embedding for semantic search:
```ts
import { embed } from 'ai'
// [!code ++:4]
const { embedding } = await embed({
model: c.get('ai').embeddingModel(),
value: 'How to configure Redis caching in VitNode',
})
```
## Learn more [#learn-more]
# Architecture (/docs/dev/architecture)
VitNode separates concerns between two core layers:
1. **TanStack Start**: Frontend UI, SSR, isomorphic routing, and client caching.
2. **Hono API**: Backend routing, session management, staff permissions, and database operations.
{/* Image prompt: VitNode architectural flow diagram displaying TanStack Start (SSR, route loaders, TanStack Query) communicating across an HTTP/RPC boundary with Hono API (middleware, sessions, Drizzle ORM) and PostgreSQL/Redis storage. Dark theme, 1600x900. */}
## System Boundaries [#system-boundaries]
| Responsibility | TanStack Start (Web App) | Hono (API) |
| :-------------------- | :-------------------------------------------- | :-------------------------------------------------------------- |
| **Routing** | Page URLs, dynamic parameters, nested layouts | REST/RPC endpoints under `/api/*` |
| **Data Fetching** | Route loaders and the universal `fetcher` | Query execution via Drizzle ORM |
| **State & Cache** | TanStack Query client cache | Redis domain cache & database storage |
| **Security Boundary** | UI guards (redirecting unauthenticated users) | **Enforces authentication, permissions, CSRF, and rate limits** |
Route guards (`beforeLoad`) enhance UX by redirecting visitors early, but the
Hono API is the true security boundary. All private endpoints strictly verify
cookies and permissions on every request.
---
## End-to-End Request Flow [#end-to-end-request-flow]
When a user visits a page (e.g. `/blog`):
| Phase | Runtime | Action |
| :------------------------ | :--------------------- | :---------------------------------------------------------------- |
| **1. Request** | Browser | Visitor navigates to `/blog` |
| **2. Routing** | Server (SSR) / Browser | TanStack Router matches route and executes `loader` |
| **3. Query Warming** | Server / Browser | `context.queryClient.query` executes isomorphic fetcher |
| **4. RPC Call** | Server / Browser | The universal `fetcher` calls the Hono endpoint by plugin id |
| **5. API Middleware** | Server (Hono) | Verifies session cookie, applies rate limits, injects `c.get(db)` |
| **6. Handler & Database** | Server (Hono) | Handler validates input and queries PostgreSQL via Drizzle |
| **7. Response** | Server / Browser | JSON data hydrates TanStack Query cache and paints component |
---
## The Request Pipeline [#the-request-pipeline]
Before route matching, every request passes through the middleware
`createVitNodeStart` installs - in this order, and an app cannot get in front of
any of it:
| Order | Middleware | Applies to |
| :---- | :----------------- | :------------------------------------------------------------------------------ |
| 1 | **CSRF** | Server function calls (`handlerType === 'serverFn'`) |
| 2 | **Locale** | Page requests: canonical `308` redirects and the locale cookie |
| 3 | **Document cache** | HTML responses: forced `Cache-Control: private, no-store` plus security headers |
| 4 | Your own | Whatever `requestMiddleware` lists |
`/api/*` reaches the same middleware and passes through untouched - no redirect,
no rewrite, no cache directive - so the Hono bridge sees the request exactly as
the client sent it and keeps its own caching policy. See
[Configuration](/docs/dev/configuration).
---
## Plugin System Architecture [#plugin-system-architecture]
VitNode is built around modular plugins located in `plugins/*`:
- **Independent Packages**: Plugins compile to their own `dist/` with isolated dependencies.
- **Unified Manifest**: Routes, AdminCP navigation, and database models are registered declaratively.
- **Zero Overhead**: Inactive plugins contribute no code or overhead to production bundles.
## Learn More [#learn-more]
# Cache API data (/docs/dev/cache/api)
`c.get("cache")` stores values in Redis from inside a Hono route handler. Use it for reads that are slow and give the same answer to many visitors, such as counts over a large table or a computed summary. Without Redis, every call runs your loader directly, so the same code works with and without it.
## Before you begin [#before-you-begin]
Connect Redis in `vitnode.api.config.ts` as described in [Redis](/docs/dev/advanced/redis). You can write the code below without Redis, but nothing is cached until it is connected.
### Wrap the slow read in `remember` [#wrap-the-slow-read-in-remember]
In the route handler, pass `remember` a key, a time to live (TTL) in seconds, and a function that loads the value:
```ts title="plugins/site-notes/src/api/modules/notes/routes/stats.route.ts"
handler: async c => {
// [!code ++:5]
const stats = await c.get('cache').remember(
'notes:stats',
60 * 5,
async () => await countNotesByAuthor(c),
)
return c.json(stats)
},
```
`countNotesByAuthor` stands for your existing database query. On a hit, `remember` returns the stored value and skips the query. On a miss, it runs the query, stores the result for five minutes and returns it.
Keys are prefixed with the plugin that owns the route. `notes:stats` is stored as `vitnode:cache:@acme/site-notes:notes:stats`, so it cannot collide with another plugin's key. Values are stored as JSON, so a `Date` comes back as a string.
### Delete the key when the data changes [#delete-the-key-when-the-data-changes]
In every route that changes notes, delete the key after the write succeeds:
```ts title="plugins/site-notes/src/api/modules/notes/routes/create.route.ts"
handler: async c => {
const note = await createNote(c, c.req.valid('json'))
await c.get('cache').delete('notes:stats') // [!code ++]
return c.json(note, 201)
},
```
The TTL is a safety net, not the invalidation. Without `delete`, visitors see outdated stats for up to five minutes after every write. `delete` also accepts an array of keys.
A key such as `notes:stats` returns the same value to everyone. For data that
depends on who is asking, put the user ID in the key, for example
`notes:stats:${userId}`, or do not cache it.
`remember` treats a stored `null` as a miss, so a loader that returns `null` runs on every call. If an empty answer is expensive to compute, return an object such as `{ stats: null }` instead.
## Check the result [#check-the-result]
1. Open **AdminCP → System → Integrations** (`/admin/core/system/integrations`). The Redis card shows **Active** when Redis is connected. **Needs attention** means it is configured but unreachable.
2. Call the stats route, then list your plugin's keys:
```bash
redis-cli --scan --pattern 'vitnode:cache:@acme/site-notes:*'
```
The output includes `vitnode:cache:@acme/site-notes:notes:stats`. Calling the route again within five minutes does not run `countNotesByAuthor`.
3. Create a note and run the same command. The key is gone until the next call to the stats route.
For every method, including `get`, `set`, `flush` and locks, see the [Cache reference](/docs/dev/cache/reference).
# Cache app data (/docs/dev/cache/app)
The app cache in VitNode is TanStack Query. A plugin route's loader fills a query entry during SSR or navigation, the page reads that same entry without another request, and a write invalidates it so the next read is fresh.
This guide builds a notes list for an `@acme/site-notes` plugin with a `notes` API module. How the API call itself works is covered in [Data fetching](/docs/dev/fetcher).
### Define the query once [#define-the-query-once]
Create one `queryOptions` helper for the list. The loader, the page and the mutation all use it, so they always agree on the key.
```ts title="plugins/site-notes/src/features/notes/notes-query.ts"
import { queryOptions } from '@tanstack/react-query'
import { fetcher } from '@vitnode/core/tanstack/fetcher'
export const notesQueryKey = ['@acme/site-notes', 'notes'] as const
export const notesQuery = () =>
queryOptions({
queryKey: notesQueryKey,
queryFn: async () => {
const response = await fetcher({
plugin: '@acme/site-notes',
method: 'get',
module: 'notes',
path: '/',
})
if (response.status !== 200) {
throw new Error(`Loading notes answered ${response.status}.`)
}
return await response.json()
},
})
```
Start every key with your plugin ID. `invalidateQueries` matches keys by prefix, so a generic first segment such as `'notes'` would let one plugin invalidate another plugin's data.
### Warm the query in the route loader [#warm-the-query-in-the-route-loader]
Create the page with `defineRoute` from `@vitnode/core/tanstack/plugin-routes`. Its loader context includes `queryClient`. The framework-neutral `definePluginRoute` from `@vitnode/core/routing` only receives `locale`.
```tsx title="plugins/site-notes/src/pages/notes-page.tsx"
import { useSuspenseQuery } from '@tanstack/react-query'
import { defineRoute } from '@vitnode/core/tanstack/plugin-routes'
import { notesQuery } from '../features/notes/notes-query'
export const route = defineRoute({
load: async ({ context }) => {
await context.queryClient.query({ ...notesQuery(), staleTime: 'static' }) // [!code ++]
},
})
const NotesPage = () => {
const { data: notes } = useSuspenseQuery(notesQuery())
return (
{notes.map((note) => (
{note.title}
))}
)
}
export default NotesPage
```
`staleTime: 'static'` makes the loader reuse an entry that is already cached instead of fetching again on every navigation. The page reads the entry the loader just filled, so it renders without suspending.
### Invalidate the query after a write [#invalidate-the-query-after-a-write]
VitNode's query client does not refetch when a component mounts or the tab regains focus. A cached list stays as it is until your code invalidates it, so invalidate it in every mutation that changes notes:
```ts title="plugins/site-notes/src/features/notes/use-create-note.ts"
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { fetcher } from '@vitnode/core/tanstack/fetcher'
import { toast } from 'sonner'
import { notesQueryKey } from './notes-query'
export const useCreateNote = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (title: string) => {
const response = await fetcher({
plugin: '@acme/site-notes',
method: 'post',
module: 'notes',
path: '/',
args: { body: { title } },
})
if (response.status !== 201) {
throw new Error(`Creating a note answered ${response.status}.`)
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: notesQueryKey }) // [!code ++]
toast.success('Note created', {
description: 'It now appears in the notes list.',
})
},
})
}
```
Invalidate the narrowest key that covers everything the write could change. For a list with paging or sorting, that is the list's root key: the changed row may have moved to a different page.
For data that belongs to one signed-in user, such as drafts or settings, add
the user ID to the query key. Otherwise, signing out and back in as someone
else in the same tab can show the previous account's cached data.
## Check the result [#check-the-result]
1. Open the notes page with the browser's Network tab open. The list renders without a browser request for notes, because the loader filled the cache during SSR.
2. Navigate to another page and back with a link. No new request for notes is sent.
3. Create a note. You see one `POST` request, then one `GET` request that refetches the list, and the new note appears.
To cache the database work behind the `notes` endpoint as well, continue with [Cache API data](/docs/dev/cache/api).
# Cache (/docs/dev/cache)
VitNode caches in three places. The app keeps API responses in TanStack Query, so a page never asks for something it already has. The API keeps expensive results in Redis through `c.get("cache")`, so a slow query runs once for everyone. The page cache stores the rendered HTML of public pages, so an article renders once for all its visitors. All three are opt-in: nothing is cached until your code asks for it.
## Pick a layer [#pick-a-layer]
| You want to | Use | Guide |
| --------------------------------------------------------------- | -------------------------- | -------------------------------------------- |
| Render SSR data without refetching it, and skip repeat requests | App cache (TanStack Query) | [Cache app data](/docs/dev/cache/app) |
| Stop running the same slow database query for every visitor | API cache (Redis) | [Cache API data](/docs/dev/cache/api) |
| Look up `remember`, `delete`, `flush` and the other methods | API cache (Redis) | [Cache reference](/docs/dev/cache/reference) |
| Render a public page once and send that HTML to every visitor | Page cache | [Cache server-rendered pages](/docs/dev/cache/pages) |
Most features use both. The API caches the expensive part once, and each visitor's app keeps its own copy of the response.
[`fetcher()`](/docs/dev/fetcher) forwards the visitor's cookies, so a stored
response could be one visitor's data served to another. That is why caching
lives in the two layers above and not in the fetcher.
Content Engine public reads are not cached on the server. To cache them in your app or a separate front end, see [Content Engine caching](/docs/content-engine/caching).
# How cached pages stay fresh (/docs/dev/cache/pages-invalidation)
A cached page is one rendered copy of HTML that every visitor receives. This page explains how long that copy lives, what it may contain, and how a Content Engine change replaces it. To turn caching on, follow [Cache server-rendered pages](/docs/dev/cache/pages).
## TTL and stale-while-revalidate [#ttl-and-stale-while-revalidate]
A route policy has two windows. Take `{ maxAge: 300, staleWhileRevalidate: 60 }` on `/blog/:slug`, and an article first requested at 12:00:00:
| Request at | What the visitor gets | Header |
| ----------- | ---------------------------------------------------------------------------------------- | ------------- |
| 12:00:00 | A fresh render, which is stored | `MISS` |
| 12:03:00 | The stored copy, without rendering | `HIT` |
| 12:05:30 | The stored copy right away, while a new render runs in the background and replaces it | `STALE` |
| 12:05:31 | The new copy | `HIT` |
| after 12:06 | If nobody asked during the stale window, the visitor waits for a fresh render | `MISS` |
`maxAge` (the TTL) is how long a copy is fresh. `staleWhileRevalidate` is a fixed extra window, never an open-ended one: after it, nobody gets the old copy. Leave it out, or set it to `0`, and a visitor always waits for the fresh render once the TTL ends.
When many visitors ask for the same uncached page at once, the server renders it once and hands the result to all of them. That only covers requests reaching the same server process.
## Public HTML and personal UI [#public-html-and-personal-ui]
A cached page is rendered once for everybody, so VitNode renders it as a signed-out visitor sees it:
- The render receives no cookies and no `Authorization` header, whoever asked.
- The main layout skips the session, exactly as it does for a [prerendered page](/docs/dev/deployments/prerendering#what-a-prerendered-page-leaves-out). The browser loads the account menu and header navigation after the page arrives.
- A response is stored only when it is a `200` HTML page that sets no cookie and read no API response marked `private` or `no-store`. Everything else, including errors, redirects and `404`s, is sent once and forgotten.
Signed-in visitors receive the same stored copy and see their account menu once the browser loads it. When the shared copy does not exist, for example a moderator opening a hidden article that answers `404` to everyone else, VitNode renders the page again for that visitor's own request. That copy is marked `private, no-store` and never stored. The same happens when the shared render sets a cookie, so the visitor still receives it.
A page component on a cached route must not read the session on the server. Put anything that depends on who is looking, such as an edit button, in a component that loads its data in the browser.
## Content Engine invalidation [#content-engine-invalidation]
Content Engine routes label their responses with what they were built from: the record (`content:blog.post:item:3`) or, for a list, the whole content type (`content:blog.post:list`). While a cached page renders, VitNode collects the labels of every API response the page read, and stores them with the page.
Every content write announces itself after its database transaction has committed, and VitNode then invalidates the pages that read that record or listed its type. Publishing, editing, translating, hiding, deleting, scheduled publishing and the API all go through that one path, so no write can skip it. Whether a stale copy may still be served depends on the change:
| Change | Mode | What visitors see |
| ----------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------- |
| Edit, publish, unhide, restore, translation added, edited or published | Soft | One more visitor may get the previous copy while the new one renders. |
| Hide, delete, unpublish, translation unpublished or deleted, slug changed | Hard | The old copy is never served again. The next request renders a fresh page. |
A hard invalidation also covers a render that started before the change and finishes after it: that render is sent to the visitor who asked, but not stored. After a slug change, the old address answers with its `308` redirect straight away.
Invalidation is per record, not per language. Editing the Polish translation also refreshes the English page, which lists the Polish one in its `hreflang` links anyway. Scheduled publication and creating a draft are announced too, but a draft that nobody can see changes no page.
Data your page reads from your own API routes is invalidated only if you label it. Call `setPageCacheTags(c, [tag])` in the route and `invalidatePageCacheTags([tag], { mode })` after the write. See the [reference](/docs/dev/cache/pages-reference#label-and-invalidate-your-own-data). Anything unlabelled, such as settings, shows up when the TTL ends.
## From an edit to the public page [#from-an-edit-to-the-public-page]
With the blog's policy, an article saved in the AdminCP reaches visitors like this:
1. The save commits. The page is invalidated before the API answers the editor.
2. The next visitor gets the previous copy once (soft) or a fresh render (hard).
3. Every visitor after that gets the new version.
Visitors who navigate inside the app load the article from the public API, which is never cached on the server, so they see the new version at step 1.
## How it differs from other caches [#how-it-differs-from-other-caches]
| Cache | What it stores | Shared by | Expires |
| ----------------------------------------------------------- | ---------------------------------------------------- | --------------- | ---------------------------------------- |
| [Prerendering](/docs/dev/deployments/prerendering) | HTML files written by `vitnode build` | Every visitor | On the next build |
| Page cache (this page) | HTML rendered on request | Every visitor | TTL, stale window, content changes |
| [App cache](/docs/dev/cache/app) (TanStack Query) | API responses, in one browser tab | One visitor | `staleTime`, `invalidateQueries` |
| [API cache](/docs/dev/cache/api) (`c.get("cache")`) | Values your API routes choose | Every request | Its TTL, `delete` after a write |
The page cache never stores API responses, and Content Engine public reads are not cached on the server, so a page rendered after an invalidation always reads the current database.
## Deployment limits [#deployment-limits]
- **Memory storage is per process.** Each process keeps its own copy, and a restart empties it. An invalidation only reaches the process that handled the write, which is fine for one process and wrong for several. Use `storage: "redis"` for more than one instance.
- **Separate API server.** In the default setup the API runs inside the web app and shares its cache. If your API runs in its own process, set `pageCache: { storage: "redis" }` in `buildApiConfig` too, with the same `REDIS_URL`, so writes reach the web app's cache.
- **Browsers and CDNs** receive `cache-control: no-cache` and an `ETag`. They may keep a copy but must ask the server each time, and the server answers `304 Not Modified` while the copy is current. No lifetime is advertised, because nothing could purge a CDN after a hide or a delete.
- **Clock skew.** A hard invalidation compares timestamps written by different servers. VitNode allows 2 seconds of difference between their clocks; keep them in sync with NTP.
- **Deployments.** Every build gets its own id in the cache key, so two versions running side by side never serve each other's HTML. Set `VITNODE_BUILD_ID` at build time, for example to a commit SHA, to choose the id yourself.
- **Storage outage.** If an invalidation cannot be written, VitNode does not report it as done. The write still succeeds, the failure is logged, scheduled publishing retries it, and the process that handled the write stops serving cached pages until the longest policy has expired. Other instances cannot be told and keep serving their copies until their TTL ends.
# Page cache reference (/docs/dev/cache/pages-reference)
Exact options and behavior of the page cache. For a walkthrough, see [Cache server-rendered pages](/docs/dev/cache/pages).
## Route `cache` option [#route-cache-option]
Accepted by `page()` and `index()` from `@vitnode/core/routing`.
| Name | Type | Required | Default | Description |
| ---------------------- | -------- | -------- | ------- | ------------------------------------------------------------------------ |
| `maxAge` | `number` | Yes | - | Seconds a stored page is fresh. Whole number from 1 to 31536000. |
| `staleWhileRevalidate` | `number` | No | `0` | Extra seconds a stale page may be served while it re-renders. 0 to 31536000. |
`cache: false` declares that the route is not cached, which is also the default. The build fails with `invalid-cache` for a layout, an unknown key or a duration outside these ranges. A policy on an AdminCP route, an `/api` path or a route with `requires` (its own or a parent layout's) fails the build too.
## `vitNodeConfig.pageCache` [#vitnodeconfigpagecache]
Set in `buildConfig()` in the host app's `src/vitnode.config.ts`.
| Name | Type | Required | Default | Description |
| --------- | -------------------------------------------- | -------- | ---------- | ----------------------------------------------------------------------------------- |
| `enabled` | `boolean` | No | `true` | `false` turns the cache off for every route. Development never caches either way. |
| `storage` | `"memory" \| "redis"` | No | `"memory"` | `"redis"` reads `REDIS_URL` when the server starts. |
| `routes` | `Record` | No | `{}` | Per-route override, keyed by canonical path (`"/blog/:slug"`). `false` disables. |
An override wins over the route's own `cache`, and may opt in one of the app's own route files. A key that matches no page route fails the build.
## `withPageCache(entry, options)` [#withpagecacheentry-options]
From `@vitnode/core/tanstack/page-cache/server`. Wraps a TanStack Start server entry and returns one with the same `fetch`.
| Name | Type | Required | Description |
| --------- | --------------------- | -------- | --------------------------------------------- |
| `buildId` | `string` | Yes | `pageCacheBuildId` from `src/page-cache.gen.ts` |
| `config` | `VitNodeConfig` | Yes | The app's `vitNodeConfig` |
| `routes` | `PageCacheRouteTable` | Yes | `pageCacheRoutes` from `src/page-cache.gen.ts` |
## Response headers [#response-headers]
| Header | Value | Meaning |
| ------------------ | ---------------------------------------------- | -------------------------------------------------------------------- |
| `x-vitnode-cache` | `HIT`, `STALE`, `MISS`, `REVALIDATED` | Served from the cache, stale while refreshing, rendered and stored, or re-rendered after expiry |
| `x-vitnode-cache` | `BYPASS` | The request skipped the cache, or the render could not be shared |
| `cache-control` | `no-cache` | A shared page: browsers and CDNs must revalidate with the `ETag` |
| `cache-control` | `private, no-cache` | A shared page that also sets this visitor's locale cookie |
| `cache-control` | `private, no-store` | A page rendered for one visitor |
Routes without a policy keep their usual headers and get no `x-vitnode-cache`.
## When a request skips the cache [#when-a-request-skips-the-cache]
A request on a cached route goes straight to the renderer when:
- its method is not `GET` or `HEAD` (`GET` and `HEAD` are cached separately);
- its query string has a parameter other than these analytics parameters, which are ignored: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `utm_id`, `fbclid`, `gclid`, `gbraid`, `wbraid`, `dclid`, `msclkid`, `yclid`, `twclid`, `ttclid`, `li_fat_id`, `igshid`, `mc_cid`, `mc_eid`, `_ga`, `_gl`. A `?preview=` link is one such parameter;
- the locale rules redirect it, for example `/en/blog/x` to `/blog/x`;
- the server could not write an invalidation and has paused the cache.
Entries are keyed by the request origin (scheme, host and port), the path, the method and the build id. Each language's URL and each domain therefore has its own entry.
## Label and invalidate your own data [#label-and-invalidate-your-own-data]
From `@vitnode/core/api/lib/page-cache`, for data a cached page reads from your own Hono routes.
| Function | Returns | Behavior |
| ------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `setPageCacheTags(c, tags)` | `void` | Labels the response. A cached page that reads it during its render is stored with these tags. |
| `invalidatePageCacheTags(tags, { mode })` | `Promise` | Invalidates every page stored with any of `tags`. `mode` is `"soft"` or `"hard"`. |
```ts title="plugins/events/src/api/modules/events/routes/list.route.ts"
import { setPageCacheTags } from '@vitnode/core/api/lib/page-cache'
// in the handler, before returning the response
setPageCacheTags(c, ['events:list'])
```
```ts title="after the write commits"
import { invalidatePageCacheTags } from '@vitnode/core/api/lib/page-cache'
const result = await invalidatePageCacheTags(['events:list'], { mode: 'soft' })
if (result.status === 'failed') {
await c.get('log').error('Cached event pages could not be invalidated.')
}
```
`status` is `"done"`, `"failed"` (with `error`) or `"not-configured"` when this process has no page cache, such as an API server without `pageCache` in `buildApiConfig`. Content Engine routes and writes already do this for you.
## `buildApiConfig({ pageCache })` [#buildapiconfig-pagecache-]
Only for an API that runs in its own process next to a web app using `storage: "redis"`.
| Name | Type | Required | Default | Description |
| ------------- | --------- | -------- | ------- | ---------------------------------------------------------------------- |
| `storage` | `"redis"` | Yes | - | Writes invalidations to the Redis at `REDIS_URL`. |
| `maxLifetime` | `number` | No | `86400` | Longest `maxAge + staleWhileRevalidate` of any cached route, in seconds. |
# Cache server-rendered pages (/docs/dev/cache/pages)
The page cache stores the complete HTML a public page renders and sends the same copy to the next visitors. Use it for pages that look the same for everyone, such as a blog article, when they still have to be rendered on the server because their content lives in the database.
A route is never cached unless it asks to be. This guide caches the blog plugin's article page, `/blog/:slug`.
### Declare a cache policy on the route [#declare-a-cache-policy-on-the-route]
Add `cache` to the page in your plugin's `src/routes.ts`. Both durations are in seconds:
```ts title="plugins/blog/src/routes.ts"
import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
export const routes = definePluginRoutes([
page('/blog/:slug', {
cache: { maxAge: 300, staleWhileRevalidate: 60 }, // [!code ++]
component: lazy(() => import('./pages/post-page')),
messages: ['@vitnode/blog.post'],
}),
])
```
`maxAge: 300` keeps a rendered article fresh for 5 minutes. `staleWhileRevalidate: 60` adds one more minute in which a visitor may still get the previous copy while a fresh one renders in the background. [How cached pages stay fresh](/docs/dev/cache/pages-invalidation#ttl-and-stale-while-revalidate) walks through the timeline.
`index()` routes accept the same option. A layout, an AdminCP page and a route with `requires` cannot be cached, and the build fails if one declares a policy.
### Serve pages through the cache [#serve-pages-through-the-cache]
Wrap TanStack Start's server entry in your app's `src/server.ts`. Apps created with `create-vitnode-app` already have this file:
```ts title="apps/web/src/server.ts"
import handler from '@tanstack/react-start/server-entry'
import { withPageCache } from '@vitnode/core/tanstack/page-cache/server'
import { pageCacheBuildId, pageCacheRoutes } from './page-cache.gen'
import { vitNodeConfig } from './vitnode.config'
export default withPageCache(handler, {
buildId: pageCacheBuildId,
config: vitNodeConfig,
routes: pageCacheRoutes,
})
```
`src/page-cache.gen.ts` is written by VitNode's Vite plugin on every `vitnode dev` and `vitnode build`. It holds every page route with the policy that applies to it, so commit it like the other `*.gen.ts` files.
### Choose the storage and override plugin policies [#choose-the-storage-and-override-plugin-policies]
Configure the cache once, in the host app. Every key is optional:
```ts title="apps/web/src/vitnode.config.ts"
export const vitNodeConfig = buildConfig({
// ...
pageCache: {
storage: 'redis', // reads REDIS_URL when the server starts
routes: {
'/blog/:slug': { maxAge: 600, staleWhileRevalidate: 120 },
'/events/:slug': false,
},
},
})
```
| Storage | Where entries live | Use it when |
| ---------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `"memory"` | Inside each server process (default) | You run one server process. A restart empties the cache, and each process would keep its own copy. |
| `"redis"` | The Redis at the `REDIS_URL` variable | You run more than one instance. All of them share entries, and an invalidation reaches every instance. |
`routes` keys are canonical route paths, the same spelling the route was declared with. A policy replaces the plugin's, `false` turns caching off for that route, and you can also opt in one of your app's own route files. A key that matches no page route fails the build. `enabled: false` turns the whole cache off.
The Redis URL stays in the runtime environment and never ends up in the build. If `storage` is `"redis"` and `REDIS_URL` is missing, the server logs an error and serves pages without caching them.
### Build and start the app [#build-and-start-the-app]
The cache only runs in production. Development always renders fresh, so an edit shows up on reload.
```bash tab="bun"
bun run build && bun start
```
```bash tab="pnpm"
pnpm build && pnpm start
```
```bash tab="npm"
npm run build && npm start
```
The build's route summary shows the policy that each route actually got. See [Build and start for production](/docs/dev/cli/build#see-how-each-page-is-served).
## Check the result [#check-the-result]
Request a published article twice and look at the `x-vitnode-cache` header:
```bash
curl -s -o /dev/null -D - http://localhost:3000/blog/hello-world | grep -i 'x-vitnode-cache\|cache-control'
curl -s -o /dev/null -D - http://localhost:3000/blog/hello-world | grep -i 'x-vitnode-cache\|cache-control'
```
```text
cache-control: no-cache
x-vitnode-cache: MISS
cache-control: no-cache
x-vitnode-cache: HIT
```
The first request rendered the page. The second was answered from the cache without rendering. Edit the article in the AdminCP and request it again: you get `STALE` once, then the new text. Hide it and the next request answers `404` immediately.
`BYPASS` means the request skipped the cache, for example because of a `?preview=` link or another query parameter. The [reference](/docs/dev/cache/pages-reference#when-a-request-skips-the-cache) lists every reason.
## Related [#related]
- [How cached pages stay fresh](/docs/dev/cache/pages-invalidation)
- [Page cache reference](/docs/dev/cache/pages-reference)
- [Prerender static pages](/docs/dev/deployments/prerendering)
# Cache reference (/docs/dev/cache/reference)
`c.get("cache")` is available in every Hono route handler. It stores JSON values in Redis under a per-plugin prefix and does nothing when Redis is not configured. For a step-by-step example, see [Cache API data](/docs/dev/cache/api).
## Methods [#methods]
| Method | Returns | Behavior |
| -------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `get(key)` | `Promise` | Reads and parses a value. Returns `null` on a miss, on a Redis error, or without Redis. |
| `set(key, value, ttlSeconds?)` | `Promise` | Stores `value` as JSON. Without `ttlSeconds`, or with `0`, the key never expires. |
| `has(key)` | `Promise` | Whether the key exists. `false` on a Redis error or without Redis. |
| `remember(key, ttlSeconds, loader)` | `Promise` | Returns the cached value when `get` finds one. Otherwise runs `loader`, stores its result with `set` and returns it. |
| `delete(key)` | `Promise` | Deletes one key, or every key in an array. |
| `flush()` | `Promise` | Deletes every key of the current plugin. Other plugins' keys and unrelated data in the same Redis instance are left untouched. |
| `acquireLock(key, ttlSeconds)` | `Promise` | Takes a lock that expires after `ttlSeconds`. `true` when this call holds the lock, `false` when another caller does. |
| `releaseLock(key)` | `Promise` | Releases a lock taken with `acquireLock`. |
| `status()` | `Promise<{ configured: boolean; connected: boolean }>` | Pings Redis. Both fields are `false` without Redis. |
`getSystem`, `setSystem` and `deleteSystem` also exist. They write to a namespace shared with VitNode core, so plugins should use the plugin-scoped methods above.
## Keys [#keys]
| Method | Stored as |
| -------------------------------------------------- | ------------------------------------- |
| `get`, `set`, `has`, `remember`, `delete`, `flush` | `vitnode:cache:{pluginId}:{key}` |
| `acquireLock`, `releaseLock` | `vitnode:cache:__system__:lock:{key}` |
The plugin ID comes from the route that handles the request. Lock keys are not prefixed with it, so include your plugin ID in a lock key, for example `@acme/site-notes:rebuild-stats`.
## Serialization [#serialization]
Values go through `JSON.stringify` and `JSON.parse`:
- A `Date` comes back as an ISO string, and a `Map` or `Set` comes back as `{}`.
- `null` is stored, but `get` returns `null` for it, so `remember` treats it as a miss and runs the loader again.
- `undefined` is not stored.
## Without Redis and when Redis fails [#without-redis-and-when-redis-fails]
When the `redis` option in `vitnode.api.config.ts` is not set, every read misses, every write does nothing, and `remember` always runs its loader.
When Redis is configured but fails, the error never reaches your route. A failed read counts as a miss and a failed write is skipped. Commands fail immediately while Redis is unreachable instead of waiting in a queue, so an outage does not stall requests.
Locks behave differently, because a lock exists to stop work from running twice:
| Situation | `acquireLock` returns |
| -------------------- | --------------------- |
| Redis connected | `true` or `false` |
| Redis not configured | `true` |
| Redis error | `false` |
Without Redis, every caller gets the lock. If two instances must never run the same work at once, guard it in the database as well, for example with `FOR UPDATE SKIP LOCKED`.
# Cloudflare Turnstile (/docs/dev/captcha/cloudflare)
Turnstile is the provider to reach for first: a visible widget, no score to
tune, and a set of dummy keys that work on `localhost`. There is nothing to
install - the provider lives in core - so the whole job is two keys and one
config block.
## Quick start [#quick-start]
If you already have a site key and a secret key, this is the entire
integration.
```bash title=".env"
CLOUDFLARE_TURNSTILE_SITE_KEY=0x4AAAAAAA...
CLOUDFLARE_TURNSTILE_SECRET_KEY=0x4AAAAAAA...
```
```ts title="src/vitnode.api.config.ts"
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
plugins: [],
// [!code ++:5]
captcha: {
type: 'cloudflare_turnstile',
siteKey: process.env.CLOUDFLARE_TURNSTILE_SITE_KEY,
secretKey: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY,
},
})
```
Restart the API and the widget appears on `/register`, and on
`/login/reset-password` on a deployment that has an
[email adapter](/docs/dev/email) to send the link with. The rest of this page is
how to get those two values.
An empty `secretKey` does not disable captcha - it breaks it. `captchaMiddleware`
steps aside only when the whole `captcha` block is missing, so a deployment with
the block and no keys **rejects** registration and password reset with `400`.
Gate the block on the environment variable:
```ts title="src/vitnode.api.config.ts"
export const vitNodeApiConfig = buildApiConfig({
// [!code ++:7]
captcha: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY
? {
type: 'cloudflare_turnstile',
siteKey: process.env.CLOUDFLARE_TURNSTILE_SITE_KEY,
secretKey: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY,
}
: undefined,
})
```
## Create the widget [#create-the-widget]
### Sign in to Cloudflare [#sign-in-to-cloudflare]
Open the [Cloudflare dashboard](https://dash.cloudflare.com/) and sign in.
Turnstile is an independent product - Cloudflare's own words are that you can
use it "on any website, regardless of whether it is proxied through the
Cloudflare network" - so there is no DNS to move before you start.
### Open Turnstile [#open-turnstile]
Pick **Turnstile** in the account sidebar, below **WAF**.
### Add a widget [#add-a-widget]
Press **Add widget** and fill in three things, then **Create**:
| Field | What to put in it |
| ----------------------- | ------------------------------------------------------------------------ |
| **Widget name** | Anything - it is internal. Your site's name is a fine answer. |
| **Hostname management** | Every hostname you serve the form from, e.g. `your-domain.com`. |
| **Widget mode** | **Managed** unless you have a reason. All three modes work with VitNode. |
{/* Image prompt: The Cloudflare Turnstile "Add widget" form filled in with a widget name, one entry under Hostname management, and the "Managed" widget mode option selected out of Managed / Non-Interactive / Invisible, light theme, 1100x700. */}
### Copy the site key and the secret key [#copy-the-site-key-and-the-secret-key]
Cloudflare shows both immediately after the widget is created, and you can come
back to them from the widget's **Settings** tab at any time.
The **site key** is public - the API publishes it so the browser can load the
widget. The **secret key** is not: it is only ever sent from your API to
Cloudflare's `siteverify` endpoint.
{/* Image prompt: The Cloudflare Turnstile widget detail page showing the "Site Key" and "Secret Key" fields with copy buttons, values partially redacted, light theme, 1100x500. */}
### Set the environment variables [#set-the-environment-variables]
These names are not magic - they are whatever you read in your config on the
next step. What matters is that both are server-side variables, because the
secret key must never reach a browser bundle.
```bash title=".env"
CLOUDFLARE_TURNSTILE_SITE_KEY=0x4AAAAAAA... # [!code ++]
CLOUDFLARE_TURNSTILE_SECRET_KEY=0x4AAAAAAA... # [!code ++]
```
### Register the provider [#register-the-provider]
Add the `captcha` block to your API config with `type: 'cloudflare_turnstile'`.
```ts title="src/vitnode.api.config.ts"
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
plugins: [],
// [!code ++:5]
captcha: {
type: 'cloudflare_turnstile',
siteKey: process.env.CLOUDFLARE_TURNSTILE_SITE_KEY,
secretKey: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY,
},
})
```
The config is evaluated when the process boots, so restart the API rather than
waiting for a hot reload to notice.
### Verify it works [#verify-it-works]
Ask the API what it thinks it has configured. The site key comes back on the
public middleware route:
```bash
curl http://localhost:3000/api/@vitnode/core/middleware
# {"isEmail":false,"sso":[],"captcha":{"siteKey":"0x4AAAAAAA...","type":"cloudflare_turnstile"}}
```
Then open `/register`. A Turnstile widget renders just above the **Register**
button, and the button stays disabled until the widget reports success.
The AdminCP confirms it from the other side: **AdminCP → System →
Integrations** (`/admin/core/system/integrations`) shows the **Captcha** card as
_Active_, with **Cloudflare Turnstile** underneath.
## Test keys for local development [#test-keys-for-local-development]
Cloudflare publishes dummy keys that work on any hostname, `localhost`
included. Use them while you are wiring the form up, and to reproduce failures
on purpose.
The two halves are independent: a site key decides what the widget does in the
browser, a secret key decides what `siteverify` answers. Pick one of each.
### Test site keys [#test-site-keys]
| Site key | Widget | What it does |
| -------------------------- | --------- | ------------------------------- |
| `1x00000000000000000000AA` | Visible | Always passes |
| `2x00000000000000000000AB` | Visible | Always fails |
| `1x00000000000000000000BB` | Invisible | Always passes |
| `2x00000000000000000000BB` | Invisible | Always fails |
| `3x00000000000000000000FF` | Visible | Forces an interactive challenge |
### Test secret keys [#test-secret-keys]
| Secret key | What `siteverify` answers |
| ------------------------------------- | ------------------------- |
| `1x0000000000000000000000000000000AA` | Always passes |
| `2x0000000000000000000000000000000AA` | Always fails |
| `3x0000000000000000000000000000000AA` | `token already spent` |
A test site key mints the token `XXXX.DUMMY.TOKEN.XXXX`, which a real secret key
rejects - so mixing a dummy site key with a production secret is its own kind of
`400`.
All of them come from
[Cloudflare's testing reference](https://developers.cloudflare.com/turnstile/troubleshooting/testing/),
which is also where the failure modes each one reproduces are spelled out.
## Widget modes [#widget-modes]
All three of Cloudflare's modes work, because VitNode renders the widget through
`turnstile.render` and waits for its callback either way.
| Mode | What the visitor sees | Submit button |
| ------------------- | ------------------------------------------ | ---------------------------------- |
| **Managed** | A checkbox, only when Cloudflare wants one | Unlocks when the widget calls back |
| **Non-Interactive** | A spinner, and never a checkbox | Unlocks when the widget calls back |
| **Invisible** | Nothing at all | Unlocks when the widget calls back |
Invisible mode is the one worth thinking twice about: the button is disabled for
a moment with nothing on screen explaining why.
The widget also inherits two things from the page it renders on - the visitor's
locale, and your site's resolved theme - so it follows the language switcher and
dark mode with no configuration of its own.
## Gotchas [#gotchas]
Turnstile refuses to render for a hostname that is not on the widget's list,
and VitNode's submit button simply never unlocks. There is no toast and no
server error, because nothing was ever submitted. Check the browser console
for Turnstile's own message, then add the hostname - or switch to the test
keys above while developing.
`siteverify` answers `success: true` or `success: false`, and VitNode scores
that as `1` or `0`. How much the visitor is asked to do is the widget mode you
picked in Cloudflare's dashboard; how suspicious they looked is Cloudflare's
call, and it is not a number you get to see or set. If you want one to tune,
that is [reCAPTCHA v3](/docs/dev/captcha/recaptcha).
The API config reads `process.env` when the process starts, so a new key in
`.env` only takes effect after you restart the dev server.
## Next [#next]
# Custom forms (/docs/dev/captcha/custom-adapter)
`AutoForm` handles captcha for you, and most of the time that is the end of it.
When you are rendering the form yourself - a hand-built `