Back to Blog

The Complete Guide to Building Blocks in AEM Edge Delivery Services

aem edge-delivery-services blocks frontend
Ignacio Mancilla

Who writes this?

Ignacio Mancilla

The Complete Guide to Building Blocks in AEM Edge Delivery Services

If you’ve ever tried building blocks (components) in AEM Edge Delivery Services, you know the official Adobe docs give you the basics but leave out a lot of the real-world pain points. After building dozens of blocks for enterprise projects, I’ve put together this guide covering everything from the fundamentals to the gotchas that will save you hours of debugging.

This is the guide I wish I had when I started with EDS. It builds on Adobe’s official block development documentation and expands it with the patterns, limitations, and real-world lessons I’ve encountered along the way.

How Blocks Actually Work

Before jumping into code, you need to understand the full rendering pipeline. This is the key mental model that makes everything click:

Universal Editor (Author fills a form)
    |
JSON Model (models/_block-name.json)
    |
HTML Table Structure (auto-generated by AEM)
    |
JavaScript Decorator (blocks/block-name/block-name.js)
    |
CSS Styling (blocks/block-name/block-name.css)
    |
Final Rendered Page

The critical insight here is that AEM converts your model fields into an HTML table — each field becomes a row with a single cell. Your JavaScript decorator then reads those rows, extracts the values, destroys the table, and builds proper semantic HTML in its place.

This is fundamentally different from traditional AEM component development (HTL/Sling Models), and it’s the #1 source of confusion for developers coming from classic AEM.

Project Structure

Here’s where everything lives:

project/
├── models/
│   ├── _banner.json              # Block model definition
│   ├── _component-models.json    # Model aggregator
│   ├── _component-filters.json   # Which blocks are available
│   └── ...
├── blocks/
│   └── banner/
│       ├── banner.js             # JavaScript decorator
│       └── banner.css            # Block styles
├── component-models.json         # Compiled (generated)
├── component-definition.json     # Compiled (generated)
└── component-filters.json        # Compiled (generated)

The models/ folder contains your source files (prefixed with _). The root-level JSON files are compiled outputs — never edit those directly. Always run npm run build:json after modifying any model.

The Three Files You Need for Every Block

1. The JSON Model

This defines the authoring interface in Universal Editor. The field order here is critical — it determines the row order in the generated HTML.

{
  "id": "banner",
  "fields": [
    {
      "component": "text",
      "name": "title",
      "value": "",
      "label": "Title",
      "valueType": "string"
    },
    {
      "component": "richtext",
      "name": "subtitle",
      "value": "",
      "label": "Subtitle",
      "valueType": "string"
    },
    {
      "component": "select",
      "name": "layout",
      "value": "left",
      "label": "Content Layout",
      "valueType": "string",
      "options": [
        { "name": "Left (Default)", "value": "left" },
        { "name": "Center", "value": "center" },
        { "name": "Right", "value": "right" }
      ]
    },
    {
      "component": "reference",
      "name": "backgroundImage",
      "label": "Background Image",
      "multi": false
    },
    {
      "component": "select",
      "name": "classes",
      "value": "",
      "label": "Background Color",
      "valueType": "string",
      "options": [
        { "name": "White (Default)", "value": "" },
        { "name": "Blue", "value": "blue" },
        { "name": "Dark", "value": "dark" }
      ]
    }
  ]
}

Key things to note:

  • The id must match the block folder name
  • Field order = row order in HTML = destructuring order in JS
  • The classes field is special — its value gets automatically applied as a CSS class on the block wrapper

2. The JavaScript Decorator

This is where the magic (and most of the pain) happens. Your decorator receives the raw HTML table and transforms it into clean, semantic markup:

import { moveInstrumentation } from '../../scripts/scripts.js';

export default function decorate(block) {
  const rows = [...block.children];

  // Order MUST match the model's field order exactly
  const [titleRow, subtitleRow, layoutRow, backgroundImageRow, classesRow] = rows;

  // 1. Read values from each row
  // 2. Create new semantic elements
  // 3. Use moveInstrumentation() to preserve Universal Editor editability
  // 4. Remove every original row with .remove()
  // 5. Append your new elements to the block

  // Example: processing a text field
  const titleCell = titleRow?.querySelector(':scope > div');
  const title = document.createElement('h1');
  moveInstrumentation(titleCell, title);
  title.textContent = titleCell?.textContent.trim();
  titleRow?.remove();

  // The classes row is auto-applied by the framework -- just clean it up
  classesRow?.remove();

  block.append(title);
}

The core loop is always the same: read the row, build new DOM, transfer instrumentation, remove the row, append. How you handle each field type (rich text, images, selects) varies — more on that below.

3. The CSS

Style your block and its variants. The classes field values become CSS classes on the block wrapper automatically, so you can target them directly:

.banner { padding: 5rem 0; }

/* Color variants driven by the "classes" field */
.banner.blue { background: #1c2337; color: white; }
.banner.dark { background: #0f172a; color: white; }

/* Layout variants you add via block.classList.add() */
.banner.layout-center { text-align: center; }

The key idea: your decorator adds CSS classes, your stylesheet targets them. Keep your selectors flat and tied to the block name.

Field Types Deep Dive

EDS gives you a handful of field types. Here’s how each one works and how to handle it in your decorator.

Text

Single-line plain text. Seems simple, but has a major gotcha (see Critical Limitations below).

{
  "component": "text",
  "name": "title",
  "value": "",
  "label": "Title",
  "valueType": "string"
}
const titleText = titleRow.querySelector(':scope > div')?.textContent.trim();

Rich Text

Multi-line formatted content. The cell may contain <p>, <strong>, <ul>, and other HTML elements. The key difference from text: you need to move the child nodes, not read textContent.

{
  "component": "richtext",
  "name": "description",
  "value": "",
  "label": "Description",
  "valueType": "string"
}
const cell = descriptionRow.querySelector(':scope > div');
const wrapper = document.createElement('div');
// Move child nodes (not textContent!) to preserve HTML formatting
while (cell.firstChild) wrapper.append(cell.firstChild);

Select

Dropdown with predefined options. Great for configuration fields like layout, color, size, etc.

{
  "component": "select",
  "name": "layout",
  "value": "left",
  "label": "Layout",
  "valueType": "string",
  "options": [
    { "name": "Left (Default)", "value": "left" },
    { "name": "Center", "value": "center" }
  ]
}
const layout = layoutRow.querySelector(':scope > div')?.textContent.trim();
if (layout && ['left', 'center'].includes(layout)) {
  block.classList.add(`layout-${layout}`);
}

Reference

For images and file assets. Renders as a <picture> element inside the cell. Use createOptimizedPicture from aem.js to generate responsive variants.

{
  "component": "reference",
  "name": "image",
  "label": "Hero Image",
  "multi": false
}
const picture = imageRow.querySelector('picture');
const img = picture?.querySelector('img');
// Use createOptimizedPicture(img.src, img.alt) for responsive images
// Or use img.src directly as a background-image -- your call

AEM Content

For links and CTAs that let authors select internal pages or enter external URLs. The cell will contain an <a> element you can extract.

{
  "component": "aem-content",
  "name": "ctaLink",
  "label": "Button Link"
}
const link = ctaRow.querySelector('a');
// link.href has the URL, link.textContent has the label

Classes (Special)

This one is unique. The framework automatically applies the selected value as a CSS class on the block’s root element. You just need to remove the row.

{
  "component": "select",
  "name": "classes",
  "value": "",
  "label": "Background Color",
  "valueType": "string",
  "options": [
    { "name": "White (Default)", "value": "" },
    { "name": "Blue", "value": "blue" }
  ]
}

If the author selects “Blue”, the block gets class="banner blue" automatically. In your JS, you just do:

if (classesRow) classesRow.remove();

Critical Limitations (The Stuff Adobe Doesn’t Emphasize)

These are the things that will cost you hours if you don’t know about them upfront. I learned all of these the hard way.

Multiple Text Fields Create Duplicate Blocks

This is the single most important limitation to understand.

Every text field in your model creates a complete instance of the block in Universal Editor. If you have 8 text fields, the author will see 8 duplicate blocks when they try to add one.

// DO NOT DO THIS -- creates 8 duplicate blocks
{
  "id": "contact-form",
  "fields": [
    { "component": "text", "name": "nameLabel" },
    { "component": "text", "name": "namePlaceholder" },
    { "component": "text", "name": "emailLabel" },
    { "component": "text", "name": "emailPlaceholder" },
    { "component": "text", "name": "messageLabel" },
    { "component": "text", "name": "messagePlaceholder" },
    { "component": "text", "name": "submitButtonText" },
    { "component": "text", "name": "successMessage" }
  ]
}

The fix: Limit yourself to 1-2 text fields max per block. Hardcode everything else in your JavaScript:

// This works correctly
{
  "id": "contact-form",
  "fields": [
    { "component": "text", "name": "title" },
    { "component": "richtext", "name": "subtitle" },
    { "component": "select", "name": "buttonStyle" },
    { "component": "reference", "name": "backgroundImage" },
    { "component": "select", "name": "classes" }
  ]
}

Then hardcode the secondary text values directly in your JavaScript. Is it ideal? No. But it’s the only way to keep Universal Editor from creating duplicates. Document what’s hardcoded in your JSDoc so future developers understand why.

Rule of thumb:

  • 1-2 text fields for main headings — safe
  • richtext for body content — safe
  • select for configuration — safe
  • reference for images — safe
  • 3+ text fields for labels/placeholders — broken

Row Order Must Match Exactly

The order you destructure rows in JavaScript must be identical to the field order in your JSON model. There is no name-based lookup — it’s purely positional.

// Model field order: title, subtitle, image, layout

// WRONG -- will silently read wrong values
const [titleRow, imageRow, subtitleRow, layoutRow] = rows;

// CORRECT
const [titleRow, subtitleRow, imageRow, layoutRow] = rows;

The nasty part: there’s no error. Your block will just display the wrong content in the wrong places, and you’ll spend 30 minutes wondering why the image shows where the subtitle should be.

Always add comments mapping each row to its field:

const [
  titleRow,           // field: title (text)
  subtitleRow,        // field: subtitle (richtext)
  imageRow,           // field: image (reference)
  layoutRow,          // field: layout (select)
  classesRow,         // field: classes (select, auto-applied)
] = rows;

Universal Editor Preview Is Not WYSIWYG

Universal Editor does run your JavaScript decorator, so the DOM transformation happens. However, the final styled result can differ significantly from what authors see during editing. Block styling depends on CDN deployment and caching, so authors often see a partially styled or unstyled version of the block while editing:

What authors may see in Universal Editor:
+------------------+
| Welcome          |  <- Decorator ran, but styles
| Hello world      |     may not be fully applied
+------------------+

What actually renders on the published page:
+----------------------------+
|      WELCOME               |
|                            |
|    Hello world             |
|                            |
|    [ Learn More ]          |
+----------------------------+

This means authors should preview/publish to see the fully styled result. It’s a UX gap you should communicate to your content team early. Consider creating documentation with screenshots showing what the final output looks like for each block configuration.

Collapsed Fields (AEM Content + Text)

When you pair an aem-content field with a text field for link text, they collapse into a single row:

// These two fields...
{
  "component": "aem-content",
  "name": "cta1Link",
  "label": "Button Link"
},
{
  "component": "text",
  "name": "cta1LinkText",
  "label": "Button Text",
  "valueType": "string"
}

// ...produce ONE row, not two

In your JavaScript, only count one row for both:

const [
  titleRow,
  cta1Row,     // contains BOTH the link and its text
  // NO separate cta1TextRow -- it's collapsed
  imageRow,
] = rows;

// The text is inside the <a> element
const link = cta1Row.querySelector('a');
const buttonText = link?.textContent.trim();
const buttonHref = link?.getAttribute('href');

This is a common source of off-by-one errors in row destructuring. When calculating total rows, always remember: aem-content + text = 1 row.

Patterns That Work

Here are the key patterns I reach for on every block. These are short on purpose — try them yourself to see how they fit in your decorator.

Always Remove Rows

If you don’t remove processed rows, you’ll see both the raw table data and your new elements. Every row must call .remove() after you’ve read its value.

Optional Chaining Everywhere

Fields can be empty. Rows can be undefined. Always use ?. when querying:

// Safe -- won't crash on empty fields
const text = row?.querySelector(':scope > div')?.textContent?.trim();

Default Values for Config Fields

Select fields can be empty. Always set a fallback before reading the row, and validate against your list of accepted values.

moveInstrumentation for Editability

When you create new DOM elements to replace the original cells, call moveInstrumentation(originalCell, newElement) to transfer the data-aue-* attributes. Without this, authors lose click-to-edit in Universal Editor.

Rich Text: Move Nodes, Not Text

For richtext fields, never use .textContent — it strips formatting. Instead, loop through cell.firstChild and .append() each node to your new wrapper to preserve the HTML structure.

Repeating Items: Group and Loop

For blocks with repeated groups (e.g. 3 service cards), group the related rows into an array of objects and .forEach() over them. This keeps your decorator clean instead of duplicating processing logic.

CSS: Clearing Section Wrappers

EDS wraps blocks in section containers that add padding/margins. For full-width blocks, use main .section:has(.your-block) to override them.

Debugging

When something doesn’t work, start by logging your rows at the top of your decorator:

const rows = [...block.children];
console.log('Total rows:', rows.length);
rows.forEach((row, i) => {
  console.log(`Row ${i}:`, row.querySelector(':scope > div')?.textContent.trim());
});

This tells you immediately if your row count and order match what you expect. From there:

  • Nothing renders? You probably forgot .append() or the row order is wrong.
  • Content is duplicated? You’re not calling .remove() on the original rows.
  • Styles aren’t applied? Log block.classList and compare against your CSS selectors.
  • Row count is off? Remember collapsed fields — aem-content + text = 1 row, not 2.

Registration and Deployment

Register Your Block in Filters

For a block to appear in Universal Editor, add it to models/_component-filters.json:

[
  {
    "id": "section",
    "components": [
      "text",
      "image",
      "button",
      "title",
      "banner",
      "hero-simple",
      "services"
    ]
  }
]

Build and Validate

npm run build:json   # Compile model files
npm run lint         # Lint JS and CSS
npm run lint:js      # JavaScript only
npm run lint:css     # CSS only

Always run build:json after modifying any model file. Forgetting this step is another common source of “why isn’t my block showing up” frustration.

Checklist for New Blocks

Every time I create a new block, I follow this checklist:

  1. Create models/_block-name.json with fields in the correct order
  2. Add the block to models/_component-filters.json
  3. Run npm run build:json
  4. Create blocks/block-name/block-name.js
    • Destructure rows matching the model order
    • Process each field
    • Remove every row with .remove()
    • Create clean DOM elements
    • Use moveInstrumentation() for editability
    • Append elements to the block
  5. Create blocks/block-name/block-name.css
    • Base styles
    • Color/layout variants
    • Responsive breakpoints
  6. Run npm run lint
  7. Test in Universal Editor
  8. Test on published/preview page
  9. Document hardcoded values in JSDoc

Final Thoughts

Block development in AEM Edge Delivery Services is conceptually simple but full of subtle traps. The table-to-DOM transformation model is elegant once you understand it, but the limitations around text fields, field ordering, and collapsed rows can eat hours of your time if you’re not prepared.

The biggest takeaway from my experience: keep your blocks simple. Stick to 1-2 text fields, use select fields for configuration, and don’t fight the framework. The more you try to make a block do, the more likely you are to hit one of these edge cases.

If you’re working with EDS and have found other gotchas or patterns, I’d love to hear about them — feel free to reach out on LinkedIn or GitHub.

Ignacio Mancilla

Who writes this?

Ignacio Mancilla

AEM Solutions Architect | AI Engineer

Contact

Get in Touch

Have a question, a project idea, or just want to say hi? Drop me a message.