Editor manifests
A CollectionEditorManifest declares everything the editor primitives need to
know about a CMS collection beyond its field descriptors.
defineCollectionEditor
Identity helper that gives the manifest object the right type without
as const:
import { defineCollectionEditor } from '@nordcom/commerce-cms/editor';
export const businessDataEditor = defineCollectionEditor({
collection: 'businessData',
routes: {
label: { singular: 'Business data', plural: 'Business data' },
basePath: (domain) => `/${domain}/content/business-data/`,
breadcrumbs: ({ domain }) => [
{ label: 'Content', href: `/${domain}/content/` },
{ label: 'Business data' },
],
},
tenant: { kind: 'scoped', field: 'tenant' },
access: { list: tenantMember, read: tenantMember, update: editorOrAdmin, delete: adminOnly },
revalidate: ({ domain }) => [`/${domain}/content/business-data/`],
});Fields
collection: CollectionSlug (required)
The collection slug — a member of the compile-time CollectionSlug union
(src/editor/manifest.ts); an unknown slug fails defineCollectionEditor at
compile time. The collection's fields, drafts, and localization come from the
editor schema (editorCollectionSchema, looked up by slug at render time) — the
manifest never duplicates them.
routes (required)
| Field | Type | Notes |
|---|---|---|
label.singular | string | Used as the page title for create / single-doc edit. |
label.plural | string | Used in headers and breadcrumbs. |
basePath | (domain) => Route | Returns the list path. domain is null on cross-tenant routes. |
breadcrumbs | ({ domain }) => [{ label, href? }] | Optional. Last item has no href. |
keyField | 'id' | string | URL segment field for the id portion. Default 'id'. Override for handle-keyed collections. |
tenant (required)
kind | Where clause | Use for |
|---|---|---|
'scoped' | and: [tenant = X, keyField = id] | Most content collections (pages, articles, header, footer, businessData). |
'shared' | keyField = id | Cross-tenant admin collections (users, media). |
'singleton-by-domain' | or: [domain = id, alternativeDomains contains id] | Shop, where the route segment IS the domain. |
access (required)
Route-level gates. Run before the Convex functions' own tenant/role checks
(defense in depth). Return false → notFound().
access: {
list: tenantMember,
read: tenantMember,
create: editorOrAdmin,
update: editorOrAdmin,
delete: adminOnly,
}Built-in predicates exported from @nordcom/commerce-cms/editor:
adminOnly— onlyrole: 'admin'passes.editorOrAdmin— admin or editor.tenantMember— admin always; editors only if the requested domain is inuser.tenants.
list (optional)
When omitted, the manifest has no list view (global-style collection).
list: {
columns: [
{ label: 'Title', accessor: 'title' },
{ label: 'Updated', accessor: 'updatedAt', render: (v) => new Date(String(v)).toLocaleString() },
],
sortBy: '-updatedAt', // optional; default '-updatedAt'
bulkActions: ['delete', 'publish'], // optional
}livePreview (optional)
Builder for the preview iframe URL. The iframe slot stays hidden when this field is omitted.
livePreview: ({ tenantId, collection, data, locale }) =>
`https://${tenantId}.preview.example.com/${collection}?locale=${locale}&t=${(data as { updatedAt: string }).updatedAt}`revalidate (optional)
Paths to revalidatePath after every successful write. Called with
{ domain, doc, status }.
revalidate: ({ domain, status }) => {
const paths = [`/${domain}/content/business-data/`];
if (status === 'published') paths.push(`/${domain}/`);
return paths;
}Worked examples
Tenant-scoped global with drafts (businessData)
export const businessDataEditor = defineCollectionEditor({
collection: 'businessData',
routes: {
label: { singular: 'Business data', plural: 'Business data' },
basePath: (d) => `/${d}/content/business-data/`,
breadcrumbs: ({ domain }) => [
{ label: 'Content', href: `/${domain}/content/` },
{ label: 'Business data' },
],
},
tenant: { kind: 'scoped', field: 'tenant' },
access: {
list: tenantMember, read: tenantMember,
update: editorOrAdmin, delete: adminOnly,
},
revalidate: ({ domain }) => [`/${domain}/content/business-data/`],
});Singleton-by-domain (shop — Phase 2)
export const shopEditor = defineCollectionEditor({
collection: 'shops',
routes: {
label: { singular: 'Shop', plural: 'Shops' },
basePath: (d) => `/${d}/settings/shop/`,
keyField: 'domain',
},
tenant: { kind: 'singleton-by-domain' },
access: { list: () => false, read: tenantMember, update: adminOnly, delete: adminOnly },
revalidate: ({ domain }) => [`/${domain}/`, `/${domain}/settings/shop/`],
});Registering a manifest
Add the manifest to the allManifests array in
packages/cms/src/editor/manifests/index.ts. Then run pnpm cms:gen to emit
the corresponding action wrappers. CI will fail with a DRIFT: error if the
checked-in _generated/ files don't match what the generator produces.