Reference

Content model

Declare a content type once. Pluto makes the routes and the admin pages.

A content type is one typed contract. It lists the fields, the storage source, and the publish workflow. Core reads the contract to make the server routes and the admin UI.

Declare a content type

Wrap a content type in defineContentType. It gives TypeScript the narrowed field types.

shared/utils/content-post.ts
export const postType = defineContentType({
  name: 'post',
  label: 'Post',
  labelPlural: 'Posts',
  source: 'posts',
  titleField: 'title',
  fields: [
    { name: 'title', type: 'text', label: 'Title', required: true, inList: true },
    { name: 'body', type: 'richtext', label: 'Body' }
  ],
  status: {
    values: [{ label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }],
    default: 'draft',
    publishedValue: 'published'
  }
})

defineContentType returns the type unchanged. In development, it prints a warning for these problems:

  • A duplicate field name.
  • A titleField that names no field.
  • A slug.field that names no field of type slug.
  • A select field with no options.
  • A reference field with no target and no optionsUrl.
  • A reference field that sets create, publicFields, or remember with no target.
  • An empty source with no files.nested.
  • A canvas form whose form.canvasField names no field.
  • A status with no values.

A type with a problem still loads.

Field types

TypeBuilt-in widget
text, textareaPlutoFieldText
slugPlutoFieldSlug
numberPlutoFieldNumber
booleanPlutoFieldBoolean
datePlutoFieldDate
selectPlutoFieldSelect
referencePlutoFieldReference
mediaPlutoFieldMedia. The picker opens the media library.
richtextNone in core. A layer registers the widget. The Git CMS registers a Markdown widget for richtext.

Every field has name, type, and label. It can also have display hints: inList, inForm, order, region, group, widget, filter, and sortable.

  • region is main or side. The default is main.
  • group puts a side field in a collapsible group of the type (sideGroups). See Groups in the aside.
  • column sets the storage column when it differs from name.
  • virtual: true marks a field that has no storage column. The value passes through validation and the form payload. A hook can then read it.
  • listFormat is a pure function. It sets the text of the field in the generic list.
  • filter adds (true) or removes (false) the filter of the field in the generic list. A reference with a target has a filter by default. A select and a boolean have one when they are inList. Other types cannot filter.
  • sortable: false stops the sort on the column of the field. An inList field of type text, textarea, slug, number, select, boolean, or date sorts by default.
  • default gives a new item its first value in the admin form. The server does not apply it.

A textarea grows with the text from rows (default 4) up to maxRows (default 14).

A richtext field can set editor to 'auto' (the default), 'visual', or 'source'. The Markdown widget of the Git CMS reads it to pick the first mode of its Visual/Source switch. Other widgets ignore it. See Editing.

name and id

A content type is registered as an entry with an id, and the type goes under type. The registry rewrites the id to <extensionId>:<id>. The type.name is not rewritten. Always read type.name for the real identity of a content type.

Register a content type

A layer registers its types in a plugin:

app/plugins/pluto-extension.ts
export default defineNuxtPlugin(() => {
  definePlutoExtension({
    id: 'my-layer',
    contentTypes: [{ id: 'post', type: postType }]
  })
})

On the server, the layer registers the type in a Nitro plugin:

server/plugins/content.ts
export default defineNitroPlugin(() => {
  registerContentType(postType)
})

A backend layer registers the adapter on the server. The last adapter wins.

Extend a content type of another layer

A layer can add fields to a type that another layer registers. The SEO layer adds its fields to post this way. An extension is a pure function that returns a new type. Give the same extension on the client and on the server:

shared/utils/rating.ts
export const ratingExtension: PlutoContentTypeExtension = {
  id: 'rating',
  extend: (type) =>
    type.name === 'post'
      ? { ...type, fields: [...type.fields, { name: 'rating', type: 'number', label: 'Rating' }] }
      : type
}
app/plugins/rating.ts
export default defineNuxtPlugin(() => {
  definePlutoExtension({ id: 'rating', contentTypeExtensions: [ratingExtension] })
})
server/plugins/rating.ts
export default defineNitroPlugin(() => {
  extendContentTypes(ratingExtension)
})

The registered type does not change. getContentType, listContentTypes and usePlutoContentTypes() give the extended type. An extension with the same id replaces the earlier one. On Supabase, a new field needs a column in the table of the type.

Content type options

OptionPurpose
sourceThe table or view name that the type reads and writes. For the Git CMS, it is the folder.
primaryKeyThe storage column of the id. The default is id.
slug{ field } names the slug field. false turns slugs off.
defaultSort{ field, direction }. The generic list sorts with it when the user picks no sort.
statusThe publish workflow. false means no workflow. schedule and review add scheduled publish and pending review. See Scheduled publish and review.
excerpttrue or { field }. A short summary. See Excerpt and cover image.
covertrue or { field }. The cover image. See Excerpt and cover image.
descriptionOne sentence about the type for the editor. The page of a singleton type shows it.
singletontrue, or { id }. The type has one item, edited in one form. See Singleton types.
trashtrue keeps a removed item in the trash for 30 days. { column } sets the Supabase column (default deleted_at). See Trash.
timestampsThe created and updated columns. Set a key to false to disable it, or set false for both.
capabilitiesread, write, delete, and publish.
autoRoutesSet false to skip the generated nav entry and pages.
basePath, newPath, editPathURLs for the list, new, and edit pages. editPath is a function of the id.
adminBartrue, or { new, edit }. Buttons of the type in the admin bar. See Admin bar buttons.
liveOnSavetrue when the public site reads the type at run time, so a save shows at once. The build status then shows Live right after a save.
api{ public, fields }. public: true serves the type on the Public API. fields lists the fields of the public answer.
formLayout options for the generic form. See Lists and forms.
filesOptions for the file adapters (the Git CMS): nested, exclude, and frontmatter. See Nested content types.
hooksafterCreate, afterUpdate and toPublic. They run on the server.
previewUrlA function of the item. It gives the public URL for the View link of the form, or undefined when the item has no public page. It must be pure.
navOrder, iconNavigation order and icon.
navGroupThe id of a nav group, for example 'pages'. The types of a group show as one sidebar item and in one New menu of the admin bar.

Admin bar buttons

Set adminBar to show buttons of the type in the admin bar:

  • new: a New button (for example New Post) on every page where the bar shows. It opens the new page of the type.
  • edit: an Edit button (for example Edit Post) on a public page that calls usePlutoEditLink(type, id). It opens the edit page of that item.

adminBar: true gives both. With no adminBar, the type adds no button. The write capability of the type decides who sees them.

When two or more types can show a New button, the bar shows one New button that opens a menu. The types without a group are items of the menu. A group (navGroup) with two or more types is a submenu after them. A group with one type shows that type as an item. When all types are in one group, the menu lists them directly, without a submenu. The menu opens on hover too.

In the admin sidebar, each content type item has a + button at the right end. It shows on hover and on keyboard focus, and it opens the new page of the type.

const postType: PlutoContentType = {
  name: 'post',
  // ...
  adminBar: true,
}

The helpers contentTypeNewPath(type) and contentTypeEditPath(type, id) give the same URLs as the list and the form. See Admin shell.

Capabilities

If a type declares no capability for an operation, that operation is open to anyone who can reach the route.

  • write and delete are enforced by the generic routes through the adapter authorize method.
  • read is never enforced by the generic routes. It only decides if the nav entry shows. The adapter and its store decide what a caller can read.
  • publish falls back to write when unset.
Never call can('') as a stand-in for "no capability declared". Test for the capability first, as the generic list and form do.

Hooks

hooks.afterCreate and hooks.afterUpdate run on the server after the adapter call succeeds. If a hook throws, the whole write fails.

hooks: {
  afterCreate: async (ctx, item, rawBody) => { /* ... */ },
  afterUpdate: async (ctx, item, rawBody) => { /* ... */ }
}

A virtual field reaches the hook in rawBody.

hooks.toPublic(ctx, data, { view, item, batch }) changes the answer of the Public API for one item. Return the object to send. batch holds every item of the answer, so the hook can load related rows once for all of them. Set it where the layer registers the type on the server.

Validate a payload

validateContentPayload(type, payload, options) checks a payload against the field rules. It has no I/O.

const errors = validateContentPayload(postType, { title: '' })
// [{ field: 'title', message: '"Title" is required.' }]

Pass { partial: true } for a PATCH. A required field that is missing is then not an error. A required field with an empty value is always an error.

The function checks required, number (numeric, integer, min, max), boolean, select options, and maxLength. A date field gets only the required check. A reference field must hold a list of ids when it is multiple, else one id. An empty list fails required. This is true for a multiple media field too.

Each error can have a code and params for translation.

The generic server routes

All routes use the prefix /api/_pluto/content/. They are internal routes, for the admin. They are not a stable contract. A public site uses the Public API.

Every official layer follows one rule: an internal route is under /api/_pluto/<area>/ (core: content, content-activity, content-trash, content-revisions, content-revision, content-backup, media, mail; a layer: its own name, for example /api/_pluto/supabase/ or /api/_pluto/shop/). Core blocks a cross-site write to /api/_pluto/** for every layer.

RouteMethodAction
/api/_pluto/content/[type]GETList
/api/_pluto/content/[type]POSTCreate
/api/_pluto/content/[type]/[...id]GETGet one
/api/_pluto/content/[type]/[...id]PATCHUpdate
/api/_pluto/content/[type]/[...id]DELETEDelete

The id routes are catch-alls, so an id can hold slashes (1.start/2.install).

The list route accepts limit, offset, search, status, filter[<field>], and sort (field for ascending, -field for descending). parseAdminListQuery reads them, with the rules of the generic list. A value that the type does not allow gives 400. Without sort, the list uses defaultSort. For a type with a status workflow, published=1 returns published rows only.

An adapter applies query.status on list: it returns only the items with that status. An item with no stored status has the default status. A type with no workflow ignores it. The Git CMS and the Supabase adapters apply it.

An adapter can implement the optional countCreated. The dashboard activity chart uses it.

For the Public API, an adapter applies query.filters (equality, compared as text), and can implement timestampKeys(type) (the item keys of the created, updated and published-at times) and toPublic (its own keys in the public answer).

An adapter can also implement the optional readFile(ctx, relativePath). It reads one raw file in the folder of the type, for example 1.start/.navigation.yml, and returns { text, version } or null. The Git CMS adapter implements it. It returns null for a path that can leave the folder (.., an absolute path, a backslash). A .md file follows the rules of a get, so an anonymous caller never reads a draft.

Public reads from a layer

A layer route that serves public pages calls markPublicRead(event) first. It is auto-imported on the server. The adapter then treats the caller as anonymous: the CMS adapter reads no session, and the Supabase adapter reads with the anon key. Only published items come out. Put such a route under /api/pluto/v1/<layer>/.

server/api/pluto/v1/my-layer/page.get.ts
export default defineEventHandler(async (event) => {
  markPublicRead(event)
  // Read through the adapter of the type.
})
The generic routes are for the admin. They always ask the adapter for unpublished items. The store behind the adapter decides what a caller gets. For a public read, use the Public API.

Singleton types

A singleton type has one item: the site info, the contact data, the social links, a home page with fixed sections. Set singleton: true:

shared/utils/content-contact.ts
export const contactType = defineContentType({
  name: 'contact',
  label: 'Contact info',
  labelPlural: 'Contact info',
  description: 'The email, phone and address in the footer of every page.',
  icon: 'lucide:contact',
  source: 'settings',
  titleField: 'email',
  singleton: true, // <-- This defines the singleton type
  status: false,
  adminBar: { edit: true },
  api: { public: true },
  fields: [
    { name: 'email', type: 'text', label: 'Email', required: true },
    { name: 'phone', type: 'text', label: 'Phone' },
    { name: 'address', type: 'textarea', label: 'Address' }
  ]
})

Register it like any type. Then:

  • The sidebar has one item, with the label. It opens the form at /admin/content/contact, under the icon, the label, the description and the time of the last save. There is no list, no New and no Delete. Before the first save, the form is empty. /new and /<id> go to the form.
  • The server refuses a second item (409) and a delete (400).
  • The dashboard leaves the type out of its counts.

Storage:

  • Git CMS: one file, <dir>/<source>/<id>.md. The id is singleton.id, else the type name. The example writes content/settings/contact.md, so several singletons can share the settings folder. The id must be lowercase letters, numbers and dashes.
  • Supabase: the source table, with one row. Create the table like for any type (see Add a content type).

On the site, usePlutoSingleton(type) reads the item. The type needs api.public: true. Your editor suggests the names of the singleton types, and item has the fields of the type (see Item types). item is null until an editor saves the item, so keep a fallback:

app/components/SiteFooter.vue
<script setup lang="ts">
const { item: contact } = await usePlutoSingleton('contact')
</script>

<template>
  <footer>
    <a v-if="contact" :href="`mailto:${contact.email}`">{{ contact.email }}</a>
  </footer>
</template>

With adminBar.edit, the admin bar shows Edit Contact info on every page that calls it. Another stack reads GET /api/pluto/v1/content/contact?limit=1 and takes data[0].

singleton ignores trash and slug.

Item types

Pluto reads the item type from the definition. Each export const x = defineContentType({ ... }) in the shared/utils/ folder of your app or a layer joins the global PlutoContentTypes map, under its name. Nuxt writes the map at build time and in dev.

const contact: PlutoItem<'contact'> = ...
// { id: string | number, email: string, phone?: string | null, address?: string | null, createdAt?: string, ... }
  • A required field is always there. Any other field is optional and can be null.
  • A select gives its option values, a number a number, a boolean a boolean. A reference with a target gives { id, label, slug? }, a media field the media record.
  • api.fields limits the item to those fields.
  • PlutoContentTypeName is every name, and PlutoSingletonTypeName the names of the singleton types.
Do not annotate the export (export const x: PlutoContentType = defineContentType(...)). The annotation hides the literal name and fields, and the type leaves the map. Keep the file at the top level of shared/utils/: Nuxt does not auto-import a subfolder.

A type that Pluto cannot find takes the item type as a type argument: usePlutoSingleton<MyContact>('contact').

Trash

For a type with trash, the delete route moves the item to the trash. Without it, the delete is final. The admin shows the trash in a tab of the list. See The trash.

An adapter with a trash has the trash object, with five methods. Each method needs the delete capability of the type.

MethodDoes
move(ctx, id)Moves a live item to the trash. 404 when it does not exist.
list(ctx, { limit, offset, search })The items in the trash, the last one first. Each item has _deletedAt. search matches the title.
restore(ctx, id)Moves the item back. 404 when it is not in the trash. 409 when a live item has the id.
purge(ctx, id)Deletes an item in the trash forever, and the links to it.
purgeBefore(ctx, before)Deletes forever every item that went to the trash before before (ISO). Returns the count.

An item in the trash is not in list, get, countCreated, the public API, or the reference expansion. It keeps its id and the links to it until the purge.

The routes:

RouteMethodDoes
/api/_pluto/content-trash/[type]GETDeletes the items older than 30 days, then lists the trash (limit, offset, search).
/api/_pluto/content-trash/[type]DELETEEmpties the trash. Answers { purged }.
/api/_pluto/content-trash/[type]/[...id]POSTRestores the item.
/api/_pluto/content-trash/[type]/[...id]DELETEDeletes the item forever.

A type without trash gives 404. An adapter without trash gives 501.

The Nitro task pluto:trash:purge runs every day at 03:00 (nitro.scheduledTasks in the core nuxt.config.ts). It calls purgeBefore for each type with trash, as a system request (createPlutoSystemEvent). A system request has no user. The adapter gives it the server credentials of its backend: the GitHub App for the Git CMS, the service role key for Supabase. Without them, the task skips the type and logs a warning. To stop the task, set nitro.scheduledTasks['0 3 * * *'] to [] in your app. In development, POST /_nitro/tasks/pluto:trash:purge runs it.

On the Git CMS, an item in the trash is the file <dir>/.trash/<type name>/<id>.md, with deletedAt in the frontmatter. On Supabase, it is a row with deleted_at set. See The content adapter.

Scheduled publish and review

Two options of status add a step to the publish workflow. Both are off by default.

status: {
  column: 'status',
  default: 'draft',
  publishedValue: 'published',
  publishedAtColumn: 'publishedAt', // schedule needs it
  values: [
    { label: 'Draft', value: 'draft' },
    { label: 'Published', value: 'published' },
  ],
  schedule: true, // or { value: 'planned' }. The default value is 'scheduled'.
  review: true, // or { value: 'pending' }. The default value is 'review'.
},
capabilities: { write: 'posts:write', publish: 'posts:publish' },
  • Do not add the scheduled and review values to values. Core adds them.
  • The time of a scheduled item is in publishedAtColumn. There is no other column. A type without publishedAtColumn cannot schedule.
  • A scheduled save sends status: 'scheduled' and publishAt (an ISO date after now). A missing or past time gives 400. A save that keeps the scheduled status keeps the time. Publish now sets the time to now. A scheduled item that goes back to a draft or to review loses its time.
  • Live means published, or scheduled with a time that has come. The public API, the reference expansion, and the Git CMS reads use this rule. The rule is isLiveStatus(type, status, publishedAt) in shared/utils/content-status.ts (auto-imported).
  • Capabilities. A user with the write capability but not the publish capability can keep a draft or a review item, and move it between draft and review. The publish capability is necessary to set or to leave published or scheduled, and to change a published or scheduled item. This applies only when capabilities.publish is not the same as capabilities.write. The rule is statusChangeNeedsPublish(type, from, to).

An adapter that can publish the due items has publishDue(ctx, now). It sets every scheduled item with a time of now or earlier to published, and keeps the time. It returns the count.

The Nitro task pluto:publish:scheduled runs every 5 minutes. It calls publishDue for each type with schedule, as a system request (see Trash). A Node, Bun, or Deno server and Cloudflare run it. Vercel and Netlify do not run Nitro tasks with Nitro 2. There, the read-time rule shows the item at its time, and the admin list of a user who can publish calls publishDue when it opens. In development, POST /_nitro/tasks/pluto:publish:scheduled runs it.

Nitro 3 makes Vercel Cron Jobs from the scheduled tasks. The Vercel Hobby plan refuses a deploy with a cron that runs more than one time each day. Pluto will change the 5-minute task before it moves to Nitro 3.

Excerpt and cover image

Any content type can have a standard excerpt and a standard cover image:

excerpt: true, // or { field: 'summary' }. The default field is 'excerpt'.
cover: true, // or { field: 'hero' }. The default field is 'cover'.
  • Core adds the field when the type does not have it: a text area for the excerpt, an image field of the media library for the cover. Both show in the side panel of the form. A field that the type declares stays as it is.
  • The public API gives excerpt: the excerpt field. When it is empty, the API uses the start of the body as plain text (200 characters).
  • The public API gives cover: the media record of the image (url, width, height, alt), also when the field has another name.
  • The admin list shows a small image of the cover before the title.
  • On the site, usePlutoPublicItem uses the cover for og:image, and the excerpt for the description.

The Git CMS post type has both. The post type of the Blog layer has the cover. Its excerpt comes from the body, because the posts table has no excerpt column.

Embeds

A paragraph of a Markdown body that is one http(s) URL alone (a bare URL, or [url](url)) is an embed. Core owns the block and the security. A provider gives the player.

The HTML. The renderer of core (and of the Blog layer) writes such a paragraph as:

<p data-pluto-embed=""><a href="https://youtu.be/…">https://youtu.be/…</a></p>

It is a normal paragraph with a link, so a client of the public API that does not know embeds shows a link. The sanitizer keeps data-pluto-embed on p only, always empty. No iframe is in the stored or the rendered HTML.

The players. PlutoHtml shows the player of the first provider that matches the URL, in place of the paragraph. With no provider, the paragraph stays a link. Core has two providers, with the facade players of @nuxt/scripts: a thumbnail, and the player after a click.

ProviderURLs
youtubeyoutube.com/watch?v=…, /shorts/…, /embed/…, /live/…, youtu.be/…. t or start sets the start time.
vimeovimeo.com/<id>, vimeo.com/<id>/<hash> (an unlisted video), vimeo.com/channels/<name>/<id>, player.vimeo.com/video/<id>

Add a provider. A layer adds a provider with embedProviders in definePlutoExtension:

definePlutoExtension({
  id: 'my-embeds',
  embedProviders: [
    {
      id: 'bluesky',
      order: 30,
      label: 'Bluesky',
      // Strict: check the host and the id pattern. Return null for any other URL.
      match: (url) => {
        const parts = /^\/profile\/([^/]+)\/post\/([a-z0-9]+)$/.exec(url.pathname)
        return url.hostname === 'bsky.app' && parts
          ? { handle: parts[1]!, post: parts[2]! }
          : null
      },
      component: defineAsyncComponent(() => import('./BlueskyEmbed.vue')),
    },
  ],
})
  • match(url) gets a URL and returns text values, or null. It must be pure: it runs on the server and in the browser.
  • The component gets two props: url (the URL of the line) and match (what match returned). It renders the player. Load the third-party code only after a click, or on the server.
  • The first provider by order that matches wins. Core uses 10 (YouTube) and 20 (Vimeo).
  • @nuxt/scripts has embeds for X, Instagram, and Bluesky that fetch the post on your server, with no script of the provider in the page.

Content components

A Markdown body can hold a component of the site, with props only, at the top level:

::promo-card{title="Spring sale" featured}
::

The component must be in components/content/ and call definePlutoBlock. The site renders it, with safe props only. The Visual editor shows it as a box with its settings. See Components in posts.

Revisions

A backend that keeps the saved versions of an item implements two optional adapter methods. Then the edit form of every type shows History.

  • listRevisions(ctx, id, { limit, cursor }) returns { data, nextCursor }: the versions, newest first. The first one is the version that the item has now. Each version has id, createdAt, and optionally author (name, login, avatarUrl), action (create, update, publish, unpublish, rename) and message.
  • getRevision(ctx, id, revisionId) returns the item as it was at that version, or null.
RouteMethodAction
/api/_pluto/content-revisions/[type]/[...id]GETList the versions (limit 1 to 50, cursor)
/api/_pluto/content-revision/[type]/[revision]/[...id]GETGet one version

Both routes need the write capability of the type. Without the adapter methods, the list answers { supported: false, data: [] }.

There is no restore route. A restore copies the fields of a version into the form, except the slug and the status, and the user saves. So a restore goes through validation, capabilities, hooks and the conflict check, like any save.

The Git CMS implements both methods: a version is a commit that changed the file of the item. The Supabase layer keeps the versions in a table.

Backups

The edit form keeps a backup of the unsaved changes in the browser. A backend that can also keep a copy on the server implements the optional adapter key backups. Then a user can restore the changes on another device. Without it, the form keeps the browser backup only.

  • get(ctx, key) returns the backup record of the caller, or null.
  • put(ctx, key, record) replaces the record of the caller. It returns { otherDevice }: true when the replaced record came from another device in the last 10 minutes.
  • remove(ctx, key) deletes the record of the caller.

key is the item id as text, or +new for a new item. The adapter reads the caller from ctx.event, and a caller sees only its own records. Core also calls remove after a create, an update, and a delete. A failure there only logs a warning.

RouteMethodAction
/api/_pluto/content-backup/<type>?id=<id>GETRead the record
/api/_pluto/content-backup/<type>?id=<id>PUTWrite the record
/api/_pluto/content-backup/<type>?id=<id>DELETERemove the record

The id is a query, because an id can hold slashes. A call with no id is for a new item. The routes need the write capability of the type. The answer has supported. Without the adapter key, it is { supported: false }, checked before any capability. The PUT body is at most 1,000,000 characters (413), and core keeps only the fields that the type declares. The origin check covers PUT and DELETE.

The Supabase layer keeps the records in a table. The Git CMS has no backups. A type can turn the backup off with form.backup.

Optimistic concurrency

An item can have a _version token. An adapter that uses it compares the token on update. If the token is stale, the adapter throws a 409 error. The form keeps the unsaved changes of the user in a backup and shows "Someone saved this item after you opened it." The button Load the latest and keep my changes loads the newest item and puts the changes on top of it. A 409 for a taken slug shows an error toast instead. Do not declare a field named _version.

The Git CMS adapter also returns _frontmatter on a get: the whole frontmatter of the file, with the keys that no field maps. The form does not send it back. Do not declare a field named _frontmatter.

An adapter can also change the id of an item on update, for example when it renames a file. The form then goes to the new edit path.

Nested content types

The file adapters of the Git CMS can store a type in nested folders. Set files.nested to true:

files: {
  nested: true,
  exclude: ['index', 'drafts/'],
  frontmatter: {
    // Add the flat keys that the fields read.
    read: fm => ({ ...fm }),
    // Fold the flat keys back. Unknown keys must stay.
    write: fm => ({ ...fm })
  }
}
  • The id is the path under the folder of the type, without .md, for example 1.start/2.install.
  • Each segment is a lowercase slug with an optional numeric prefix (2.install). A path has at most 6 segments.
  • With nested, source can be ''. The type then uses the content folder itself.
  • exclude lists ids (index) and folders with a trailing slash (drafts/) that the type must not list or edit.
  • frontmatter.read and frontmatter.write map the frontmatter between the file and the fields. Both must be pure. A field that a hook maps uses a flat key as its column.

compareContentIds(a, b) is a sort comparator for such ids. In each segment, index comes first. A numeric prefix comes next, as a number. The name comes last.

contentIdPath(id) makes the URL path of an id. It encodes each segment and keeps the / between them. Use it in your own editPath when ids can hold slashes. The admin item page is a catch-all, /admin/content/<type>/[...id].

See Git CMS for the storage rules.

Composables

const { items, byName } = usePlutoContentTypes()
byName('post') // looks up by type.name

const { items, total, pending, error, refresh, remove, loadMore } = usePlutoContentList('post', {
  pageSize: 50,
  query: () => ({ search: 'snow', status: 'draft', sort: { field: 'title', direction: 'asc' } }),
})
const { item, pending, loadError, save, remove } = usePlutoContentItem('post', () => route.params.id)
  • usePlutoContentList loads the list page by page when you pass { pageSize }.
  • query is a ref or a getter of the search, the status, the filters, and the sort. When it changes, the list loads again from the first page.
  • usePlutoContentItem fetches one item only when the id is defined. save creates when the id is undefined and updates when it is defined.
  • usePlutoContentItem reads the id once. Mount the form again when the id changes.

Slugs

slugifyTitle(value) makes a slug from a title. It removes accents, lowercases, joins words with single dashes, and cuts the result at 100 characters. For example, "Pão de Mel" becomes pao-de-mel.

The generic form derives the slug from the title only while you create an item. It stops when you edit an existing item, or when you type in the slug field.

Reference fields

A reference field stores the id of an item of another content type.

{ name: 'tags', type: 'reference', label: 'Tags', target: 'tag', multiple: true, create: true }
OptionMeaning
targetThe name of the referenced content type. The field stores the id of one of its items.
multipleThe field stores a list of ids, in order. Without it, the field stores one id.
createThe widget can create an item of target from the field. It needs target. The option shows only for a user with the write capability of target.
optionsUrlA URL for the options, when the target is not a content type. Use it in place of target.
publicFieldsA list of field names. The public API adds these fields of the target to each public reference. A media field gives its record. Only a public field of the target counts. The toPublic hooks of the target do not run. It needs target.
rememberA new item gets the value that the user saved last time, for each browser. A value that does not exist anymore is not filled. It needs target.
  • The widget is a multi-select with a search box. It loads up to 1000 items of target.
  • If target is a public type, the Public API gives { id, label, slug } in place of each id.
  • filter[<field>]=<id or slug> on the public list route finds the items that have this id in the list.
  • An adapter gets PlutoContentQuery.ids on list: it returns only the items with these ids. An empty list returns no item. Core checks the ids again, so an adapter that ignores ids still works, with more reads.
  • A filter on a list value matches when the list has the value.

See Tags and categories and Authors.

Media fields

A media field stores one media id, or a list of ids with multiple. accept narrows the file types. The core widget edits it, and the public API gives the records in place of the ids. See Media.