The Complete Guide to Building Blocks in AEM Edge Delivery Services
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
idmust match the block folder name - Field order = row order in HTML = destructuring order in JS
- The
classesfield 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
textfields for main headings — safe richtextfor body content — safeselectfor configuration — safereferencefor images — safe- 3+
textfields 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.classListand 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:
- Create
models/_block-name.jsonwith fields in the correct order - Add the block to
models/_component-filters.json - Run
npm run build:json - 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
- Create
blocks/block-name/block-name.css- Base styles
- Color/layout variants
- Responsive breakpoints
- Run
npm run lint - Test in Universal Editor
- Test on published/preview page
- 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.
Who writes this?
Ignacio Mancilla
AEM Solutions Architect | AI Engineer