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:
export const discussionContentType = defineContentType({
id: "example.discussion",
// ...
publication: true,
publicApi: {
path: "discussions",
fields: ["id", "title", "slug", "body", "board", "publishedAt"],
},
});fieldshas 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:
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:
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}.
| Route | Returns |
|---|---|
GET / | Published records, a page at a time: { edges, pageInfo } |
GET /{slug} | One published record, or 404 |
GET /permissions?ids=1,2 | What 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"{
"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:
