Public API and Caching
Expose safe Content Engine fields from a plugin API and configure cache invalidation without framework-specific glue.
Public content is an API concern owned by the plugin. Start with an explicit allowlist; no field is public just because it looked innocent in a database column at 2 a.m.
Define the public response
Public API generation requires publication: { enabled: true } and at least one exposed slug field in publicApi.fields for the detail route:
import { defineContentType, field } from "@vitnode/core/content"
export const articleContentType = defineContentType({
id: "blog.article",
tableName: "blog_articles",
fields: {
adminNotes: field.textarea({ nullable: true }),
excerpt: field.textarea({ nullable: true }),
slug: field.slug({ source: "title" }),
title: field.text({ required: true }),
},
publication: { enabled: true },
publicApi: {
enabled: true,
path: "articles",
fields: ["id", "title", "slug", "excerpt", "publishedAt"],
searchableFields: ["title", "excerpt"],
orderableFields: ["title"],
defaultOrderBy: "publishedAt",
defaultOrder: "desc",
},
})adminNotes remains private because it is not listed in fields.
Build the public module in the plugin API
Register the content type's public routes using buildContentPublicModule:
import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"
import { buildContentPublicModule } from "@vitnode/core/content/server"
import { articleContent } from "./content/articles"
export const blogApiPlugin = () =>
buildApiPlugin({
pluginId: "@acme/blog",
modules: [
buildContentPublicModule({
contentTypes: [articleContent],
pluginId: "@acme/blog",
}),
],
})The module exposes two public endpoints:
GET /api/{pluginId}/content/{path}: Lists published records with cursor pagination, optionalsearch, equalityfilters, andorderBy.GET /api/{pluginId}/content/{path}/{slug}: Retrieves a single published record resolved by its public slug.
Read it with the fetcher
buildContentPublicModule keeps every generated route in its type, so the
universal fetcher infers them like any hand-written module. The module path is
content/ followed by the content type's publicApi.path:
import { fetcher } from "@vitnode/core/tanstack/fetcher"
export const fetchArticle = async (slug: string) => {
const response = await fetcher({
plugin: "@acme/blog",
args: { params: { slug } },
method: "get",
module: "content/articles",
path: "/{slug}",
})
if (response.status === 404) return null
return await response.json()
}
export const fetchArticles = async (search?: string) =>
await fetcher({
plugin: "@acme/blog",
args: { query: { first: "20", orderBy: "title", search } },
method: "get",
module: "content/articles",
path: "/",
})A content type without publicApi contributes no module, so
module: "content/categories" for a private type is a compile error rather
than a 404 at runtime.
Only configure revalidation when a front end caches renders
Content mutations already invalidate VitNode's internal content cache tags. If a separate frontend application caches rendered pages, add its origin so the API can notify it through the framework-neutral revalidation endpoint:
export const vitNodeApiConfig = buildApiConfig({
content: {
revalidateOrigins: ["https://www.example.com"],
},
})Leave this unset when the frontend reads directly from the public content API.
Public API Configuration Reference
| Property | Type | Default | Description |
|---|---|---|---|
enabled | true | — | Opts into the generated public API module. |
path | string | — | Single lowercase URL segment (e.g. "articles", never "admin"). |
fields | string[] | — | Strict allowlist of exposed fields. Must include the slug field. Can include id and publishedAt. |
searchableFields | string[] | [] | Exposed text fields scanned by the ?search= query parameter. |
orderableFields | string[] | [] | Exposed columns accepted by the ?orderBy= query parameter. |
filterableFields | string[] | [] | Exposed columns accepted as equality filters. |
defaultOrderBy | string | "publishedAt" | Default column used for ordering public list results. |
defaultOrder | "asc" | "desc" | "desc" | Default sorting direction. |
Keep the public surface intentional
Use a plugin route for the page that consumes the endpoint, and add search indexing only when users need to discover the content outside its own section.
Publication & Editorial
Add draft/published lifecycles, revision histories, signed preview links, and scheduled publishing to content types.
Localization & Translations
Step-by-step guide to building multi-language content models with localized fields, translation tables, independent workflows, and localized public APIs.