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 } |
GET /api/pluto/v1/surround/<type>/<id or slug> | The published items before and after one item: { data: { previous, next } } |
- The routes are anonymous. They never read a session, and the answer is the same for every visitor.
- Only live items come out: published items, and scheduled items after their time. A draft, or an item in the trash, answers 404, except to an editor who asks for drafts (below).
- The surround route takes the
sortandfilterof the list. It reads up to 1,000 published items to find the neighbors. - The id of a nested type can hold slashes, for example
/api/pluto/v1/content/doc/1.getting-started/2.why-pluto. ?drafts=1on the item route gives an editor a draft too: the caller needs the read capability of the type (or its write capability). The answer then hasmeta: { draft: true }andCache-Control: private, no-store. For anyone else,draftschanges nothing.- 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. |
tag, category | Git CMS (core) and Blog (Supabase) | The terms of a post. See Tags and categories. |
author | Git CMS (core) and Blog (Supabase) | The authors of a post. See Authors. |
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. On a field that holds a list, the item matches when the list has the value. On a reference field, the value is an id or a slug, for example filter[tags]=hiking or filter[authors]=ana-lima. |
A limit or offset that is not a whole number uses the default. A limit under 1 gives 1, and a limit over 100 gives 100. A sort or a filter on a field that the answer does not have answers 400. A reference filter value that matches no published item gives an empty list.
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.- A
referencefield that has a publictargetgives{ id, label, slug }in place of each id: a list for amultiplefield, else one object ornull. A privatetargetkeeps the ids. See Tags and categories. - The option
publicFieldsof areferencefield adds more public fields of the target to each reference. Theauthorsof a post are{ id, label, slug, avatar, bio }. See Authors. excerptandcover, when the type turns on these options. See Excerpt and cover image.- The keys that the backend and the layer add. For example, the Git CMS adds
excerptto a list item andhtmlto one item, 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
Each official content type has its composables: usePosts and usePost for the posts of both backends, useTags, useTag, useCategories and useCategory for the terms of a post, useAuthors and useAuthor for the authors of a post, useProducts, useProduct and useProductCategories for the shop. They call the API with useFetch, so they run on the server and the page HTML holds the data.
<script setup lang="ts">
const route = useRoute()
const { post } = await usePost(() => String(route.params.slug))
</script>
<template>
<article>
<h1>{{ post?.title }}</h1>
<PlutoHtml :html="post?.html ?? ''" />
</article>
</template>
An item composable does four things for the page:
- A missing item, or a draft, shows the error page with a 404. To handle it yourself, pass
{ notFound: 'null' }: the item is thennull, anderroris set. - It sets the SEO tags: the title, the description (
excerptordescription), andog:imagefrom the cover, else from the firstmediafield. With the SEO add-on, the SEO fields of the item replace these tags. Pass{ seo: false }to set your own. - It shows Edit Post (or Edit Product) in the admin bar for an editor. Pass
{ editLink: false }to turn it off. - An editor also sees a draft on the page, before it is out.
draftis thentrue, so the page can show a badge. Pass{ drafts: false }to show published items only.
A list composable reads the page and the search from the URL (?page= and ?q=):
<script setup lang="ts">
const { posts, search, page, pages, pageLink } = await usePosts({ limit: 10 })
</script>
<template>
<input v-model="search" type="search" placeholder="Search posts">
<PostList :posts="posts" />
<NuxtLink v-if="page < pages" :to="pageLink(page + 1)">Older posts</NuxtLink>
</template>
searchis the search box. The URL gets?q=300 ms after the last key, and the list goes back to page 1. A shared link opens on the same search.pageis the current page. Set it to go to another page: the URL changes.pageLink(n)is a link to pagen.pagesis the number of pages, andtotalthe number of items.limit,sortandfiltertake values or refs. Passsearchwith a value or a ref to control it yourself, orsearch: false. Passpage: 2for a fixed page, orpage: falsefor the first page only.
The posts around a post, newest first:
const { newer, older } = await useAdjacentPosts(() => String(route.params.slug))
Each one is a post of the list, or null at either end.
The site
usePlutoSite() gives name, description, url, root and absoluteUrl(path). The name comes from the backend (Settings), else websiteName. The description comes from websiteDescription in nuxt.config.ts (or NUXT_PUBLIC_WEBSITE_DESCRIPTION).
The address of the site comes from websiteUrl (NUXT_PUBLIC_WEBSITE_URL, for example https://my-blog.com). Vercel and Netlify give it by default. Without it, a server uses the address of the request. root is the address with the base path. absoluteUrl("/posts/hello") gives the full URL of a site path. On a static build without websiteUrl, root is empty and absoluteUrl gives an empty string for a site path.
Call usePlutoSiteHead() in the layout. It sets the title template (Post title - Site name), og:site_name, and the site description as the default description of each page. A page that sets its own description, such as a post, wins.
<script setup lang="ts">
usePlutoSiteHead()
</script>
Every composable also returns the refs of useFetch: pending, error, refresh.
Dates and reading time
formatPublishedDate(item) gives the date of an item as text: publishedAt, else createdAt, for example Mar 5, 2026.
formatPublishedDate(post) // 'Mar 5, 2026'
formatPublishedDate(post, { dateStyle: 'long' }) // 'March 5, 2026'
formatPublishedDate(post, { locale: 'pt-BR', timeZone: 'America/Sao_Paulo' })
- It takes the options of
Intl.DateTimeFormat, and alocale(default'en'). - The default time zone is UTC, so the server and the browser give the same text. Set
timeZoneto show the date in the time zone of your site. - With no date, it gives an empty string.
readingMinutes(post.html) gives the reading time of a body in minutes, at least 1. It counts 220 words a minute. Pass another rate as the second argument: readingMinutes(post.html, 180).
Your own content types
definePlutoContent makes the same composables for any public type:
interface Event { id: number, title: string, slug: string, date: string }
export const { useList: useEvents, useItem: useEvent } = definePlutoContent<Event>('event')
useEvents() returns items, and useEvent(slug) returns item. Give defaults as the second argument: definePlutoContent<Event>('event', { list: { sort: 'date' } }).
For a singleton type, such as the contact data, usePlutoSingleton('contact') returns item: the one item, typed from the definition of the type, or null before the first save. It reads GET /api/pluto/v1/content/contact?limit=1.
The low-level composables
usePlutoPublicList(type, query), usePlutoPublicItem(type, id, { drafts }) and usePlutoPublicSurround(type, id, query) call the API with useFetch and do nothing else. 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 } })
On a static site
A static site, such as a GitHub Pages site, has no API. The build writes one more file for it, /api/pluto/v1/index.json. This file holds all the published items of each public type. In the browser, usePlutoPublicList reads this file one time and answers from it. A search and the pages after the first work with no server, and you change nothing in your pages. This works with every backend.
The browser uses the same rules as the API. The search looks in the title, and filter compares the value as text.
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.