Developers

Custom components

Build multi-field blocks with MacroNodeDefinition — you own the host element, fields schema, and render(); Macro owns draft/live, locales, permissions, assets, and the overlay form.

When to use a component
Use a primitive (data-macro-key on native HTML) for a single heading, image, or link. Build a component for heroes, pricing tables, sliders, FAQs, and any block where several fields share one layout and behavior.

Mental model

A component is one JSON payload per data-macro-key and locale, plus optional slots (real child content nodes). Editors never edit your TypeScript — they edit the payload and slot children through the overlay and inline UI.

NameTypeDescription
You buildsite codeHost element, render() DOM, fields schema, optional custom sidebar, CSS/JS
Macro providesplatformSave, draft/live, locales, permissions, overlay form, asset library APIs

1. Project setup

bash
pnpm add @macrocontent/sdk @macrocontent/overlay
macro.config.js
import { myNodes } from './macro-nodes.ts'

export const macroConfig = {
  apiBaseUrl: 'https://api.macrocontent.dev',
  websiteId: '<platform-website-id>',
  publicKey: 'pk_…',
  defaultLocale: 'en',
  enableInlineEditing: true,
  nodes: myNodes,
}

After editor login, Macro syncs fields into node_definitions. You do not register Dashboard “blueprints” by hand.

2. Recommended folder layout

macro.config.js
macro-nodes.ts
macro-node.ts
my-hero.ts
my-hero.css

Reference implementations in the demo site: simple hero registration in macro-nodes.ts, payload-based styled widget in components/asteria-slider/, items-slot content block in components/content-slider/, project-slot shell in components/free-section/.

3. Define a MacroNodeDefinition

components/my-hero/macro-node.ts
import type { MacroNodeDefinition } from '@macrocontent/sdk'
import { resolveComponentPayload } from '@macrocontent/sdk'
import { renderMyHero } from './my-hero'

export const myHeroNode: MacroNodeDefinition = {
  // Namespaced like an npm package — unique per catalog
  typeId: '@acme/hero',
  label: 'Hero',
  // Optional custom element — then the host can omit data-macro-type
  tag: 'acme-hero',

  defaultPayload: {
    title: 'Headline',
    subtitle: '<p>Subtitle</p>',
    imageAssetId: '',
    ctaLabel: 'Learn more',
    ctaHref: '/about',
  },

  fields: [
    { key: 'title', label: 'Headline', input: 'text', inline: true, group: 'content' },
    { key: 'subtitle', label: 'Subtitle', input: 'richtext', group: 'content' },
    {
      key: 'imageAssetId',
      label: 'Image',
      input: 'asset',
      mimePrefix: 'image/',
      contextBar: true,
    },
    { key: 'ctaLabel', label: 'CTA', input: 'text' },
    { key: 'ctaHref', label: 'CTA URL', input: 'url' },
  ],

  render(element, rawPayload, ctx) {
    const payload = resolveComponentPayload('@acme/hero', rawPayload)
    renderMyHero(element, payload, ctx)
  },

  // Optional — only for complex sidebars (reorder, custom chrome)
  // editor: { mountContentPanel: mountMyHeroEditor },
}
NameTypeRequiredDescription
typeIdstringrequiredNamespaced id, e.g. @acme/hero (like an npm scope).
labelstringrequiredHuman name in the overlay / Dashboard.
tagstringoptionalCustom element tag so the host can omit data-macro-type.
fieldsarrayrequiredSchema for the overlay sidebar and Dashboard.
defaultPayloadobjectoptionalSeed when the DB is empty / first sync.
renderfnrequiredBuild live + preview DOM from the payload.
slotsarrayoptionalNamed regions — items (CMS list) or project (light DOM).
editorobjectoptionalOptional mountContentPanel / mountInline / mountContextBar overrides.

Field schema reference

fields[] is the single source of truth for the overlay sidebar and the Dashboard content explorer (when you do not mount a custom panel).

NameTypeRequiredDescription
keystringrequiredPayload path segment (e.g. title, imageAssetId).
labelstringoptionalSidebar / Dashboard label.
inputenumrequiredtext | textarea | richtext | asset | boolean | number | url | select | json
mimePrefixstringoptionalFor asset — e.g. image/ or video/.
optionsarrayoptionalFor select — { value, label }[].
inlinebooleanoptionalHint to enable in-page text editing.
contextBarbooleanoptionalHint for compact overlay actions (e.g. Choose image).
groupstringoptionalSub-tab id under Content (content, design, …).
NameTypeDescription
textinputSingle-line; set inline: true for in-page edit
textareainputMulti-line plain text
richtextinputFormatted HTML
assetinputMedia picker; use mimePrefix + contextBar
booleaninputToggle
numberinputNumeric
urlinputURL field
selectinputDropdown with options
jsoninputComplex structures / escape hatch
fields-examples.ts
// Content group + design group (styled widget)
fields: [
  { key: 'autoplay', label: 'Autoplay', input: 'boolean', group: 'content' },
  { key: 'intervalMs', label: 'Interval (ms)', input: 'number', group: 'content' },
  {
    key: 'theme',
    label: 'Theme',
    input: 'select',
    group: 'design',
    options: [
      { value: 'light', label: 'Light' },
      { value: 'dark', label: 'Dark' },
    ],
  },
  { key: 'accent', label: 'Accent', input: 'text', group: 'design' },
]

4. Implement render()

Build DOM from the payload on every hydrate / preview. Rules that bite in production:

  • Store asset UUIDs in the payload; resolve display URLs with ctx.resolveAssetUrl(id).
  • Prefer resolveComponentPayload(typeId, raw) so missing keys fall back to defaultPayload.
  • Mark inline text with macroFieldAttr and image anchors with macroAssetFieldAttr so editors get the same UX as primitives.
  • If you wipe innerHTML, re-attach slot regions afterward (see Slots guides) — mountSlotRegion caches regions on the host.
components/my-hero/my-hero.ts
import type { ComponentRenderContext } from '@macrocontent/sdk'
import { macroFieldAttr, macroAssetFieldAttr } from '@macrocontent/sdk'

export function renderMyHero(
  element: Element,
  payload: Record<string, unknown>,
  ctx: ComponentRenderContext,
) {
  const title = typeof payload.title === 'string' ? payload.title : ''
  const imageAssetId =
    typeof payload.imageAssetId === 'string' ? payload.imageAssetId : ''
  const imageUrl =
    imageAssetId && ctx.resolveAssetUrl
      ? ctx.resolveAssetUrl(imageAssetId)
      : ''

  element.innerHTML = ''
  const card = document.createElement('article')
  card.className = 'my-hero'

  if (imageUrl) {
    const media = document.createElement('div')
    media.className = 'my-hero__media'
    Object.entries(macroAssetFieldAttr('imageAssetId')).forEach(([k, v]) =>
      media.setAttribute(k, v),
    )
    const img = document.createElement('img')
    img.src = imageUrl
    img.alt = ''
    media.appendChild(img)
    card.appendChild(media)
  }

  const h2 = document.createElement('h2')
  h2.textContent = title
  Object.entries(macroFieldAttr('title', 'text')).forEach(([k, v]) =>
    h2.setAttribute(k, v),
  )
  card.appendChild(h2)
  element.appendChild(card)
}

5. Register and place on the page

macro-nodes.ts
import { myHeroNode } from './components/my-hero/macro-node'

export const myNodes = [myHeroNode]
index.html
<acme-hero
  data-macro-key="home.hero"
  data-macro-type="@acme/hero"
  class="my-hero-host"
></acme-hero>
NameTypeRequiredDescription
data-macro-keystringrequiredContent key unique per website.
data-macro-typetypeIdoptionalRequired unless the custom element tag is registered.
typeId ↔ tag
Prefer a namespaced typeId like @acme/hero. If you set tag: 'acme-hero', the custom element can omit data-macro-type. Duplicate typeId or tag values throw at register time.

Editing surfaces (three layers)

text
Live page (your render())
  ├─ Inline: data-macro-field (click text on the page)
  └─ Context bar: macroAssetFieldAttr (Choose image)
Overlay sidebar
  ├─ Default: form generated from fields[]
  └─ Custom: editor.mountContentPanel
Dashboard → Content explorer (same fields schema)
A) Declarative sidebar (most components)

Omit editor.mountContentPanel. Macro builds inputs from fields[]: text, textarea, richtext, boolean, number, URL, select, JSON, asset. Group fields with group: 'content' | 'design' | … for sub-tabs.

B) Custom sidebar

For reorderable payload arrays or specialized UX, mount your own panel and use createComponentPayloadEditor for live preview, debounced save, and automatic draft mode. You should not hand-roll saveNodePayload + view-mode switching.

mount-editor.ts
import {
  createComponentPayloadEditor,
  resolveComponentPayload,
  type MacroNodeEditorApi,
} from '@macrocontent/sdk'

function mountSliderEditor(host: HTMLElement, api: MacroNodeEditorApi): () => void {
  const state = normalizePayload(
    resolveComponentPayload(api.typeId, api.payload as Record<string, unknown>),
  )

  const editor = createComponentPayloadEditor({
    api,
    getPayload: () => stateToRecord(state),
    applyPreview: (payload) => {
      if (api.element instanceof HTMLElement) {
        renderSlider(api.element, payload, {
          resolveAssetUrl: api.getAssetFileUrl
            ? (id) => api.getAssetFileUrl!(id)
            : undefined,
        })
      }
    },
  })

  // Wire inputs → mutate state → editor.scheduleSave() / editor.commit()
  return () => editor.teardown()
}
C) Inline + context bar

In render(), apply helpers so editors click text on the page or use “Choose image” in the overlay bar. Match schema hints:

typescript
{ key: 'title', input: 'text', inline: true }
{ key: 'imageAssetId', input: 'asset', mimePrefix: 'image/', contextBar: true }

Nested payload paths work for arrays: macroFieldAttr(`slides.${i}.title`, 'text'). For carousels, mark the visible slide (aria-hidden="false") so the context bar targets the right image.

Content vs styled presentation

Store listings declare a presentation. Local components should follow the same split so hosted buyers are not surprised:

Structure and behavior only — no bundled design. Inherit the site (color: inherit, font: inherit, currentColor) and expose at most a few tokens (--macro-accent, --macro-radius). Reference: @macro/content-slider.

Slots instead of giant payloads

When editors must add/remove/reorder structural pieces (slides, FAQ rows, cards), prefer an items slot over a JSON array in the payload. When site developers must project arbitrary HTML into a region, use a project slot.

items-slot.ts
export const contentSliderNode: MacroNodeDefinition = {
  typeId: '@acme/content-slider',
  label: 'Content Slider',
  tag: 'acme-content-slider',
  defaultPayload: { autoplay: true, intervalMs: 5000 },
  // Shell only — slide content lives in the slot
  fields: [
    { key: 'autoplay', label: 'Autoplay', input: 'boolean', group: 'content' },
    { key: 'intervalMs', label: 'Interval (ms)', input: 'number', group: 'content' },
  ],
  slots: [
    {
      name: 'slides',
      kind: 'items',
      label: 'Slides',
      min: 1,
      max: 12,
      fields: [
        { field: 'image', type: 'image', label: 'Image' },
        { field: 'headline', type: 'text', label: 'Headline' },
        { field: 'body', type: 'richtext', label: 'Body' },
        { field: 'cta', type: 'link', label: 'Call to action' },
      ],
    },
  ],
  render(element, payload, ctx) {
    renderContentSlider(element, payload, ctx)
  },
}

Deep dives: Slots: items · Slots: project.

Checklist before you ship

  1. Schema matches DOM

    Every editor-facing value has a fields[] row (or lives in a slot). Inline / context-bar hints match the attributes you set in render().

  2. Assets are UUIDs

    Never persist resolved file URLs as the source of truth. Resolve at render time.

  3. Defaults look good empty

    defaultPayload should produce a usable first paint for buyers and editors.

  4. Presentation honesty

    Content blocks do not ship a private design system. Styled widgets expose design fields.

  5. Register once

    Export from macro-nodes.ts / nodes in config. Duplicate typeIds fail loudly — fix before deploy.

Do

  • Namespace typeId like an npm scope (@brand/block).
  • Keep shell settings in fields and repeating structure in items slots.
  • Use createComponentPayloadEditor for custom sidebars.
  • Test with the overlay open: inline, sidebar, locale switch, draft → publish.

Don’t

  • Don’t rebuild primitives (single H1 / image) as components.
  • Don’t hardcode brand colors in content-block CSS.
  • Don’t wipe project-slot light DOM without stashSlotProjects first.
  • Don’t put secrets or management tokens in component bundles.

Next