Guides

Add a content type

Declare a content type and get the list, the form, and the routes for it.

This guide adds a content type from a layer. A backend layer must register a content adapter. For example, @plutocms/supabase registers a Supabase adapter. See Content adapter.

Step 1: declare the type

Put the declaration in shared/utils/. Nuxt auto-imports the exports of shared/utils/** and shared/types/**.

shared/utils/content-note.ts
export const noteType = defineContentType({
  name: 'note',
  label: 'Note',
  labelPlural: 'Notes',
  icon: 'lucide:sticky-note',
  source: 'notes',
  titleField: 'title',
  fields: [
    { name: 'title', type: 'text', label: 'Title', required: true, inList: true },
    { name: 'slug', type: 'slug', label: 'Slug', from: 'title', required: true, inList: true, region: 'side' },
    { name: 'body', type: 'textarea', label: 'Body' }
  ],
  slug: { field: 'slug' },
  status: false,
  timestamps: { created: 'created_at', updated: false },
  capabilities: { write: 'notes:manage', delete: 'notes:delete' }
})
  • source is the storage name. For the Supabase adapter, it is the table name.
  • status: false means no publish workflow.
  • A type that declares no capability for an operation is open to anyone who can reach the route.
Keep the file under shared/utils/. A value exported from another shared/ folder gets only a type declaration. A site that extends your layer then fails at Nitro startup.

Step 2: register on the client

app/plugins/pluto-extension.ts
export default defineNuxtPlugin(() => {
  definePlutoExtension({
    id: 'my-layer',
    contentTypes: [{ id: 'note', type: noteType }],
    capabilities: [
      { id: 'notes-manage', key: 'notes:manage', label: 'my-layer.capabilities.notesManage' },
      { id: 'notes-delete', key: 'notes:delete', label: 'my-layer.capabilities.notesDelete' }
    ]
  })
})

Use a plain, un-prefixed id for the entry. Read type.name for the real name of the type.

Core now adds a sidebar entry, a dashboard tile, and the pages /admin/content/note, /admin/content/note/new, and /admin/content/note/<id>. See Lists and forms.

Step 3: register on the server

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

This enables the generic routes under /api/_pluto/content/note. Add hooks.afterCreate and hooks.afterUpdate here when you need server work after a write.

Step 4: add the storage

For the Supabase backend, add a migration that creates the table and grants it. The adapter maps the fields to the columns. Set column on a field when the column name differs from the field name.

db/migrations/002_notes.sql
create table if not exists public.notes (
  id bigint generated always as identity primary key,
  created_at timestamptz not null default now(),
  slug text not null unique,
  title text not null,
  body text
);

alter table public.notes enable row level security;

grant select on public.notes to anon;
grant select, insert, update, delete on public.notes to authenticated;
grant select, insert, update, delete on public.notes to service_role;

Add RLS policies that match your capabilities. Use public.has_capability('notes:manage'). See Users and roles and Data API grants.

Step 5: translate the labels

Add the convention keys to the locale files of your layer. Core falls back to the English literals when a key is missing.

  • content.note.label, content.note.labelPlural, content.note.new
  • content.note.fields.<field>.label
  • content.note.status.<value>

Options for your own URLs

If your layer must keep existing admin URLs, set autoRoutes: false and set basePath, newPath, and editPath:

basePath: '/admin/notes',
newPath: '/admin/note/new',
editPath: id => `/admin/note/edit/${id}`

Then register nav and pages by hand and render PlutoContentList and PlutoContentForm in your page files.

If an id can hold a slash, such as a nested id of the Git CMS, build the edit link with contentIdPath(id). It keeps the / between the segments.

Custom field widgets

If a field needs its own input, see Add a field widget.