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 anyone can call: a list and a single record. They return only published records, and only the fields you name. Your pages read these routes; nothing about a content type is public until you add them.
This continues the article from Drafts & publishing: one published article, Welcome to the newsroom, and one draft, Release notes for October.
Choose the public fields
Edit src/content/article.ts and add publicApi after publication:
publication: true,
publicApi: {
path: "news",
fields: ["id", "title", "slug", "body", "publishedAt"],
},pathbecomes part of the URL,/api/newsroom/content/news, and the module name your pages use,content/news. Pick one no other plugin in the app uses, because content pages turn it into a site-wide URL.fieldshas no wildcard. A field you add later stays private until you list it here.- Exactly one slug field must be listed, because the single-record route finds records by it.
Register the public routes
Edit src/config.api.ts and add the model to buildContentPublicModule. This is an excerpt:
import { buildContentPublicModule } from "@/api/builders";
import { articleContent } from "@/database/articles";
import { manifest } from "@/manifest.gen";
import { adminModule } from "./api/modules/admin/admin.module";
import { helloModule } from "./api/modules/hello/hello.module";
export const newsroomApiPlugin = () =>
buildApiPlugin({
manifest,
modules: [
helloModule,
adminModule,
buildContentPublicModule({ contentTypes: [articleContent] }),
],
});publicApi adds no columns, so there is nothing to migrate. pnpm dev serves the routes as soon as it rebuilds.
Check the result
Call the list route while pnpm dev runs:
curl "http://localhost:3000/api/newsroom/content/news"{
"edges": [
{
"id": 1,
"title": "Welcome to the newsroom",
"slug": "welcome-to-the-newsroom",
"body": {
"type": "doc",
"content": [
{
"type": "paragraph",
"attrs": { "textAlign": null },
"content": [{ "type": "text", "text": "This is our first article. Staff write and edit it in the AdminCP." }]
}
]
},
"publishedAt": "2026-10-23T18:57:51.156Z"
}
],
"pageInfo": {
"totalCount": 1,
"totalPages": 1,
"currentPage": null,
"pageSize": 25,
"count": 1,
"hasNextPage": false,
"hasPreviousPage": false,
"endCursor": "eyJjb2x1bW4iOiJwdWJsaXNoZWRBdCIs...",
"startCursor": "eyJjb2x1bW4iOiJwdWJsaXNoZWRBdCIs..."
}
}Only the published article is in edges, with exactly the five listed fields. Now ask for each article by slug:
curl -i "http://localhost:3000/api/newsroom/content/news/welcome-to-the-newsroom"
curl -i "http://localhost:3000/api/newsroom/content/news/release-notes-for-october"The first answers 200 with the same record. The draft answers 404 with Article not found., the same answer as a slug that never existed, so the API never reveals that a draft exists.
Read it from a page
Pages call these routes with the fetcher. The module is content/ plus path; the route, its parameters and its response are typed from your definition:
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { manifest } from "@/manifest.gen";
const res = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/news",
path: "/{slug}",
args: { params: { slug: encodeURIComponent(slug) } },
});A field you did not list, or a module that does not exist, is a compile error. The list route also takes first (up to 50), page or cursor, search, orderBy and order; List API has every parameter, and Search shows which options turn them on.
Next, show the articles on the site with a Content list.