Documentation

Public API

Let anyone read the published records of a VitNode Content Engine content type through generated, typed API routes, and choose exactly which fields leave the server.

publicApi gives a content type read-only routes that anyone can call: one for a list and one for a single record. Only published records come back, and only the fields you name. Your pages read these routes; nothing is public until you add them.

You need publication first, so drafts can never leak.

Choose the public fields

Add publicApi with a URL path and the fields readers may see:

plugins/example/src/content/discussion.ts
export const discussionContentType = defineContentType({
  id: "example.discussion",
  // ...
  publication: true,
  publicApi: {
    path: "discussions",
    fields: ["id", "title", "slug", "body", "board", "publishedAt"],
  },
});
  • fields has no wildcard. A field you add to the content type later stays private until you list it.
  • Exactly one slug field must be in the list, because the detail route finds records by it.
  • User fields never appear here. To show an author, add people: ["author"], which returns a small public profile instead.

Search, sorting, filters and lighter list rows are a few more options, covered in Public lists.

Register the routes

Add the model to buildContentPublicModule in the plugin's API config:

plugins/example/src/config.api.ts
import { discussionContent } from "@/database/discussions";

export const exampleApiPlugin = () =>
  buildApiPlugin({
    manifest,
    modules: [
      buildContentPublicModule({
        contentTypes: [discussionContent],
      }),
    ],
  });

pnpm dev serves the new routes as soon as you save. publicApi adds no columns, so there is nothing to migrate.

Read it from a page

Call the routes with the fetcher. The module is content/ plus path, and the route, its query and the response are all typed:

plugins/example/src/pages/discussion-page.tsx
import { fetcher } from "@vitnode/core/tanstack/fetcher";

import { manifest } from "@/manifest.gen";

const res = await fetcher({
  plugin: manifest.id,
  method: "get",
  module: "content/discussions",
  path: "/{slug}",
  args: { params: { slug: encodeURIComponent(slug) } },
});

if (res.status === 200) {
  const discussion = await res.json(); // { id, title, slug, body, board, publishedAt }
}

A field you did not list, or a module that does not exist, is a compile error.

The routes

All routes live under /api/{pluginId}/content/{publicApi.path}.

RouteReturns
GET /Published records, a page at a time: { edges, pageInfo }
GET /{slug}One published record, or 404
GET /permissions?ids=1,2What the visitor may do with each record: canEdit, canReport. With publicWrite or reports
/delivery/...Page URLs, SEO data and sitemap entries. With delivery
GET /thread?root={id}Nested replies under a record. With thread
POST /, PATCH /{id}Create and edit from the site. With publicWrite

Each record comes back with exactly the listed fields. Dates are ISO strings, a relation is { id }, a container field is the container's id, and a file is { id, name, url, mimeType, size, width, height }. A draft, a record scheduled for later, a hidden record and a typo all answer the same 404. A record that belongs to a parent - its thread root, or its search context relation - is only as public as that parent: once the topic is hidden or unpublished, its comments drop out of the list and their detail answers 404 too. The list adds the hidden records a signed-in author, editor or moderator may see, marked hidden: true.

The list accepts first (up to 50), page or cursor, search, orderBy, order and one parameter per filterable field. Public lists turns it into a page with search, filters and pagination.

Check the result

Call the list route while pnpm dev runs:

curl "http://localhost:3000/api/@vitnode/example/content/discussions?first=1"
Response (trimmed)
{
  "edges": [
    {
      "id": 13,
      "title": "Show the author on list rows",
      "slug": "show-the-author-on-list-rows",
      "board": 3,
      "publishedAt": "2026-10-20T16:04:32.879Z",
      "author": { "id": 2, "name": "Ada Lovelace", "nameCode": "Ada-Lovelace" }
    }
  ],
  "pageInfo": { "totalCount": 12, "totalPages": 12, "hasNextPage": true }
}

Pages turn these responses into what readers see. The example plugin's /discussions page, from Public lists, shows the same records:

The Discussions page listing the records the public API returns, with their author and date