Core

Public API

Read the published content of Pluto from a site in any framework or stack.

Pluto has a public, read-only API. A site in any framework or language reads the published content through it. A Nuxt site uses it too, through two composables.

The API is versioned. The prefix is /api/pluto/v1. A breaking change ships as a new version next to it.

Routes

RouteAnswer
GET /api/pluto/v1/content/<type>The published items of a type: { data, meta: { total, limit, offset } }
GET /api/pluto/v1/content/<type>/<id or slug>One published item: { data }
  • The routes are anonymous. They never read a session, and the answer is the same for every visitor.
  • Only published items come out. A draft answers 404, also for a signed-in editor.
  • The id of a nested type can hold slashes, for example /api/pluto/v1/content/doc/1.getting-started/2.why-pluto.
  • A layer can add its own public routes under /api/pluto/v1/<layer>/. For example, the docs layer adds page, navigation and surround.
Terminal
curl "https://your-site.dev/api/pluto/v1/content/post?limit=5&sort=-publishedAt"
{
  "data": [
    {
      "id": "hello-world",
      "title": "Hello, world",
      "slug": "hello-world",
      "excerpt": "The first post of your new blog.",
      "createdAt": "2026-10-19T12:00:00.000Z",
      "publishedAt": "2026-10-19T12:00:00.000Z"
    }
  ],
  "meta": { "total": 1, "limit": 5, "offset": 0 }
}

Which types are public

A content type is private until it opts in with api.public. A private type and an unknown type both answer 404, so the API does not tell that a private type exists.

defineContentType({
  name: 'post',
  // ...
  api: {
    public: true,
    // Optional: the fields that the answer has. Default: every field that is not virtual.
    fields: ['title', 'slug', 'excerpt', 'content'],
  },
})

These types are public in the official layers:

TypeLayerNotes
postGit CMS (core)A list item has excerpt. One item has the Markdown content and the sanitized html.
doc (or the name that you set)DocsLike post. The html and the excerpt leave out the MDC syntax and keep its text. To render the components, use the page route of the layer, which gives the parsed body.
postBlog (Supabase)A list item has excerpt. One item has the stored content and the sanitized html.
productShop (Supabase)With media (with URLs), category and availability as objects.
product-category, product-availabilityShop (Supabase)The taxonomies of the shop.

Query

The list route reads these query keys. It ignores every other key.

KeyValue
limit1 to 100. The default is 20.
offset0 or more. The default is 0.
searchText to find in the title. At most 200 characters.
sortA public field, or createdAt, updatedAt, publishedAt. Add - in front to sort descending, for example -publishedAt. The default is the default sort of the type.
filter[<field>]Equality on a public field, for example filter[category]=3. Use more than one to combine them.

A bad limit or offset uses the default. A sort or a filter on a field that the answer does not have answers 400.

The answer of an item

An item has:

  • id.
  • The public fields of the type: api.fields, or every field that is not virtual.
  • createdAt, updatedAt and publishedAt, when the type has them.
  • The keys that the backend and the layer add. For example, the Git CMS adds excerpt and html, and the shop adds media.

A column that no public field names never comes out. Internal keys, such as _version and status, never come out.

Errors

An error answers JSON with statusCode and statusMessage, and data.i18n.key for a translated message:

StatusWhen
400A sort or a filter on an unknown field (pluto.errors.invalidQuery).
404An unknown or private type, a missing item, or a draft.
503The Git CMS cannot read GitHub, for example a rate limit. It sends Retry-After.

Cache and CORS

A success answer has Cache-Control: public, s-maxage=60, stale-while-revalidate=300, so a CDN can keep it. An answer never sets a cookie.

Browser code on any origin can read the API: the answer has Access-Control-Allow-Origin: *, for GET, HEAD and OPTIONS only, and never with credentials. Set both with runtimeConfig.plutoApi:

nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    plutoApi: {
      // true (any origin), a list of origins, or false (no CORS header).
      cors: ['https://www.example.com'],
      // The s-maxage in seconds. 0 sends no-store.
      maxAge: 300,
    },
  },
})

At run time, set NUXT_PLUTO_API_CORS (false, or a JSON list of origins) and NUXT_PLUTO_API_MAX_AGE.

In a Nuxt site

usePlutoPublicList(type, query) and usePlutoPublicItem(type, id) call the API with useFetch. They run on the server, so the page HTML holds the data. data.value is the answer above.

app/pages/posts/[slug].vue
<script setup lang="ts">
const route = useRoute()
const { data, error } = await usePlutoPublicItem<{ title: string, html: string }>('post', String(route.params.slug))
</script>

<template>
  <article v-if="data">
    <h1>{{ data.data.title }}</h1>
    <PlutoHtml :html="data.data.html" />
  </article>
</template>

The query of usePlutoPublicList takes limit, offset, search, sort and filter:

const { data } = await usePlutoPublicList('product', { limit: 12, sort: '-createdAt', filter: { category: 3 } })

For layer authors

  • Opt a type in with api: { public: true }, and list api.fields when a column must stay private.
  • Add data to the answer with the server hook hooks.toPublic(ctx, data, { view, item, batch }). Set it where the layer registers the type on the server. view is 'list' or 'item'. batch holds every item of the answer: load related rows once for the whole batch, and keep them on ctx.event.context, so a list of 20 items does not make 20 queries.
  • A backend adapter implements timestampKeys(type) for the timestamps, toPublic for its own keys, and query.filters for filter.
  • Put a public route of your layer under /api/pluto/v1/<layer>/, and call markPublicRead(event) first. Put an internal route of your layer under /api/_pluto/<layer>/. Core blocks a cross-site write to /api/_pluto/**.

See Content model and Use Pluto headless.