Public API
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
| Route | Answer |
|---|---|
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 addspage,navigationandsurround.
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:
| Type | Layer | Notes |
|---|---|---|
post | Git CMS (core) | A list item has excerpt. One item has the Markdown content and the sanitized html. |
doc (or the name that you set) | Docs | Like 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. |
post | Blog (Supabase) | A list item has excerpt. One item has the stored content and the sanitized html. |
product | Shop (Supabase) | With media (with URLs), category and availability as objects. |
product-category, product-availability | Shop (Supabase) | The taxonomies of the shop. |
Query
The list route reads these query keys. It ignores every other key.
| Key | Value |
|---|---|
limit | 1 to 100. The default is 20. |
offset | 0 or more. The default is 0. |
search | Text to find in the title. At most 200 characters. |
sort | A 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,updatedAtandpublishedAt, when the type has them.- The keys that the backend and the layer add. For example, the Git CMS adds
excerptandhtml, and the shop addsmedia.
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:
| Status | When |
|---|---|
| 400 | A sort or a filter on an unknown field (pluto.errors.invalidQuery). |
| 404 | An unknown or private type, a missing item, or a draft. |
| 503 | The 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:
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.
<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 listapi.fieldswhen 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.viewis'list'or'item'.batchholds every item of the answer: load related rows once for the whole batch, and keep them onctx.event.context, so a list of 20 items does not make 20 queries. - A backend adapter implements
timestampKeys(type)for the timestamps,toPublicfor its own keys, andquery.filtersforfilter. - Put a public route of your layer under
/api/pluto/v1/<layer>/, and callmarkPublicRead(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.