EDS Blocks
Block architecture in EDS. Covers how scripts.js discovers blocks, how decorate() works, key-value versus multi-cell patterns, and common mistakes.
Content Objective
This chapter covers:
- The exact anatomy of an EDS block and what each file does
- How
scripts.jsdiscovers and loads blocks automatically - How to write a
decorate()function that correctly reads authored content - The difference between key-value blocks and multi-cell blocks
- How Universal Editor stores field values and how your JS reads them
- Why some blocks break silently with no errors in the console
- The CSS class naming rules that differ from everything you know about BEM
- How to build your first block — a Hero — step by step, and debug it from the browser
If Chapters 1 and 2 explained what EDS is and how to wire it up, and Chapter 3 mapped the project structure and content flow, this chapter explains how to actually build things.
Everything in EDS is a block. Understanding how blocks work — not just the happy path but the edge cases, the constraints, and the failure modes — is the most valuable skill in EDS development.
Most production bugs in EDS projects are block bugs. Most block bugs are the same three or four mistakes repeated.
This chapter is about spotting those mistakes before they ship.
The Anatomy of a Block
A block in EDS is exactly three things:
blocks/
└── hero/
├── hero.js ← The decorator: transforms raw HTML into UI
├── hero.css ← Styles scoped to this block
└── _hero.json ← Universal Editor model (xwalk only)
Nothing else is required. No build configuration. No registration file (the registration happens in component-definition.json which we covered in Chapter 2). No server-side code.
The block name is the folder name. The JS and CSS must be named identically to the folder. This is enforced by the EDS loader — it looks for blocks/{blockname}/{blockname}.js.
Folder: blocks/adc-button/
JS: blocks/adc-button/adc-button.js ← must match
CSS: blocks/adc-button/adc-button.css ← must match
If the JS file is named button.js inside the adc-button folder, it will never load. No error. No warning. Just silence.
How scripts.js Discovers Blocks
When a page loads, scripts.js runs automatically. One of its first jobs is to find all blocks on the page and load their code.
The process works like this:
1. Page HTML arrives from AEM
2. scripts.js runs
3. scripts.js queries: document.querySelectorAll('div.block')
4. For each .block element:
a. Read the block name from data-block-name attribute
(or from the first class after 'block')
b. Dynamically import: /blocks/{name}/{name}.js
c. Call the default exported function: module.default(block)
d. Load the CSS: /blocks/{name}/{name}.css
5. Block is decorated
The key point: scripts.js calls module.default(block) — the default export. This is why your decorator must use export default function decorate(block).
// WRONG — named export, will fail silently
export function decorate(block) {
block.textContent = 'Hello';
}
// CORRECT — default export
export default function decorate(block) {
block.textContent = 'Hello';
}
When you use a named export, module.default is undefined. The call undefined(block) throws a TypeError. But because scripts.js wraps block loading in a try/catch, this error is often swallowed. The block simply does not render and no visible error appears.
This is the number one silent failure in EDS blocks.
What the Block DOM Looks Like
Before we can read content from a block, we need to understand what the block's HTML actually looks like when it arrives from AEM.
When an author creates a block in Document Authoring, they create a table:
| Hero Banner |
|-------------------------|
| desktopImage | [image] |
| title | My Title|
| size | tall |
EDS converts this table into a <div> structure:
<div class="hero-banner block" data-block-name="hero-banner" data-block-status="loading">
<div>
<div>desktopImage</div>
<div><picture>...</picture></div>
</div>
<div>
<div>title</div>
<div>My Title</div>
</div>
<div>
<div>size</div>
<div>tall</div>
</div>
</div>
Each row in the table becomes a <div> containing two child <div> elements: the key and the value.
For Universal Editor (xwalk), the structure is the same. Field values from the UE properties panel are stored in the content and rendered as the same key-value table structure.
Your decorate() function receives this raw div structure as block. Your job is to transform it into the final UI.
Two Types of Blocks
EDS blocks come in two structural patterns.
Multi-Cell Blocks (Default)
In a multi-cell block, each row represents a piece of content. The position matters more than the label.
| Cards |
|-------------------------------------|
| Image 1 | Title 1 | Description 1 |
| Image 2 | Title 2 | Description 2 |
| Image 3 | Title 3 | Description 3 |
In the DOM:
<div class="cards block">
<div>
<div><picture>...</picture></div>
<div>Title 1</div>
<div>Description 1</div>
</div>
<!-- more rows -->
</div>
Reading multi-cell content in JS is positional:
export default function decorate(block) {
[...block.querySelectorAll(':scope > div')].forEach((row) => {
const [imageCell, titleCell, descCell] = row.querySelectorAll(':scope > div');
// imageCell.querySelector('picture')
// titleCell.textContent.trim()
// descCell.innerHTML
});
}
Key-Value Blocks (xwalk)
In a key-value block, each row is a named field. Position does not matter — the first cell is the field name, the second cell is the value.
| Hero Banner |
|-------------------------|
| size | tall |
| title | My Title|
| desktopImage | [image] |
This pattern is required when using Universal Editor with key-value: true in the block model, because UE can add fields in any order.
In the _block.json model file, you declare this:
{
"definitions": [
{
"title": "Hero Banner",
"id": "hero-banner",
"plugins": {
"xwalk": {
"page": {
"resourceType": "core/franklin/components/block/v1/block",
"template": {
"name": "hero-banner",
"model": "hero-banner",
"key-value": true
}
}
}
}
}
]
}
Reading key-value content in JS requires parsing by key:
const KNOWN_FIELDS = new Set([
'size', 'title', 'desktopimage', 'subtitle', 'description',
]);
export default function decorate(block) {
const props = {};
[...block.querySelectorAll(':scope > div')].forEach((row) => {
const cells = [...row.querySelectorAll(':scope > div')];
if (cells.length === 2) {
const key = cells[0].textContent.trim().toLowerCase().replace(/[\s-]/g, '');
if (KNOWN_FIELDS.has(key)) {
props[key] = cells[1];
return;
}
}
});
// Now use props.size, props.title, etc.
const size = props.size?.textContent.trim() || 'tall';
const title = props.title?.innerHTML || '';
}
The key normalization step is critical:
const key = cells[0].textContent
.trim() // Remove whitespace
.toLowerCase() // 'desktopImage' → 'desktopimage'
.replace(/[\s-]/g, ''); // 'desktop image' → 'desktopimage'
This normalization makes the key matching robust regardless of spacing or casing in the authored content.
The key-value Constraint You Must Know
There is one critical constraint with key-value: true blocks in Universal Editor.
You cannot use key-value: true AND a filter (child items / "+" button) in the same block template.
If you try this combination, the UE properties panel will crash with "Something went wrong" when any author tries to edit the block.
This is because key-value: true changes how the block's internal structure is managed in UE, and child items (which use the filter system) are incompatible with that structure.
Practical implication for buttons inside a hero:
If your hero needs two buttons, you cannot use a dynamic child items approach with a key-value hero. Instead, define fixed button fields directly in the block model:
{
"name": "button1Label", "component": "text", "label": "Button 1 Label" },
{
"name": "button1Link", "component": "aem-content", "label": "Button 1 Link" },
{
"name": "button1Style", "component": "select", "label": "Button 1 Style",
"options": [
{ "name": "Primary", "value": "primary" },
{ "name": "Secondary", "value": "secondary" }
]
}
This gives authors two fixed button slots. If you need dynamic buttons (add as many as needed), that requires a separate block without key-value: true.
Conditions in Block Models
Universal Editor supports showing or hiding fields based on the value of other fields using the condition property.
The condition format uses JSONLogic syntax, not shorthand.
// WRONG — causes "Unrecognized operation" crash in UE
"condition": { "size": "tall" }
// CORRECT — JSONLogic format
"condition": { "==": [{ "var": "size" }, "tall"] }
// Multiple conditions (AND)
"condition": {
"and": [
{ "==": [{ "var": "size" }, "medium"] },
{ "==": [{ "var": "layout" }, "greybackground"] }
]
}
The shorthand { "fieldName": "value" } looks reasonable but it is not valid JSONLogic. UE will throw "Unrecognized operation: fieldName" and the properties panel crashes.
Always use { "==": [{ "var": "fieldName" }, "value"] } for conditions.
CSS in EDS Blocks
EDS has specific CSS conventions that differ from traditional AEM frontend development.
No BEM
EDS uses flat kebab-case class names. BEM-style double underscores (__) and double dashes (--) are not used.
/* AEM / BEM style — DO NOT USE in EDS */
.hero__content { }
.hero__content--tall { }
/* EDS style */
.hero-content { }
.hero-tall .hero-content { }
This is enforced by ESLint in the standard EDS project setup. Your CSS will fail the lint check if it uses BEM-style selectors.
Variant Classes Are on the Block Root
In traditional AEM, modifier classes are typically on child elements. In EDS, variant classes added by your block's decorate() function are added to the block root element (the .hero.block element).
// Correct — add variant to the block root
block.classList.add('hero-tall', 'hero-copy-inside-circle');
This means your CSS selectors for variants must be compound selectors (no space between .hero and the variant):
/* WRONG — this looks for .hero-tall as a child of .hero */
.hero .hero-tall { }
/* CORRECT — .hero element that also has class hero-tall */
.hero.hero-tall { }
/* CORRECT — target children inside the variant */
.hero.hero-tall .hero-section { height: 640px; }
This is a subtle but critical distinction. If you use descendant selectors for variant classes, the rules will never match and the variant will have no visual effect.
CSS Custom Properties for Design Tokens
EDS uses CSS custom properties (CSS variables) instead of Sass variables or build-time tokens.
/* Define on the block root */
.hero {
--hero-header-color: #222731;
--hero-section-height-desktop: 640px;
--hero-yellow: #ffd100;
}
/* Consume anywhere within the block */
.hero .hero-header {
color: var(--hero-header-color);
}
This approach means tokens can be changed at runtime and themed without rebuilding anything.
Block CSS Is Scoped
Each block's CSS file is loaded only when that block is on the page. This means you can freely use class names like .hero-content, .hero-section, .hero-media inside hero.css without worrying about conflicts with other blocks.
However, you must still scope everything within .hero (the block class) to avoid leaking styles to the rest of the page:
/* BAD — leaks to entire page */
.container { max-width: 1140px; }
/* GOOD — scoped to the hero block */
.hero .container { max-width: 1140px; }
Extracting Images Correctly
Images in EDS blocks come from the content as <picture> elements (for optimized responsive images) or <img> elements or URL strings.
A robust image extraction utility handles all three cases:
function extractImage(cell) {
if (!cell) return null;
// Case 1: picture element (most common in optimized content)
const pic = cell.querySelector('picture');
if (pic) return pic;
// Case 2: bare img element
const img = cell.querySelector('img');
if (img) return img;
// Case 3: link wrapping an image
const a = cell.querySelector('a');
if (a) {
const linkedImg = a.querySelector('img');
if (linkedImg) return linkedImg;
const href = a.getAttribute('href') || '';
if (href.startsWith('/') || href.startsWith('http')) {
const el = document.createElement('img');
el.src = href;
el.alt = a.textContent.trim();
el.loading = 'lazy';
return el;
}
}
// Case 4: raw URL as text content
const src = cell.textContent.trim();
if (src.startsWith('/') || src.startsWith('http')) {
const el = document.createElement('img');
el.src = src;
el.alt = '';
el.loading = 'lazy';
return el;
}
return null;
}
Always use a utility like this rather than directly calling cell.querySelector('img'). If the authored content structure changes (UE sometimes wraps images in anchors), a direct querySelector will silently return null.
Building DOM Correctly
Never use innerHTML to build block structure. Use DOM APIs.
// BAD — XSS risk, hard to debug, string concatenation errors
block.innerHTML = `
<section class="hero-section">
<div class="hero-content">
<h1>${props.title}</h1>
</div>
</section>
`;
// GOOD — safe, explicit, easy to inspect in DevTools
const section = document.createElement('section');
section.className = 'hero-section';
const content = document.createElement('div');
content.className = 'hero-content';
const h1 = document.createElement('h1');
h1.className = 'hero-header';
h1.innerHTML = props.title.innerHTML; // innerHTML from a trusted DOM element is fine
content.append(h1);
section.append(content);
block.append(section);
The only exception is when copying innerHTML from an already-sanitized DOM element (like a cell from the authored content). That is safe because the content was already parsed by the browser and does not contain user input.
The block.innerHTML = '' Pattern
A common pattern in EDS blocks is to clear the block's original content at the start of decorate() and rebuild it completely:
export default function decorate(block) {
// Parse original content
const allRows = [...block.querySelectorAll(':scope > div')];
const props = {};
// ... parse rows into props ...
// Clear and rebuild
block.innerHTML = '';
// Build new DOM structure
const section = document.createElement('section');
// ...
block.append(section);
}
This is the standard approach. The reason: the original key-value table rows in the HTML are not the final UI. You parse the data out of them, then replace the entire block content with the correctly structured UI.
A Complete Minimal Block Example
Here is the smallest possible working key-value block:
// blocks/info-card/info-card.js
const FIELDS = new Set(['title', 'description', 'image']);
export default function decorate(block) {
const props = {};
[...block.querySelectorAll(':scope > div')].forEach((row) => {
const [keyCell, valCell] = [...row.querySelectorAll(':scope > div')];
if (keyCell && valCell) {
const key = keyCell.textContent.trim().toLowerCase().replace(/[\s-]/g, '');
if (FIELDS.has(key)) props[key] = valCell;
}
});
block.innerHTML = '';
const card = document.createElement('div');
card.className = 'info-card-inner';
if (props.image) {
const imgWrap = document.createElement('div');
imgWrap.className = 'info-card-image';
imgWrap.append(props.image.querySelector('picture') || props.image.querySelector('img'));
card.append(imgWrap);
}
if (props.title) {
const h2 = document.createElement('h2');
h2.className = 'info-card-title';
h2.textContent = props.title.textContent.trim();
card.append(h2);
}
if (props.description) {
const p = document.createElement('p');
p.className = 'info-card-body';
p.innerHTML = props.description.innerHTML;
card.append(p);
}
block.append(card);
}
/* blocks/info-card/info-card.css */
.info-card {
--card-border-radius: 8px;
--card-padding: 1.5rem;
}
.info-card-inner {
border-radius: var(--card-border-radius);
padding: var(--card-padding);
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
.info-card-image img {
width: 100%;
height: auto;
border-radius: var(--card-border-radius) var(--card-border-radius) 0 0;
}
.info-card-title {
font-size: 1.25rem;
font-weight: 700;
margin: 1rem 0 0.5rem;
}
.info-card-body {
color: #555;
line-height: 1.6;
}
This example demonstrates every key concept: key parsing with normalization, a KNOWN_FIELDS set, DOM building, and scoped CSS.
Building Your First Block: A Hero, Step by Step
The reference above gives you the patterns. Building your first block for real is less about the final code and more about the development sequence. Here is the loop I use, building a simple Hero with an image, title, description, and CTA. The result is not the point — the sequence is:
- Define the content the block needs.
- Author that content and get it onto the page.
- Inspect the delivered DOM.
- Create the block folder and the smallest possible
decorate(). - Verify it loads (Console, Network).
- Enhance only what needs enhancing.
- Add block-scoped CSS.
- Test incomplete and realistic content.
Start From Content, Not Markup
Coming from AEM, the instinct is to design the final HTL markup first and build the component to produce it. For an EDS block, do the opposite. Let the authored content reach the page, inspect what EDS actually delivers, and only then decide whether the DOM needs transformation. If you write hero.js against a structure that does not exist, you create unnecessary work immediately.
Keep the first Hero small. It does not need video, animation, multiple CTA layouts, personalization, or ten variants. Those are separate requirements. The first block exists to teach you how content becomes a frontend component.
Inspect the Delivered DOM First
Open DevTools and inspect the Hero before writing any JavaScript. A delivered Hero may look conceptually like:
<div class="hero block">
<div><div><picture>...</picture></div></div>
<div>
<div>
<h1>Explore our products</h1>
<p>Find the product that fits your needs.</p>
<p><a href="/products">Explore products</a></p>
</div>
</div>
</div>
Always verify the real structure from your project. What you are checking is where the image, heading, description, and link are, how many rows and cells exist, and which semantic elements already exist. That inspection determines what the JavaScript actually needs to do.
Start With the Smallest Possible decorate()
Do not write fifty lines of transformation before confirming the block loads. Start with one line:
export default function decorate(block) {
console.log('hero decorate', block);
}
Reload and open the Console. If you see the Hero element, you have already confirmed block discovery worked, hero.js was found and loaded, the default export was found, and decorate(block) executed. That is a far better starting point than debugging a large function.
If nothing logs, check the Network panel for blocks/hero/hero.js. If it was never requested, the problem is block naming or discovery — not your DOM logic yet. This is the same first-failure rule from the project structure and content flow chapter: find the first stage where the expected flow stops.
Enhance — Do Not Always Rebuild
The parse-clear-rebuild pattern shown earlier is for blocks where the delivered key-value table is not the final UI. But when the delivered HTML is already semantic and useful — a real <a>, a real heading — you can enhance it in place and do less work:
export default function decorate(block) {
const picture = block.querySelector('picture');
const title = block.querySelector('h1, h2');
const link = block.querySelector('a');
if (picture) picture.closest('div')?.classList.add('hero-media');
if (title) title.classList.add('hero-title');
if (link) link.classList.add('button', 'primary');
}
Every lookup is relative to block, not document, so the Hero only touches its own DOM. Every lookup is defensive, so an optional missing element does not throw. There is no framework and no rendering function here — the delivered content already did most of the work.
Before restructuring the DOM in JavaScript, ask whether CSS can solve the layout instead. JavaScript restructuring should have a reason.
Preserve Semantics
A CTA that navigates should stay an <a>. Do not replace it with a styled <div> just because it is easier to style — you lose keyboard behavior and navigation semantics. The same applies to headings: preserve a sensible heading hierarchy rather than turning everything into generic <div> elements. Styling should never remove semantics.
Test Incomplete and Realistic Content
The happy path is not enough. This code crashes the whole block when the CTA is optional and absent:
const link = block.querySelector('a');
link.classList.add('button'); // throws if link === null
Guard it, then actively test the Hero without a CTA, without a description, with a very long heading, with portrait and landscape images, and at mobile, tablet, and desktop widths. Sample content like a one-word title hides layout problems. The goal is to expose the assumptions you accidentally encoded into the block.
Defensive JavaScript does not mean every field is optional. If the Hero makes no sense without a title, make the title required in the authoring model. That gives two layers of protection: the authoring contract prevents invalid content, and the frontend handling prevents unexpected content from breaking the page.
Debugging the Hero
When something is wrong, map the symptom to the first place to look:
- No
<div class="hero block">in the DOM — the problem is on the content side (authored? saved? published? correct page?), not inhero.js. - Block present but unstyled — check the Network panel: did
hero.cssload? Then check whetherdecorate()added the classes your CSS targets. A rule like.hero .hero-titledoes nothing ifhero-titlewas never added. decorate()did not run — check thathero.jswas requested, its HTTP response, module syntax errors, the default export, and that the folder and file names match the block name.Cannot read properties of null— the block loaded anddecorate()ran; the failure is inside the DOM logic. That is a much narrower problem than "the Hero doesn't work."
The Hero block lifecycle ties the whole sequence together: authored Hero content becomes the delivered Hero DOM, the loader discovers the block, hero.js loads, decorate(block) enhances it, hero.css styles it, and the final Hero renders — with Elements, Network, and Console giving you a checkpoint at each stage.

The Most Common Block Mistakes
After working through complex blocks, these are the mistakes that appear most frequently.
Mistake 1: Named export instead of default export
Already covered, but worth repeating because it is the most common:
// Silent failure
export function decorate(block) { }
// Correct
export default function decorate(block) { }
Mistake 2: Block folder name does not match file names
blocks/
└── hero-banner/
├── hero.js ← WRONG — must be hero-banner.js
└── hero.css ← WRONG — must be hero-banner.css
Mistake 3: Using the wrong selector to query rows
// WRONG — queries ALL divs, includes nested divs from content
block.querySelectorAll('div')
// CORRECT — only direct children
block.querySelectorAll(':scope > div')
The :scope > div selector is critical. Without it, you pick up nested divs from within cell content (images, rich text) as if they were row containers.
Mistake 4: Variant classes as descendant selectors
/* Never matches — hero-tall is on the block root, not a child */
.hero .hero-tall { }
/* Correct — compound selector */
.hero.hero-tall { }
Mistake 5: Not overriding base block styles for variant elements
If your block has base CSS rules like:
.hero .hero-image {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
}
And a variant places .hero-image in a different layout context, you must explicitly override those base rules in the variant:
.hero.hero-medium.hero-image-in-circle .hero-image {
position: relative;
top: auto; left: auto;
width: auto; height: auto;
}
Not overriding base rules is a common cause of "image not showing" and "element in wrong position" bugs in variant blocks.
Mistake 6: Not accounting for the picture element
EDS wraps responsive images in <picture> elements. If you do cell.querySelector('img') instead of cell.querySelector('picture') || cell.querySelector('img'), the image will load but you will lose the responsive source sets.
Mistake 7: Modifying block.classList before it has variant classes
In a complex block, the variant classes (added via block.classList.add()) must be added before you build the DOM, because the DOM structure for different variants may differ.
export default function decorate(block) {
// Parse fields first
const props = parseProps(block);
const size = props.size?.textContent.trim() || 'tall';
// Add variant classes BEFORE clearing and rebuilding
block.classList.add(`hero-${size}`);
// Now clear and rebuild
block.innerHTML = '';
// ...
}
Key Takeaways
- Every block needs three files:
{name}.js,{name}.css,_{name}.json— all named identically to the folder export default function decorate(block)is mandatory — named exports cause silent failure- Two block patterns exist: multi-cell (positional) and key-value (named fields)
key-value: trueand child items (filter) cannot coexist in the same UE block template- Conditions use JSONLogic:
{ "==": [{ "var": "field" }, "value"] }— shorthand causes a crash - Variant classes are on the block root — use compound selectors
.hero.hero-tall, not.hero .hero-tall - CSS uses flat kebab-case — no BEM
__or--notation - Always use
:scope > divto query direct row children - Base CSS rules can conflict with variant layouts — always override explicitly
- Parse, then clear, then rebuild — the standard block decoration pattern
- Build your first block from inspection — start from the delivered DOM and the smallest
decorate(), then enhance only what needs it - Enhance in place when the delivered HTML is already semantic — rebuild only when the key-value table is not the final UI
- Test missing, long, and responsive content — a block that only works with perfect sample content is not finished
Next Steps
In the next chapter, we will cover Common Issues and Debugging.
We will go through the real errors that appear in production EDS projects:
- Why blocks load but render incorrectly
- The exact cause of "image not showing" in complex variant blocks
- JSON crashes in Universal Editor and how to read the error
- Why your CSS variant rule has no effect
- Content not updating after author edits
- Debugging tools: what to look for in Network, Console, and Elements tabs
Enjoyed this chapter?
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.