Public write
Let signed-in members create and edit Content Engine records from the public site with publicWrite, with server-stamped ownership, moderator permissions, strict field allowlists and rate limiting.
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
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
You need a content type with publication and a 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
Create src/content/discussion.ts. The publicWrite block is the new part:
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"],
},
publicWrite: {
ownership: { field: "author" },
create: { fields: ["title", "body"], status: "published" },
update: { fields: ["title", "body"] },
},
admin: {
path: "example/discussions",
titleField: "title",
},
});ownership.fieldnames a singlefield.user(). The server fills it with the signed-in user on create.create.fieldsandupdate.fieldsare allowlists with no wildcard. A field you add later stays private until you list it.slugis derived fromtitleon the server, so you listtitleinstead.create.statusis what a new record starts as. It defaults to"draft"; discussions go live straight away.update.owndefaults totrue, so owners can edit their own records.
Every option and rule is in the public write reference.
Create the model
Create src/database/discussions.ts and export the table so it reaches a migration:
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
Add the model to buildContentPublicModule in your API plugin. The module mounts the read routes and, because of publicWrite, the write routes:
import { discussionContent } from "@/database/discussions";
export const exampleApiPlugin = () =>
buildApiPlugin({
pluginId: CONFIG_PLUGIN.pluginId,
modules: [
adminModule,
buildContentPublicModule({
pluginId: CONFIG_PLUGIN.pluginId,
contentTypes: [articleContent, discussionContent],
}),
],
});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.
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:
{
"@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
Run this from the workspace root, then restart pnpm dev so the API registers the new routes:
bun run build:plugins && bun run db:migrateThe migration creates example_discussions with an author column that references core_users. Database covers migrations in detail.
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:
curl -i -X POST http://localhost:3000/api/@vitnode/example/content/discussions \
-b "vitnode_auth=<session>" \
-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:
{
"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:
curl -i -X POST http://localhost:3000/api/@vitnode/example/content/discussions \
-b "vitnode_auth=<session>" \
-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:
{
"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
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
Creating needs a signed-in user and nothing else. Editing is allowed for two kinds of people:
- The owner, while
update.ownistrue(the default) and the record is not hidden. A record a moderator hid is the moderator's to restore, not the author's to keep editing. Setupdate.own: falseto let only moderators edit. - Moderators who hold the content type's
can_editmoderator permission, directly or through a role.
To make someone a discussion moderator:
- Open AdminCP → Staff → Moderators and click Add Moderator.
- Pick a Role or a User and click Add member. The permission editor opens.
- 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
Each layer below holds even if the one before it has a bug:
- Separate allowlists.
publicWritefields are independent ofpublicApi.fields. A field can be writable without being public, and public without being writable. Server-managed columns (id,status,publishedAt,version,hiddenAtand friends), the owner field, file, user, group, repeatable and block fields can never be listed. See 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
401before 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 a400with theunavailableissue code. Missing, draft and hidden targets get the same answer. - No existence leaks.
403is only for records the public can already read. A draft or a hidden record answers404, like a record that does not exist. - Rate limit and captcha.
POSTandPATCHrequests under/content/share a budget of 20 per minute per IP, on top of the global limit. Setcreate.captcha: trueto require a 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:
export const vitNodeApiConfig = buildApiConfig({
rateLimiter: {
contentWrite: { points: 10, duration: 60 },
},
});Events, search and revisions
Public writes trigger the same side effects as AdminCP writes, so nothing new needs wiring:
- Events:
createdon create, thenpublishedwhencreate.statusis"published", andupdatedwithchangedFieldson an edit that changed something. There are no separate public-write events. - Search is synced after the transaction commits, so a discussion published on create is searchable straight away.
- On an editorial content type, each create and edit stores a revision attributed to the signed-in user, and staff can restore it from the AdminCP.
Containers
A content type organized into 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
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.