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.
| Name | Type | Description |
|---|---|---|
You build | site code | Host element, render() DOM, fields schema, optional custom sidebar, CSS/JS |
Macro provides | platform | Save, draft/live, locales, permissions, overlay form, asset library APIs |
1. Project setup
pnpm add @macrocontent/sdk @macrocontent/overlayimport { 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
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
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 },
}| Name | Type | Required | Description |
|---|---|---|---|
typeId | string | required | Namespaced id, e.g. @acme/hero (like an npm scope). |
label | string | required | Human name in the overlay / Dashboard. |
tag | string | optional | Custom element tag so the host can omit data-macro-type. |
fields | array | required | Schema for the overlay sidebar and Dashboard. |
defaultPayload | object | optional | Seed when the DB is empty / first sync. |
render | fn | required | Build live + preview DOM from the payload. |
slots | array | optional | Named regions — items (CMS list) or project (light DOM). |
editor | object | optional | Optional 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).
| Name | Type | Required | Description |
|---|---|---|---|
key | string | required | Payload path segment (e.g. title, imageAssetId). |
label | string | optional | Sidebar / Dashboard label. |
input | enum | required | text | textarea | richtext | asset | boolean | number | url | select | json |
mimePrefix | string | optional | For asset — e.g. image/ or video/. |
options | array | optional | For select — { value, label }[]. |
inline | boolean | optional | Hint to enable in-page text editing. |
contextBar | boolean | optional | Hint for compact overlay actions (e.g. Choose image). |
group | string | optional | Sub-tab id under Content (content, design, …). |
| Name | Type | Description |
|---|---|---|
text | input | Single-line; set inline: true for in-page edit |
textarea | input | Multi-line plain text |
richtext | input | Formatted HTML |
asset | input | Media picker; use mimePrefix + contextBar |
boolean | input | Toggle |
number | input | Numeric |
url | input | URL field |
select | input | Dropdown with options |
json | input | Complex structures / escape hatch |
// 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 todefaultPayload. - Mark inline text with
macroFieldAttrand image anchors withmacroAssetFieldAttrso editors get the same UX as primitives. - If you wipe
innerHTML, re-attach slot regions afterward (see Slots guides) —mountSlotRegioncaches regions on the host.
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
import { myHeroNode } from './components/my-hero/macro-node'
export const myNodes = [myHeroNode]<acme-hero
data-macro-key="home.hero"
data-macro-type="@acme/hero"
class="my-hero-host"
></acme-hero>| Name | Type | Required | Description |
|---|---|---|---|
data-macro-key | string | required | Content key unique per website. |
data-macro-type | typeId | optional | Required unless the custom element tag is registered. |
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)
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.
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:
{ 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.
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
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 inrender().Assets are UUIDs
Never persist resolved file URLs as the source of truth. Resolve at render time.
Defaults look good empty
defaultPayloadshould produce a usable first paint for buyers and editors.Presentation honesty
Content blocks do not ship a private design system. Styled widgets expose design fields.
Register once
Export from
macro-nodes.ts/nodesin config. Duplicate typeIds fail loudly — fix before deploy.
Do
- Namespace
typeIdlike an npm scope (@brand/block). - Keep shell settings in
fieldsand repeating structure in items slots. - Use
createComponentPayloadEditorfor 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
stashSlotProjectsfirst. - Don’t put secrets or management tokens in component bundles.