Add a content type
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/**.
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' }
})
sourceis the storage name. For the Supabase adapter, it is the table name.status: falsemeans no publish workflow.- A type that declares no capability for an operation is open to anyone who can reach the route.
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
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
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.
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.newcontent.note.fields.<field>.labelcontent.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.