Content Engine

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:

plugins/blog/src/content/article.ts
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:

plugins/blog/src/config.api.ts
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, optional search, equality filters, and orderBy.
  • 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:

plugins/blog/src/features/articles/article-query.ts
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:

apps/api/src/vitnode.api.config.ts
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

PropertyTypeDefaultDescription
enabledtrueOpts into the generated public API module.
pathstringSingle lowercase URL segment (e.g. "articles", never "admin").
fieldsstring[]Strict allowlist of exposed fields. Must include the slug field. Can include id and publishedAt.
searchableFieldsstring[][]Exposed text fields scanned by the ?search= query parameter.
orderableFieldsstring[][]Exposed columns accepted by the ?orderBy= query parameter.
filterableFieldsstring[][]Exposed columns accepted as equality filters.
defaultOrderBystring"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.