Build for Dunea

Blocks: sections, blocks and app embeds

Ship owner-editable sections in a plugin or theme: block anatomy, settings, presets, embeds, the import allowlist and how blocks are reviewed.

A block is a React component plus a small schema. Owners place blocks on their pages from the Sections panel in Dunea, reorder them, hide them and edit their settings, without code and without asking the AI. Blocks are how a plugin puts something visible on a site (a reviews strip, a booking widget, a size chart) and how a theme ships real, editable sections instead of only design direction.

There are three kinds:

KindWhat it isExamples
sectionA full-width part of a page. Lives in a section group (a page); owners add, reorder and hide it.Hero, pricing table, product grid, reviews strip
blockA smaller piece inside a section that accepts blocks.A testimonial card, an FAQ question, a "buy" button
embedRendered once per page, outside any section. Owners switch it on or off.Announcement bar, chat bubble, cookie notice

Where blocks live in an app

frontend/src/blocks/<ns>/<name>.tsx          the component (default export)
frontend/src/blocks/<ns>/<name>.block.json   its schema
frontend/src/content/sections.json           section groups (pages) → ordered instances + settings
frontend/src/dunea/blocks.tsx                the runtime: <SectionGroup id>, <BlockEmbeds/>, helpers

<ns> is the namespace:

  • local: blocks the Dunea AI writes for this app.
  • theme: the applied theme's blocks (written when the theme is applied).
  • your listing slug: your plugin's blocks, written by Dunea when the plugin is installed and removed when it is uninstalled.

An instance in sections.json references a block by <ns>/<name>, for example acme-reviews/stars. Settings live in the app's repository, so an owner's edits show in the preview straight away and go live with their next publish, exactly like code.

Anatomy of a block

A block in your manifest is its schema plus the component source:

// dunea.plugin.ts
import { definePlugin } from '@dunea/plugin-sdk';
import { readFileSync } from 'node:fs';

export default definePlugin({
  id: 'acme-reviews',
  // …listing fields…
  permissions: ['site:blocks', 'reviews:read'],
  blocks: [
    {
      name: 'stars',
      title: 'Star rating strip',
      kind: 'section',
      description: 'Average rating, count and three recent quotes',
      settings: [
        { key: 'heading', label: 'Heading', type: 'single_line_text', default: 'Loved by our customers' },
        { key: 'show_quotes', label: 'Show quotes', type: 'boolean', default: true },
        {
          key: 'tone', label: 'Background', type: 'select', default: 'surface',
          options: [{ value: 'surface', label: 'Card' }, { value: 'accent', label: 'Accent' }],
        },
      ],
      pageTypes: ['home', 'product', 'page'],
      presets: [{ name: 'With quotes', settings: { show_quotes: true } }],
      code: readFileSync('./blocks/stars.tsx', 'utf8'),
    },
  ],
});
FieldMeaning
nameHandle, lowercase with -. The file name in the app (stars.tsx, stars.block.json). Unique in your listing.
title, descriptionWhat owners see in the "Add section" picker.
kindsection, block or embed.
settingsUp to 40 typed fields; the Sections panel renders a form from them. See Settings.
accepts, maxBlocksSections only: which child blocks it takes ('*', a block name in your namespace, or a full <ns>/<name>) and how many.
pageTypesWhere it makes sense: home, page, product, collection, cart, blog, article, funnel_step, any. The picker filters on it.
presetsUp to 10 starting points (settings, and child blocks for sections) shown in the picker.
codeThe component's TSX source, up to 60 KB.

Blocks need the site:blocks permission. Owners see it at install as "Add sections and blocks the owner can place on the site".

The component

// blocks/stars.tsx
import { useBlock, RichText, Link } from '@dunea/blocks';
import { Star } from 'lucide-react';
import clsx from 'clsx';

export default function Stars() {
  const { settings } = useBlock();

  return (
    <section
      className={clsx(
        'py-[var(--dunea-space-section)] px-[var(--dunea-space-gutter)]',
        settings.tone === 'accent' ? 'bg-[var(--dunea-color-accent)]' : 'bg-card',
      )}
    >
      <h2 className="font-heading text-[length:var(--dunea-text-3xl)]">{settings.heading}</h2>
      <div className="mt-4 flex gap-1" aria-hidden>
        {[0, 1, 2, 3, 4].map((i) => <Star key={i} className="h-5 w-5" />)}
      </div>
      <Link to="/reviews" className="mt-6 inline-block underline">Read all reviews</Link>
    </section>
  );
}
  • Default-export the component. It receives { settings, blocks, instance, schema } as props; useBlock() returns the same.
  • settings already has your defaults applied. Owners can clear any field, so handle empty values.
  • Style with the theme's classes and variables (bg-background, text-foreground, bg-primary, --dunea-*), never hardcoded colours, so your block looks right in every theme. See the Themes guide.

What @dunea/blocks gives you

ExportUse
useBlock(){ settings, blocks, instance, schema } of the current block
<InnerBlocks />Inside a section: renders its child blocks in order (hidden ones skipped)
<Image image={settings.photo} />Renders an image setting. Nothing without a safe URL; lazy by default
<Link to={settings.link}>Site paths go through the router; https, mailto: and tel: links render safely; anything else renders as text
<RichText text={settings.body} />A rich_text setting as safe markup: paragraphs, headings, lists, quotes, bold, italic, code, links. Never parses HTML
<Money value={settings.price} />, formatMoney()A money setting (minor units) in the visitor's format
useMetafield, useMetafields, useMetaobjects, useMetaobjectRead custom data

Import allowlist

Block code runs on the owner's site, so it may only import:

react   @dunea/blocks   lucide-react   clsx

Anything else fails the check (DUN-B100). There is no network access from block code: read data through the @dunea/blocks hooks, and do anything server-side in your own service through webhooks and the Plugin API.

Settings

Settings use the same typed fields as custom data, so a value that validates in the Sections panel validates everywhere:

TypeValuePanel input
single_line_text, multi_line_textstringtext, textarea
rich_textstring (safe markdown subset)textarea; render with <RichText>
integer, decimalnumber (validations.min / max)number
booleantrue / falseswitch
date, date_timeYYYY-MM-DD, ISO 8601date pickers
urlhttps://… or /pathtext
email, colorstring, #RRGGBBtext, colour
money{ amount, currency } in minor unitsamount + currency
image{ url, alt? }URL + alt text
selectone of options[].valuedropdown
jsonany JSON, up to 16 KBJSON editor
product_reference, variant_reference, collection_referenceidpicker from the owner's shop
metaobject_referenceid (validations.metaobjectType)picker from that content type
page_reference/abouttext

Add list: true for up to 50 values. Every default must itself be valid; check reports one that isn't. Give each setting a sensible default so a freshly added section looks finished.

Presets and child blocks

A preset is what the owner starts from when they add your section:

{
  name: 'faq',
  title: 'FAQ',
  kind: 'section',
  accepts: ['faq-item'],
  maxBlocks: 20,
  settings: [{ key: 'heading', label: 'Heading', type: 'single_line_text', default: 'Questions' }],
  presets: [
    {
      name: 'Three questions',
      blocks: [
        { type: 'faq-item', settings: { question: 'Do you ship abroad?', answer: 'Yes, to 40 countries.' } },
        { type: 'faq-item', settings: { question: 'Can I return it?', answer: 'Within 30 days.' } },
        { type: 'faq-item', settings: { question: 'Is it vegan?', answer: 'Every product is.' } },
      ],
    },
  ],
  code: '…',
}

The section renders its children with <InnerBlocks />; each child is a kind: 'block' entry in your blocks with its own settings and component.

App embeds

An embed renders once per page, outside the page's sections. Owners switch it on in the Sections panel; its settings live in sections.json under embeds:

{ "embeds": { "acme-chat/bubble": { "enabled": true, "settings": { "greeting": "Hi! Questions?" } } } }

Every new Dunea app renders <BlockEmbeds /> once in App.tsx.

sections.json

The owner's layout. You never write it; Dunea, the owner and the Dunea AI do. It helps to know its shape when you design presets:

{
  "version": 1,
  "groups": {
    "home": {
      "pageType": "home",
      "sections": [
        { "id": "hero", "type": "local/hero", "settings": { "heading": "Fresh bread, every morning" } },
        { "id": "stars", "type": "acme-reviews/stars", "settings": { "show_quotes": false } }
      ]
    }
  },
  "embeds": {}
}

Ids are unique across the file. Dunea validates every save the same way in the panel, in studio and for the AI: known block types of the right kind, accepted children, maxBlocks, and every instance's settings against your fields (defaults applied first). A type the app no longer has renders nothing on the live site and a visible warning in the preview.

Install, update and uninstall

  • Install. When the install becomes active, Dunea writes your blocks to frontend/src/blocks/<your-slug>/ in the app. If the app's sandbox is asleep, they are written the next time it wakes (or the owner opens the Sections panel). Nothing is placed on a page automatically: owners add your sections themselves.
  • Update. When the owner's install moves to a new version, the folder is replaced with that version's blocks. Instances of blocks you removed are dropped from the owner's pages, so keep block names stable.
  • Uninstall or suspension. The folder is deleted and every section, child block and embed of yours is removed from the owner's pages.

Before writing, Dunea re-runs the static block checks on every block; a block that fails is never written.

Review rules

Block code is always reviewed by a person, and any change to blocks (or to a theme's presets) takes a version off the fast track. dunea-plugin check runs the static rules locally:

IdRule
DUN-B100Only react, @dunea/blocks, lucide-react and clsx may be imported
DUN-B101No eval or new Function
DUN-B102No dynamic import()
DUN-B103No network access (fetch, XMLHttpRequest, WebSocket, EventSource, sendBeacon)
DUN-B104No raw HTML (dangerouslySetInnerHTML, innerHTML, outerHTML, insertAdjacentHTML); use <RichText>
DUN-B105No cookies or browser storage
DUN-B106No navigating the page yourself; use <Link>
DUN-B107No <script> or <iframe> elements
DUN-B108No Node globals (process.…, require(), __dirname)
DUN-B109A default export (the component)

Reviewers also check that the block works in at least two very different themes, handles empty settings, and is accessible (real headings, alt text, keyboard-reachable controls).

Example: an announcement bar embed

{
  name: 'announcement',
  title: 'Announcement bar',
  kind: 'embed',
  settings: [
    { key: 'text', label: 'Message', type: 'single_line_text', required: true, default: 'Free shipping over ₹999' },
    { key: 'link', label: 'Link', type: 'url' },
  ],
  code: `import { useBlock, Link } from '@dunea/blocks';

export default function Announcement() {
  const { settings } = useBlock();
  if (!settings.text) return null;
  const body = <span>{settings.text}</span>;
  return (
    <div className="bg-primary px-4 py-2 text-center text-sm text-primary-foreground">
      {settings.link ? <Link to={settings.link} className="underline">{body}</Link> : body}
    </div>
  );
}
`,
}

Continue with Themes for theme blocks and presets, or Publishing.

On this page