Logo VitNode

Public write API

Reference for Content Engine publicWrite - 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
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. 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.

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 /{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

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
valuesobjectEvery field in create.fields or update.fields, null when empty. Nothing else
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

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
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, 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:

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 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 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
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),
);

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

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, 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

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.