Skip to content

Admin Customization

VexCMS’s admin panel exposes a small set of extension points (slots) a project can hook into without forking a component: per-field/per-type component overrides, and additive action-menu contributions on blocks() and array() fields. The built-in UI (duplicate/move/remove buttons, block copy/paste, …) is itself registered through this same mechanism, with vex.-prefixed ids — there is no private rendering path reserved for VexCMS’s own actions, so hiding a built-in and adding your own use identical config shapes.

  • Override chains (Input, Cell, Label, Description) — exactly one component renders. Each entry in the chain receives renderDefault, the previous stage of the chain (the true built-in for the first entry); calling it renders what came before, not calling it replaces it entirely.
  • Additive contributions (beforeInput, afterInput, blocks.item.actions, blocks.header.actions, array.item.actions) — every contribution whose access/condition gates pass renders, ordered by order/before/after.

Both live at admin.components — on a field instance for that one field, or under defineConfig()’s root admin.components for every field of a given type / every blocks or array field project-wide.

import { text } from "@vexcms/core"
const slug = text({
label: "Slug",
admin: {
components: {
Input: [
{
id: "acme.slug-input",
component: (props) => (
<div className="relative">
{props.renderDefault(props)}
<CopyButton value={props.value} />
</div>
),
},
],
afterInput: [
{
id: "acme.slug-preview-link",
component: () => <SlugPreviewLink />,
},
],
},
},
})

Input/Cell/Label/Description are override chains; beforeInput/ afterInput are additive contribution lists. No built-in contributes to field.label or field.description yet — every field’s Input still renders its own label/description inline, so those two slots start empty for every field.

Set admin.components.fields.<type> in defineConfig() to override every field of one type — a field’s own admin.components.Input/Cell chain wraps the project-wide chain, which wraps the true built-in:

import { defineConfig } from "@vexcms/core"
export default defineConfig({
collections: [/* … */],
admin: {
components: {
fields: {
// Every text() field in the project renders through this chain
// first, unless that field's own admin.components.Input adds more.
text: {
Input: [{ id: "acme.text-input", component: AcmeTextInput }],
},
},
blocks: {
itemActions: [
{ id: "acme.block.flag", component: FlagBlockAction },
],
headerActions: [
{ id: "acme.blocksHeader.exportAll", component: ExportAllAction },
],
},
array: {
itemActions: [
{ id: "acme.array.flag", component: FlagArrayItemAction },
],
},
},
},
})

Resolution order for a field’s Input (same for Cell): the field’s own admin.components.Input chain, composed outward from the project-wide admin.components.fields.<type>.Input chain, composed outward from the true built-in — so a field-level override’s renderDefault reaches the project-wide override before the built-in, and the project-wide override’s own renderDefault reaches the built-in directly.

blocks.item.actions, blocks.header.actions, and array.item.actions are additive lists seeded with VexCMS’s own built-ins, registered under stable vex.-prefixed ids from BUILTIN_CONTRIBUTION_IDS:

import { BUILTIN_CONTRIBUTION_IDS } from "@vexcms/core"
const blocksField = blocks({
blocks: [heroBlock, calloutBlock],
admin: {
components: {
itemActions: [
// Hides the built-in "duplicate" action for this one blocks() field.
{ id: BUILTIN_CONTRIBUTION_IDS.block.duplicate, hidden: true },
{
id: "acme.block.customAction",
component: CustomBlockAction,
},
],
},
},
})

A hide directive ({ id, hidden: true }) names an existing contribution (usually a built-in) in the same resolved list and removes it without deleting the entry that documents its order/before/after — never reuse an id for a different component.

BUILTIN_CONTRIBUTION_IDS (packages/core/src/components/builtinIds.ts) covers:

| Group | Ids | | --- | --- | | block | duplicate, insertAbove, insertBelow, moveUp, moveDown, remove, copy, pasteAbove, pasteBelow | | blocksHeader | copyAll, pasteAppend, replaceAll | | array | duplicate, moveUp, moveDown, remove |

An additive entry (ComponentContributionInput) is either a component contribution or a hide directive:

{
id: string // stable, vex.-prefixed for built-ins
component: Component // the contribution's component
order?: number // sort key; ties broken by array position (default 0)
before?: string | string[] // id(s) this must render before
after?: string | string[] // id(s) this must render after
hidden?: boolean // omit from the resolved list without deleting the entry
access?: (ctx: { hasPermission: (resource: string, action: string) => boolean }) => boolean
condition?: (props: { data, siblingData }) => boolean // same signature as admin.condition
}
// or, to hide an existing entry:
{ id: string; hidden: true }

access gates on RBAC and is checked first; condition is checked after and reuses admin.condition’s (data, siblingData) signature — for blocks.item.actions/array.item.actions, siblingData resolves to that one item’s own fields, not the whole document. Each contribution renders inside its own error boundary, so one bad third-party component cannot take down the surrounding form.

An override entry (ComponentOverrideInput) has no order/before/after/ access/condition — chain order is array order, and the chain itself decides whether to render anything by calling or skipping renderDefault:

{
id: string
component: (props) => ReactNode // props includes renderDefault
}

Every slot has a stable, flat, dot-joined id exported from @vexcms/core as SLOT_IDS — useful for error messages and, later, plugin conflict reporting:

import { SLOT_IDS } from "@vexcms/core"
SLOT_IDS.field.input // "field.input"
SLOT_IDS.field.cell // "field.cell"
SLOT_IDS.field.label // "field.label"
SLOT_IDS.field.description // "field.description"
SLOT_IDS.field.beforeInput // "field.beforeInput"
SLOT_IDS.field.afterInput // "field.afterInput"
SLOT_IDS.blocks.itemActions // "blocks.item.actions"
SLOT_IDS.blocks.headerActions // "blocks.header.actions"
SLOT_IDS.array.itemActions // "array.item.actions"

Nested admin.components keys map 1:1 onto these: admin.components.beforeInput on a field resolves SLOT_IDS.field.beforeInput; admin.components.itemActions on a blocks() field resolves SLOT_IDS.blocks.itemActions.

Collection and global edit views expose eight slots under admin.components.edit, set at the root of defineConfig() for every resource or on one collection/global’s own admin.components.edit:

import { defineConfig } from "@vexcms/core"
export default defineConfig({
admin: {
components: {
edit: {
header: {
actions: [{ id: "acme.exportPdf", component: ExportPdfAction }],
before: [],
after: [{ id: "acme.envBadge", component: EnvBadge }],
},
actionsMenu: {
items: [{ id: "acme.exportCsv", component: ExportCsvMenuItem }],
},
beforeFields: [],
afterFields: [],
sidebar: {
before: [],
after: [{ id: "acme.cacheNote", component: CacheNote }],
},
},
},
},
})

SLOT_IDS.edit names them: edit.header.actions, edit.header.before, edit.header.after, edit.actionsMenu, edit.beforeFields, edit.afterFields, edit.sidebar.before, edit.sidebar.after. All eight are additive contribution lists — the same ComponentContributionInput shape as blocks.item.actions — merged root → collection/global level with the same order/before/after/hidden/access/condition resolution as every other slot in this guide. Every entry’s component receives EditSlotProps: { kind: "collection" | "global"; slug: string }.

Save, Publish/Unpublish, Delete and Cancel render through edit.header.actions as vex.* contributions, under BUILTIN_CONTRIBUTION_IDS.edit. Header order (left to right, after the ⋯ menu and status badge): Preview, Save (Draft), Publish or Unpublish, Cancel, Delete.

Save (Draft)/Publish-or-Unpublish/Cancel (and, for a non-versioned resource, Save/Cancel) are ALWAYS rendered — greyed out (disabled) when not applicable (no permission, no unsaved changes, viewing a read-only version), never hidden by state. Hiding any of these by state would shift every button after it on every edit; a project that genuinely wants one gone uses a hidden directive (below) instead, an explicit opt-in rather than a side effect of document state.

Publish and Unpublish share one spot — both ids (vex.publish/vex.unpublish) are adjacent in header order, but exactly one ever renders: Unpublish when the document is published, the form is clean, and no newer draft/viewed-version exists; Publish otherwise. Both buttons share a fixed minimum width (the wider “Unpublish” label’s width), so the swap between them never shifts Cancel or Delete beside it.

import { BUILTIN_CONTRIBUTION_IDS } from "@vexcms/core"
BUILTIN_CONTRIBUTION_IDS.edit.save // "vex.save"
BUILTIN_CONTRIBUTION_IDS.edit.publish // "vex.publish"
BUILTIN_CONTRIBUTION_IDS.edit.unpublish // "vex.unpublish"
BUILTIN_CONTRIBUTION_IDS.edit.delete // "vex.delete"
BUILTIN_CONTRIBUTION_IDS.edit.cancel // "vex.cancel"
BUILTIN_CONTRIBUTION_IDS.edit.versionHistory // "vex.versionHistory"
BUILTIN_CONTRIBUTION_IDS.edit.livePreview // "vex.livePreview"
BUILTIN_CONTRIBUTION_IDS.edit.revalidate // "vex.revalidate"
BUILTIN_CONTRIBUTION_IDS.edit.duplicate // "vex.duplicate"

Delete renders with a trash icon on the header button, the collapsed ⋯ menu item, and the confirmation dialog’s own Delete button — all three share the same Trash2 icon.

vex.versionHistory (“History”, versioned resources only), vex.revalidate (a manual ISR purge) and vex.duplicate (“Duplicate”, collection documents only — not media or globals) render through edit.actionsMenu instead of edit.header.actions: a ⋯ menu to the left of the status badge (DocumentEditActionsMenu), for actions used rarely enough that a standalone header button isn’t worth the width. Resolved order: Preview (only when the responsive collapse below has moved it in — otherwise Preview stays inline and History is the menu’s first item), History, Revalidate, Duplicate, then Delete (only once collapsed in, always last). The menu renders no trigger at all when its resolved, access-filtered contribution list is empty — a global with nothing contributed to edit.actionsMenu and nothing responsively collapsed into it (see below) gets no empty ⋯ button. This depends on EVERY item gating its own visibility through access (checked by useResolvedEditSlot before anything mounts) rather than returning null from render — a null-rendering item still counts as present in the resolved list, so a collection-only item like vex.revalidate declares access: (ctx) => ctx.resource?.kind === "collection" on its contribution entry instead of bailing out inside the component.

Selecting History opens a dialog listing every version (newest first) — the same content VersionHistoryDropdown renders standalone, reused through useEditActionsMenuDialog (below) so the list survives the ⋯ menu closing on select.

Hide one the same way as a block action — a hide directive naming the id, in whichever slot’s resolved list the id actually renders in (edit.header.actions for vex.delete, edit.actionsMenu for vex.duplicate):

import { BUILTIN_CONTRIBUTION_IDS } from "@vexcms/core"
admin: {
components: {
edit: {
header: {
actions: [{ id: BUILTIN_CONTRIBUTION_IDS.edit.delete, hidden: true }],
},
},
},
}

Unlike blocks/array actions, each edit.* slot that carries built-ins also takes an overrides map keyed by the built-in’s id — the key names what is wrapped, each entry’s own id names the wrapper, and array order is composition order (first = outermost):

import { BUILTIN_CONTRIBUTION_IDS } from "@vexcms/core"
admin: {
components: {
edit: {
header: {
overrides: {
[BUILTIN_CONTRIBUTION_IDS.edit.save]: [
{ id: "acme.save.confirm", component: ConfirmBeforeSave },
],
},
},
},
},
}

ConfirmBeforeSave receives renderDefault — the built-in Save button (or the next wrapper in, if more than one targets vex.save) — the same override-chain contract as Input/Cell. An override keyed to an id that isn’t in that slot’s resolved list throws VexComponentConfigError, same as a duplicate id within one resolved list.

A project item in edit.actionsMenu renders through EditActionsMenuItem (@vexcms/react) — a styled DropdownMenuItem wrapper, so a project’s own menu entries read identically to the built-ins (icon + label, disabled/ destructive states):

import { EditActionsMenuItem } from "@vexcms/react"
import { useVexDocument } from "@vexcms/react"
function ExportCsvMenuItem() {
const document = useVexDocument()
return (
<EditActionsMenuItem
icon={<DownloadIcon />}
onSelect={() => exportCsv(document.id)}
>
Export CSV
</EditActionsMenuItem>
)
}

Selecting any item in the ⋯ menu closes it — base-ui unmounts DropdownMenuContent and every item inside it the instant a DropdownMenuItem’s onSelect runs. A contribution that wants to open a dialog, or do anything after a promise that resolves once the menu is already gone (the built-in Duplicate’s conflict dialog, which opens only after its create request rejects), cannot render that dialog inline from its own JSX — by the time the promise settles, the item is already unmounted and its state is gone.

useEditActionsMenuDialog() (@vexcms/react) opens content through the menu’s host instead — rendered by DocumentEditActionsMenu itself, as a sibling of the dropdown, so it survives the dropdown closing:

import { EditActionsMenuItem, useEditActionsMenuDialog } from "@vexcms/react"
function ArchiveMenuItem() {
const dialog = useEditActionsMenuDialog()
return (
<EditActionsMenuItem
onSelect={() =>
archiveDocument().catch((error) => {
// The menu has already closed by the time this runs — `dialog.open`
// still renders, because the host lives outside DropdownMenuContent.
dialog.open(<ArchiveConflictDialog error={error} onClose={dialog.close} />)
})
}
>
Archive
</EditActionsMenuItem>
)
}

open(node) replaces whatever dialog node is currently showing; close() clears it. Throws outside a DocumentEditActionsMenu-rendered contribution, the same pattern as useVexDocument/useDocumentEditRuntime.

CollectionEditView/GlobalEditView keep the header on one row at every width instead of wrapping: as the row narrows, the title truncates first (plain CSS — no hard minimum, so it can truncate all the way to empty), and only once it’s narrower than a conceptual floor (HEADER_TITLE_VALUE_MIN_WIDTH_PX in useHeaderActionsCollapse) does vex.livePreview move into edit.actionsMenu (right after vex.versionHistory, which already lives there permanently for a versioned resource), then vex.delete (last item) — collection documents only, since globals have no delete. vex.save/vex.cancel and, for versioned resources, Publish/Unpublish never collapse; on a narrow-enough viewport with many of those always-inline actions (e.g. a versioned collection’s full button row on a phone), the title can legitimately truncate to nothing once both collapsible actions are already in the menu and there is still no room left.

useHeaderActionsCollapse (@vexcms/react) drives this by measuring, not by a fixed breakpoint: it compares the header row’s available width against the title’s floor plus the actions group’s current natural width (both shrink-0, so their rendered width is always exact, collapsed or not), via ResizeObserver. Each collapse’s freed width is remembered and used as the threshold to restore it, so a resize that lands exactly on the boundary doesn’t thrash between the two states. A project’s own edit.header.actions/ edit.actionsMenu contributions are unaffected — only the two built-ins above ever move between the two slots.

Built-in and third-party edit-view contributions read document-level state through useVexDocument() (@vexcms/react), not through form state:

import { useVexDocument } from "@vexcms/react"
function PublishedSince() {
const document = useVexDocument()
if (document.status !== "published") return null
return <span>Published</span>
}
type VexDocumentState = {
kind: "collection" | "global"
slug: string
id?: string // absent on a new, unsaved document
saved: Record<string, unknown> | undefined // last saved values, not live form state
isDirty: boolean
status?: "draft" | "published" // versioned resources only
canEdit: boolean
}

It deliberately excludes live field values — useVexForm already exposes those, and re-exporting them here would re-render every useVexDocument() consumer on every keystroke. It throws outside VexDocumentProvider, which both edit views mount.

A contribution that navigates after its own mutation — not a click, which VexLink already covers — calls useVexNavigate() (@vexcms/react) instead of its framework’s own useRouter().push: it returns (href) => void guarded by the same unsaved-changes check as an in-app link click, falling back to a full page navigation when the current adapter has no useNavigate capability. A raw framework router.push bypasses that guard entirely.

import { useVexNavigate } from "@vexcms/react"
function ArchiveAndLeave() {
const navigate = useVexNavigate()
return <button onClick={() => navigate("/admin/collections/posts")}>Archive</button>
}

Slots render through SlotOutlet (packages/react/src/slots/SlotOutlet.tsx), which merges a call site’s built-in contributions with the project/field’s own list, re-resolves order over the merged array (so a project’s before/after can target a built-in id), evaluates access/condition, and wraps each surviving contribution in ComponentErrorBoundary. Override chains resolve through resolveFieldComponent/resolveFieldCellComponent (packages/react/src/slots), with Input overrides applied per field by useFieldControlOverride (packages/react/src/fields), composing field → project-type → built-in into one renderDefault chain.

A duplicate id within one resolved list — two real contributions sharing an id, not a hide directive naming one — throws VexComponentConfigError.

Edit-view slots render through the same SlotOutlet via DocumentEditSlotOutlet (packages/react/src/documents/DocumentEditSlotOutlet.tsx), which also resolves each slot’s overrides map and wraps every override layer — like every other override chain in this guide — in its own ComponentErrorBoundary keyed by the override id. edit.actionsMenu resolves through the same useResolvedEditSlot/EditSlotContribution machinery, shared with DocumentEditSlotOutlet and reused by DocumentEditActionsMenu (packages/react/src/documents/DocumentEditActionsMenu.tsx), the ⋯ menu’s own component.