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.
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):
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:
decorate(block) — imperative DOM mutationCallout.jsx — declarative component'use client'lib/registry.jsaem.js lazy-loads per block instancecallout.csscallout.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 shimname.js; both underblocks/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 interactivenpm run lintclean; renders from an authored table