Skip to content

Client reference (@martipops/cms-vue) ​

The plugin ​

ts
import cms from '@martipops/cms-vue'

createApp(…)
  .use(ui)                          // Nuxt UI must be installed first
  .use(cms, {
    endpoint: '/admin/cms',         // where registerCmsRoutes is mounted
    mediaPicker: MediaPickerModal,  // optional; image/file fields need it
    fields: { weeklyHours: WeeklyHoursField },  // editors for custom field types
    mediaUrl: (key) => `/media/${key}`,          // optional; this is the default
    linkFolder: 'files',            // optional; media folder link fields offer (false hides it)
  })

It registers CmsPage, CmsSection, CmsCollection and CmsToolbar globally. With the Options API you can also import them by name for type checking.

Vite: add the package's plugin. It keeps the package out of Vite's dependency pre-bundling and bundles it into SSR builds, because the components import Nuxt UI's .vue files, which only your app's Vite can compile:

ts
// vite.config.ts
import cms from '@martipops/cms-vue/vite'

export default defineConfig({ plugins: [vue(), ui(), cms() /* … */] })

Tailwind: the components are styled with Tailwind classes, so Tailwind must scan them. Import the package's CSS entry after Nuxt UI; it points Tailwind at the components wherever the package is installed:

css
@import 'tailwindcss';
@import '@nuxt/ui';
@import '@martipops/cms-vue/tailwind.css';

Media picker contract. Props: open, folder (a field's folder, or linkFolder for links), title. Emits: update:open, and select with { key, url }.

Components ​

<CmsPage :content> ​

Registers a page's props with the store and tells the CmsSections inside which page they belong to. It renders no element of its own.

<CmsSection> ​

PropDefaultNotes
name—Section key
pagenearest CmsPageSet it when not inside a CmsPage (e.g. site content in a layout)
tagdivWrapper element; other attributes (class, id) pass through
button-classtop-2 right-2Positions the Edit … button (it's absolute)

Slot props: data (values with drafts applied), edit() (opens the panel), editable.

The edit-mode outline shows the section's state: dashed primary (editable), dashed warning (unsaved changes) or solid error (failed validation).

<CmsCollection :collection> ​

PropDefaultNotes
collection—CollectionProps from cms.collectionProps()
tagdiv
button-classtop-2 right-2
no-buttonfalseHide Edit … when items open themselves via edit(item)

Slot props: items (visible items with drafts applied), all (including hidden), edit(item?) (opens the panel, focused on that item), editable.

Each Item has { key, id, slug, hidden, data, isNew, dirty }. Use key for v-for keys: new, unsaved items have a temporary string key and id: null.

A common pattern is clicking a card to edit it:

vue
<CmsCollection v-slot="{ items, edit, editable }" :collection="specials" no-button>
  <div v-for="item in items" :key="item.key" @click.capture="editable && (edit(item), $event.preventDefault())">
    <SpecialCard :special="item.data" />
  </div>
</CmsCollection>

<CmsToolbar :shortcuts> ​

Render it once, in the layout, for signed-in users. It hosts the panel and history modal, guards navigation when there are unsaved drafts, and restores edit mode across page visits (in sessionStorage; ?edit in the URL turns it on).

shortcuts adds toolbar buttons for sections that aren't visible as a block on the page:

vue
<CmsToolbar
  :shortcuts="[{ label: 'Footer', icon: 'i-lucide-panel-bottom', page: 'site', section: 'footer' }]"
/>

Reading content in script ​

The slot data covers templates. For computed values (formatting, JSON-LD, nav menus), read through the store so drafts are included:

ExportUse
sectionValues(page, section)A section's values with drafts. The page must be registered
collectionItems(name)All items with drafts, in draft order
canEditPage(page) / canEditCollection(name)Edit mode on, and the server sent the schema
registerPage(props) / registerCollection(props)For data used outside a CmsPage/CmsCollection (e.g. the layout)
openPanel(panel)Open the panel from anywhere ({ kind: 'section', page, section } or { kind: 'collection', collection, focus? })
mediaUrl(key)Media key → URL (or null)
editorThe raw reactive store (read-only by convention)

Site-wide data pattern. Register shared props in the layout, then derive everything from the store. Every page that shows that data then previews drafts:

ts
// layout
watch: {
  siteContent: { immediate: true, handler: registerPage },
  locations: { immediate: true, handler: registerCollection },
}

// anywhere
const items = canEditCollection('locations') ? collectionItems('locations') : props.locations.items

Generated forms ​

CmsPanel renders one CmsField per field. CmsField picks the input by type, recurses into groups (CmsFieldGroup) and lists (CmsFieldList), and shows server errors on the field they belong to.

Groups and lists render CmsField through a small runtime registry (registry.field, set when the package loads) rather than importing it. That breaks the circular import, which would otherwise make Vue's type checker infer any for the whole component tree.

Behaviour notes ​

  • Discard throws away every draft. Exit with drafts asks first.
  • Navigating away with unsaved drafts asks first. Partial reloads (only: [...]) and non-GET visits don't count as leaving.
  • After a successful save, the store clears its drafts and calls router.reload(), so what's on screen is exactly what was stored.
  • After a failed save, nothing is stored, drafts are kept, and errors stay on their fields until each field is edited.