Documentation

Public write API

Reference for Content Engine publicWrite, with options and defaults, writable fields, generated create and edit routes, request and response bodies, status and error codes, createContentPublicWriteService and definition errors.

This page lists everything publicWrite accepts and everything its routes answer. For a guided setup, follow Public write. Examples use example.discussion from the example plugin, whose write routes live at /api/@vitnode/example/content/discussions.

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.

plugins/example/src/content/discussion.ts
publicWrite: {
  ownership: { field: "author" },
  create: { fields: ["title", "body"], status: "published" },
  update: { fields: ["title", "body"] },
},
NameTypeRequiredDefaultDescription
ownership.fieldfield nameYesA single, shared field.user(). The server stamps it with the signed-in user on create
createobjectNooffAdds POST /. At least one of create and update is required
create.fieldsfield namesYesNon-empty allowlist of 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. Container policies take over when the container collection has them
create.captchabooleanNofalseRequires a captcha token on every create, when a captcha provider is configured
updateobjectNooffAdds GET /{id}/edit and PATCH /{id}, and registers the can_edit moderator permission
update.fieldsfield namesYesNon-empty allowlist of writable fields
update.ownbooleanNotrueLets the owner edit their own record while it is not hidden and the container's authorEdit allows it. 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

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:

FieldWhy
fileA file id names somebody's upload, and the upload routes are AdminCP-only
userA user id names a person. Ownership is stamped by the server instead
group, repeatable, blocksEach 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 sourceDerived on the server. List its source field instead, so nobody can squat on another address
A localized: true fieldPublic writes do not create translations yet
The ownership fieldOnly the server sets the owner
id, createdAt, updatedAt, status, publishedAt, version, hiddenAt, hiddenBy, visibilityManaged 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.

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.

When the content type lives in a container collection with publishing policies, can_edit is scoped by that collection and can_approve is added next to it. See Container-scoped permissions.

Generated routes

The routes live in the public module, under /api/{pluginId}/content/{publicApi.path}, next to the public read routes. Only the ones publicWrite enables are mounted, and the fetcher types them the same way: a post to a content type without create is a compile error.

Method and pathEnabled byBodySuccess
POST /create{ values }201 with the record
GET /{id}/editupdate200 with the record, drafts included, Cache-Control: private, no-store
PATCH /{id}update{ values, expectedVersion }200 with the record
GET /permissions?ids=1,2update or reports200 with { items: [{ id, canEdit, canReport }] }, Cache-Control: private, no-store

GET /{id}/edit and PATCH /{id} answer only for the owner and for moderators. There is no public delete, publish or hide route.

GET /permissions takes up to 50 comma-separated ids and answers for guests too. canEdit follows the same rules as PATCH; canReport is true for a public record, with reports on, that the visitor did not write. A record the visitor can neither read nor edit is left out of items, so the route never confirms a draft exists.

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

Both bodies are strict JSON objects. A key outside the allowlist is refused, never dropped.

BodyFieldRules
POST /valuesThe fields in create.fields. Required fields must be present; optional fields may be left out
PATCH /{id}valuesAny non-empty subset of update.fields. Only the fields sent change
PATCH /{id}expectedVersionPositive integer, required on an editorial 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.

PATCH request
PATCH /api/@vitnode/example/content/discussions/1
Content-Type: application/json
Cookie: vitnode_auth=<session>

{ "values": { "title": "How do I add a widget zone below an article?" }, "expectedVersion": 2 }

Response record

Every successful response is the same record shape:

NameTypeDescription
idnumber or stringA string for the uuid and bigint ID strategies
slugstring | nullThe value of the publicApi slug field, the key of GET /{slug}
status"draft" | "published"
publishedAtISO date string | null
versionnumberEditorial content types only. Send it back as expectedVersion
approvalobject | nullThe record's pending submission, or a rejection nothing replaced yet. See Approvals
valuesobjectEvery field in create.fields or update.fields, null when empty. Nothing else
200
{
  "approval": null,
  "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

StatusBodyWhen
200RecordGET /{id}/edit or PATCH /{id} succeeded
201RecordPOST / succeeded
400Rejection, CONTENT_INVALID_INPUTThe body is not JSON, breaks a rule, names a field outside the allowlist, or links to a record that is not available
400Plain textcreate.captcha is on and the captcha token is missing or invalid
401Rejection, CONTENT_UNAUTHENTICATEDNo user session
403Rejection, CONTENT_FORBIDDENThe record is publicly readable, but you are not its owner or a moderator, the author's edit window closed, or the container lets only moderators create
404Rejection, CONTENT_NOT_FOUNDThe record does not exist, or it is a draft or hidden and you may not edit it
409ConflictA unique value is taken, or the record changed since expectedVersion
429{ error, retryAfter }The rate limit is used up

Validation failures are 400, the status every VitNode input error uses, not 422.

Rejection

400, 401, 403 and 404 answer with one JSON shape, zodContentPublicWriteRejection from @vitnode/core/content:

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 and 404, and for 403 unless a container policy refused the write. 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:

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:

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:

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:

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

409 answers with zodContentConflict, the same shape the AdminCP's editorial routes use:

409 version conflict
{
  "code": "CONTENT_VERSION_CONFLICT",
  "contentTypeId": "example.discussion",
  "currentVersion": 4,
  "expectedVersion": 3,
  "itemId": 1
}
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

POST and PATCH requests to a path containing /content/, outside /admin/, share a budget of 20 requests per 60 seconds, on top of the write budget every mutation spends. Change it with rateLimiter.contentWrite in apps/api/src/vitnode.api.config.ts. The budget counts per signed-in user when the request carries a session cookie, and per IP otherwise (rateLimiter.perUser, on by default). It is off when NODE_ENV=development. See Rate limiter for the 429 body and Redis-backed counters.

Codes

ConstantValue
CONTENT_PUBLIC_WRITE_CODES.invalidInputCONTENT_INVALID_INPUT
CONTENT_PUBLIC_WRITE_CODES.unauthenticatedCONTENT_UNAUTHENTICATED
CONTENT_PUBLIC_WRITE_CODES.forbiddenCONTENT_FORBIDDEN
CONTENT_PUBLIC_WRITE_CODES.notFoundCONTENT_NOT_FOUND
CONTENT_CONFLICT_CODES.versionCONTENT_VERSION_CONFLICT
CONTENT_CONFLICT_CODES.uniqueCONTENT_UNIQUE_CONFLICT

Issue codes:

CodeSourceMeaning
not_writableCONTENT_PUBLIC_WRITE_ISSUE_CODES.notWritableA key in values outside the allowlist. One issue per key
unavailableCONTENT_PUBLIC_WRITE_ISSUE_CODES.unavailableA relation names a record that is missing, a draft or hidden
invalid_jsonCONTENT_PUBLIC_WRITE_ISSUE_CODES.invalidJsonThe body is not JSON
edit_window_closedCONTENT_PUBLIC_WRITE_ISSUE_CODES.editWindowClosedOn 403: the container's authorEdit no longer lets the owner edit
moderators_onlyCONTENT_PUBLIC_WRITE_ISSUE_CODES.moderatorsOnlyOn 403: the container has create: "moderators" and you hold no can_edit there
too_small, too_big, invalid_type, invalid_valueZodA value breaks the field's rules, such as minLength or an enum
customZodA custom rule, such as an empty required rich text document or an update with no fields
unrecognized_keysZodA 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.

import { parseContentPublicWriteRejection } from "@vitnode/core/content";

const rejection = parseContentPublicWriteRejection(
  await res.json().catch(() => null),
);

Frontend helpers

From @vitnode/core/tanstack/content, for pages in the browser.

useContentPermissions

const permissions = useContentPermissions({ fetch, ids, queryKey });
permissions.of(id); // { canEdit, canReport }
OptionTypeDescription
idsreadonly (number | string)[]The records on screen, at most 50
fetch(ids) => Promise<{ items }>Calls GET /permissions through fetcher with ids.join(",")
queryKeyQueryKeyThe content type's query prefix; invalidating it refreshes the answer

of(id) returns all false until the answer arrives and for a record left out. The hook waits for the session and asks again when somebody signs in or out.

useContentPublicWrite

const write = useContentPublicWrite({ fields, latestVersion, uniqueField, version });
const record = await write.submit(form, async expectedVersion => await fetcher(/* ... */));
OptionTypeDescription
fieldsreadonly string[]The form's fields. Server issues on them show under the field; others are a toast
uniqueFieldstringWhere "already taken" shows on 409 CONTENT_UNIQUE_CONFLICT. Without it, a toast
versionnumberThe version the edit form opened with. Leave it out for a create
latestVersion() => Promise<number | null>Reads the current version for Keep my version. Without it, only a reload is offered

submit returns the record on a 2xx and null otherwise, after showing the reason. It passes the current expectedVersion to request and stores the new one after each save. write.conflict is the version-conflict alert, or null; render it above the form. Messages come from core.global.content_write and follow the reader's language.

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({ 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.

MethodReturnsBehavior
create(values)Promise<ContentPublicWriteRecord>Validates values against create.fields, stamps the owner, checks relation targets, creates and, when the policy says so, publishes or opens an approval submission in one transaction. Emits events and syncs search after the commit
findEditable(id)Promise<ContentPublicWriteRecord>Reads one record for its owner or a moderator, drafts included
update(id, body)Promise<ContentPublicWriteRecord>Validates { values, expectedVersion }, locks the row, authorizes, checks relation targets and writes, or stores a proposal when the container reviews edits, in one transaction. Emits events and syncs search after the commit

The methods throw instead of answering:

ErrorWhen
ContentPublicWriteDeniedreason is "unauthenticated", "forbidden" or "notFound". Also "notFound" when create or update is off. issue is "edit_window_closed", "moderators_only" or null
ContentPublicWriteInputErrorA relation target is not available. Carries field and detail
ZodErrorvalues or the body breaks the allowlist or a field rule
ContentVersionConflictexpectedVersion is out of date (editorial content types)
Postgres unique violationA 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:

plugins/example/src/api/modules/discussions/ask.route.ts
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 { buildRoute } from "@/api/builders";
import { discussionContent } from "@/database/discussions";
import { manifest } from "@/manifest.gen";

export const askRoute = buildRoute({
  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: manifest.id,
    });

    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 general write budget, which is why this one asks for a captcha.

Helpers

FunctionReturnsBehavior
resolveContentPublicWriteActor(c){ type: "api", userId } | nullThe signed-in user of the public session. Never an AdminCP session, never the body
authorizeContentPublicWrite(c, { action, actor, container?, definition, pluginId, policy?, row }){ allowed, via, reason? }The owner and moderator rules above, for a stored row. action is "update". With a policy, a closed edit window gives reason: "editWindowClosed"
contentPublicWriteRefusal(row)"forbidden" | "notFound""forbidden" only when the public can already read row
resolveContentPublicationPolicy({ actor, c, container, definition, operation, pluginId }){ approval, policy, status }How a public create starts. Without container policies, status is publicWrite.create.status. With them, see Publishing policies reference

Definition errors

defineContentType checks publicWrite when the definition loads and throws a ContentEngineError that starts with [Content Engine] <id>:. Relation targets that were still loading are checked again when the routes are built at boot.

ProblemError starts with
No publicApipublicWrite needs `publicApi`.
A localized content typepublicWrite is not available on a localized content type yet.
Not an object or falsepublicWrite is an options object or `false`.
An unknown optionpublicWrite.x is not an option. publicWrite accepts: create, ownership, update.
deletepublicWrite.delete is not supported.
permissions, create.permission or update.permissionpublicWrite.permissions is not an option.
No ownershippublicWrite.ownership is required
ownership.assignOnCreatepublicWrite.ownership.assignOnCreate is not an option.
ownership.field is unknownpublicWrite.ownership.field references unknown field "x".
ownership.field is not a single, shared user fieldpublicWrite.ownership.field "x" must be a single, shared `field.user()`.
Neither create nor updatepublicWrite enables nothing.
An empty fields listpublicWrite.create.fields is empty. There is no wildcard
A field listed twicepublicWrite.create.fields lists "x" twice.
A group leafpublicWrite.create.fields includes the leaf path "x".
A server-managed columnpublicWrite.create.fields includes "x", which the server manages.
The ownership fieldpublicWrite.create.fields includes the ownership field "x".
An unknown fieldpublicWrite.create.fields references unknown field "x".
A localized fieldpublicWrite.create.fields includes the localized field "x".
A kind that cannot be writtenpublicWrite.create.fields includes "x" of kind "file", which cannot be written publicly.
A slug with a sourcepublicWrite.create.fields includes the slug "slug", which is derived from "title"
A relation whose target has no publicApipublicWrite.create.fields includes the relation "x", but its target "y" has no public API.
A required field missing from create.fieldspublicWrite.create.fields leaves out "x", which every new record needs.
A container field in update.fieldspublicWrite.update.fields includes the container field "x".
create.status is not a statuspublicWrite.create.status is "x". It is one of: draft, published.
create.captcha or update.own is not a booleanpublicWrite.create.captcha is `true` or `false`.

The field-list errors read publicWrite.update.fields for the update list.