Build your site

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 }
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 sort and filter of 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=1 on 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 has meta: { draft: true } and Cache-Control: private, no-store. For anyone else, drafts changes nothing.
  • 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.
tag, categoryGit CMS (core) and Blog (Supabase)The terms of a post. See Tags and categories.
authorGit 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.

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. 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, updatedAt and publishedAt, when the type has them.
  • A reference field that has a public target gives { id, label, slug } in place of each id: a list for a multiple field, else one object or null. A private target keeps the ids. See Tags and categories.
  • The option publicFields of a reference field adds more public fields of the target to each reference. The authors of a post are { id, label, slug, avatar, bio }. See Authors.
  • excerpt and cover, 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 excerpt to a list item and html to one item, 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

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.

app/pages/posts/[slug].vue
<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 then null, and error is set.
  • It sets the SEO tags: the title, the description (excerpt or description), and og:image from the cover, else from the first media field. 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. draft is then true, 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=):

app/pages/index.vue
<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>
  • search is 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.
  • page is the current page. Set it to go to another page: the URL changes. pageLink(n) is a link to page n.
  • pages is the number of pages, and total the number of items.
  • limit, sort and filter take values or refs. Pass search with a value or a ref to control it yourself, or search: false. Pass page: 2 for a fixed page, or page: false for 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.

app/layouts/default.vue
<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 a locale (default 'en').
  • The default time zone is UTC, so the server and the browser give the same text. Set timeZone to 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:

app/composables/events.ts
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.

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 } })

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 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.