N
Naveenr.dev
Chapter 04
26 min read•2026-06-30
📖 Edge Delivery Services SeriesChapter 04 · 27 chapters

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.js discovers 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:

code
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.

code
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:

code
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).

javascript
// 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:

code
| Hero Banner             |
|-------------------------|
| desktopImage  | [image] |
| title         | My Title|
| size          | tall    |

EDS converts this table into a <div> structure:

html
<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.

code
| Cards                               |
|-------------------------------------|
| Image 1  | Title 1  | Description 1 |
| Image 2  | Title 2  | Description 2 |
| Image 3  | Title 3  | Description 3 |

In the DOM:

html
<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:

javascript
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.

code
| 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:

json
{
  "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:

javascript
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:

javascript
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:

json
{
  "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.

json
// 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.

css
/* 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).

javascript
// 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):

css
/* 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.

css
/* 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:

css
/* 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:

javascript
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.

javascript
// 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:

javascript
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:

javascript
// 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);
}
css
/* 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:

  1. Define the content the block needs.
  2. Author that content and get it onto the page.
  3. Inspect the delivered DOM.
  4. Create the block folder and the smallest possible decorate().
  5. Verify it loads (Console, Network).
  6. Enhance only what needs enhancing.
  7. Add block-scoped CSS.
  8. 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:

html
<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:

javascript
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:

javascript
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:

javascript
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:

  1. No <div class="hero block"> in the DOM — the problem is on the content side (authored? saved? published? correct page?), not in hero.js.
  2. Block present but unstyled — check the Network panel: did hero.css load? Then check whether decorate() added the classes your CSS targets. A rule like .hero .hero-title does nothing if hero-title was never added.
  3. decorate() did not run — check that hero.js was requested, its HTTP response, module syntax errors, the default export, and that the folder and file names match the block name.
  4. Cannot read properties of null — the block loaded and decorate() 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 Hero block lifecycle from authored content to final rendered block, with the DevTools panel to inspect at each stage: Elements for the input DOM, Network for JS and CSS loading, Console for decoration and errors
The Hero block lifecycle from authored content to final rendered block, with the DevTools panel to inspect at each stage: Elements for the input DOM, Network for JS and CSS loading, Console for decoration and errors

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:

javascript
// Silent failure
export function decorate(block) { }

// Correct
export default function decorate(block) { }

Mistake 2: Block folder name does not match file names

code
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

javascript
// 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

css
/* 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:

css
.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:

css
.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.

javascript
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: true and 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 > div to 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.