How to Build a New Block

A block adds styling and behavior to authored content. Every block has two halves that meet at a contract: the author fills in a simple table, and a developer turns that table into markup. This project supports two ways to write that developer half — the classic Edge Delivery approach and this project's React framework — and this guide builds the same example both ways so you can see exactly what changes.

The most important idea: the authoring contract is identical in both approaches. The author types the same table either way. What differs is only the developer's implementation — legacy code mutates the DOM in a decorate() function, while the framework turns the delivered HTML into data and renders it with a React component.

Read shared contract View source

The shared contract

An author creates a block by making a table whose first cell names the block. Rows become records; columns become cells. For our example, an author types this table to create a Callout block (first cell names the block, info is a variant):

Callout (info)
⚠️
Publish content before it appears in the query index.

The delivered .plain.html for that table looks like this, and it is the same input for both approaches:

<div class="callout info">
  <div>
    <div>⚠️</div>
    <div>Publish content before it appears in the query index.</div>
  </div>
</div>

Path A — the legacy Edge Delivery block

In classic Edge Delivery, aem.js lazy-loads a block's JavaScript in the browser and calls its default decorate(block) function, which mutates the delivered DOM in place. Convention wires everything: the file name is the block name.

Create blocks/callout/callout.js:

// Runs in the browser; aem.js loads this and calls decorate() for each callout on the page.
export default function decorate(block) {
  [...block.children].forEach((row) => {
    const [icon, body] = row.children;
    icon?.classList.add('callout-icon');
    body?.classList.add('callout-body');
  });
}

Add blocks/callout/callout.css:

.callout { display: flex; gap: var(--space-s); padding: var(--space-m); border-left: 4px solid var(--accent-blue); background: var(--light-color); }
.callout .callout-icon { flex: 0 0 auto; }

That is the whole block. There is no registration step — the block name in the table (callout) tells aem.js to fetch blocks/callout/callout.js and blocks/callout/callout.css and run them. The code ships to and runs in the browser.

Path B — the framework block

This project replaces the "load and decorate" step with React. The delivered HTML is parsed into data by parseEds (in lib/eds/parse.js), then a component renders it. Static blocks are Server Components, so they ship zero client-side JavaScript.

The parsed block node your component receives:

{ kind: 'block', name: 'callout', variants: ['info'], rows: [ [ iconCell, bodyCell ] ] }
// each cell = { html: string, pictureOnly: boolean }

1. Write the component

Create blocks/callout/Callout.jsx:

import './callout.css';

// Server Component — zero client JS.
export default function Callout({ rows, variants = [] }) {
  const [iconCell, bodyCell] = rows?.[0] ?? [];
  return (
    <div className={['callout', ...variants, 'block'].join(' ')}>
      <span className="callout-icon" dangerouslySetInnerHTML={{ __html: iconCell?.html ?? '' }} />
      <div className="callout-body" dangerouslySetInnerHTML={{ __html: bodyCell?.html ?? '' }} />
    </div>
  );
}

2. Add the entry shim

The registry imports the lowercase block name, so add blocks/callout/callout.js:

import Callout from './Callout.jsx';

export default Callout;

3. Register the block

Unlike legacy, wiring is explicit. Add it to lib/registry.js:

import Callout from '../blocks/callout/callout.js';

export const registry = { hero: Hero, cards: Cards, columns: Columns, steps: Steps, tabs: Tabs, callout: Callout };

4. Style it

Use the same blocks/callout/callout.css as the legacy path — the CSS is identical between both approaches.

Side by side

The author input and the CSS are the same. Everything in the developer half differs:

Aspect
Legacy Edge Delivery
This framework
Author input
Same table
Same table
Developer writes
decorate(block) — imperative DOM mutation
Callout.jsx — declarative component
Where it runs
Browser (client)
Server (RSC), zero client JS by default
Interactivity
Always ships the block's JS
Opt-in with 'use client'
Wiring
Convention: file name is the block name
Explicit: entry shim plus lib/registry.js
Loading
aem.js lazy-loads per block instance
Next.js code-splits at render
CSS
callout.css
callout.css (identical)

When to use which

Both approaches read the same authored content, so authors never see a difference. For pages served through this project's Next.js layer, write the framework block: it renders on the server, ships no JavaScript unless you opt in, and keeps the performance profile that Edge Delivery is known for. Reach for a client component ('use client') only when the block needs browser APIs, state, or event handlers — see blocks/tabs/Tabs.jsx and blocks/header/Header.jsx. The legacy pattern remains useful to understand because it is how the upstream boilerplate and the wider Edge Delivery ecosystem work.

Checklist

  • Contract decided before code; handles missing or extra cells gracefully
  • Framework: component named Name.jsx; entry shim name.js; both under blocks/name/
  • Framework: registered in lib/registry.js
  • Legacy: file name matches the block name; no registration needed
  • CSS scoped to the block; tokens and page or section layout left to styles.css
  • 'use client' only if the block is genuinely interactive
  • npm run lint clean; renders from an authored table