Technical implementation guide for AEM Edge Delivery blocks - DOM manipulation, decoration patterns, and JavaScript best practices...
This skill covers the technical implementation of AEM blocks AFTER you have:
Key principle: Content structure drives implementation. Don't design structure around your code convenience.
Every block consists of:
blocks/blockname/blockname.js - Decoration logicblocks/blockname/blockname.css - Scoped styles/**
* loads and decorates the block
* @param {Element} block The block element
*/
export default async function decorate(block) {
// 1. Load dependencies (if needed)
// 2. Extract configuration from block (if applicable)
// 3. Transform DOM
// 4. Add event listeners
// 5. Set loaded status (if needed)
}
This function is called automatically by the AEM page loading system when the block is encountered.
ALWAYS inspect the HTML before writing code. Don't make assumptions.
1. Local curl (fastest)
# View full page HTML
curl http://localhost:3000/path/to/test-page
# View page as plain.html (stripped to content only)
curl http://localhost:3000/path/to/test-page.plain.html
2. Browser console
// Log the block element
console.log(block);
// Log the HTML structure
console.log(block.outerHTML);
// Log all rows
console.log([...block.children]);
3. Preview environment
# View from preview environment
curl https://branch--repo--owner.aem.page/path/to/test-page.plain.html
AEM converts authored tables into HTML with this structure:
Authored content:
| Block Name |
|------------|
| value1 | value2 | value3 |
| value4 | value5 | value6 |
Delivered HTML:
<div class="block-name">
<div>
<div>value1</div>
<div>value2</div>
<div>value3</div>
</div>
<div>
<div>value4</div>
<div>value5</div>
<div>value6</div>
</div>
</div>
Key observations:
class="block-name" (lowercase, spaces to hyphens)<div> containing child <div>s<div><picture> elements with <img> inside<a> elementsGet all rows:
const rows = [...block.children];
Get cells from a row:
const cells = [...row.children];
Get specific row/cell:
const firstRow = block.children[0];
const firstCell = firstRow.children[0];
Get text content:
const text = cell.textContent.trim();
Get image:
const picture = cell.querySelector('picture');
const img = cell.querySelector('img');
Get link:
const link = cell.querySelector('a');
const href = link?.href;
const linkText = link?.textContent;
Pattern 1: Simple row iteration
export default async function decorate(block) {
const rows = [...block.children];
rows.forEach((row) => {
const cells = [...row.children];
// Transform each row
row.classList.add('item');
});
}
Pattern 2: Extract configuration from first row
export default async function decorate(block) {
const config = {};
const configRow = block.children[0];
const cells = [...configRow.children];
// First cell = key, second = value (config block pattern)
config[cells[0].textContent.trim()] = cells[1].textContent.trim();
configRow.remove(); // Remove config row after extraction
// Process remaining rows
[...block.children].forEach((row) => {
// Use config to influence decoration
});
}
Pattern 3: Rebuild DOM structure
export default async function decorate(block) {
const rows = [...block.children];
// Create new structure
const container = document.createElement('div');
container.classList.add('container');
rows.forEach((row) => {
const cells = [...row.children];
// Build new element from cell data
const item = document.createElement('div');
item.classList.add('item');
const img = cells[0].querySelector('img');
if (img) item.append(img);
const text = cells[1].textContent.trim();
const p = document.createElement('p');
p.textContent = text;
item.append(p);
container.append(item);
});
// Replace block contents
block.textContent = '';
block.append(container);
}
Pattern 4: Handle optional fields gracefully
export default async function decorate(block) {
const rows = [...block.children];
rows.forEach((row) => {
const cells = [...row.children];
// Required field
const title = cells[0]?.textContent.trim();
if (!title) return; // Skip malformed rows
// Optional fields - check existence
const img = cells[1]?.querySelector('img');
const link = cells[2]?.querySelector('a');
// Build element, include optional parts only if present
const item = document.createElement('div');
if (img) item.append(img.cloneNode(true));
const heading = document.createElement('h3');
heading.textContent = title;
if (link) {
link.textContent = '';
link.append(heading);
item.append(link);
} else {
item.append(heading);
}
row.replaceWith(item);
});
}
Get optimized image:
const picture = cell.querySelector('picture');
const img = cell.querySelector('img');
// Images from AEM are already optimized with:
// - Multiple source formats (webp, etc.)
// - Responsive srcset
// - Lazy loading attributes
// Just move/clone the picture element:
newElement.append(picture);
Modify image attributes:
const img = cell.querySelector('img');
if (img) {
img.alt = 'Descriptive alt text';
img.loading = 'lazy'; // Usually already set
}
Extract image URL:
const img = cell.querySelector('img');
const imageSrc = img?.src;
Extract link data:
const link = cell.querySelector('a');
if (link) {
const href = link.href;
const text = link.textContent.trim();
const title = link.title;
}
Wrap content in link:
const link = cell.querySelector('a');
if (link) {
const href = link.href;
link.textContent = ''; // Clear existing content
link.append(newContent); // Add new content
}
Create new link:
const a = document.createElement('a');
a.href = url;
a.textContent = 'Click here';
a.title = 'Descriptive title';
Get formatted content (preserving HTML):
// Cell may contain paragraphs, headings, lists, etc.
const content = cell.innerHTML;
// Or clone the content
const contentClone = cell.cloneNode(true);
newElement.append(contentClone);
Get plain text (stripping HTML):
const plainText = cell.textContent.trim();
Variants modify block behavior without changing the JavaScript file.
Authored:
| Block Name (Variant1, Variant2) |
|---------------------------------|
| content |
Delivered HTML:
<div class="block-name variant1 variant2">
<div>content</div>
</div>
Detect variants in JavaScript:
export default async function decorate(block) {
const isVariant1 = block.classList.contains('variant1');
const isVariant2 = block.classList.contains('variant2');
if (isVariant1) {
// Apply variant1-specific behavior
}
if (isVariant2) {
// Apply variant2-specific behavior
}
}
CSS for variants:
/* Base block styles */
.block-name {
/* Default styles */
}
/* Variant-specific styles */
.block-name.variant1 {
/* Override for variant1 */
}
.block-name.variant2 {
/* Override for variant2 */
}
CRITICAL: All CSS selectors MUST be scoped to the block class.
This prevents style conflicts between blocks and ensures maintainability.
.search-input {
padding: 1rem;
}
.item-label {
font-weight: bold;
}
Problem: These selectors will match ANY element with these classes across the entire page, causing conflicts.
.block-name .search-input {
padding: 1rem;
}
.block-name .item-label {
font-weight: bold;
}
/* For elements you create dynamically */
.block-name input {
padding: 1rem;
}
Why this matters:
Rule: Every selector must start with .block-name (your block's class).
Load library (if absolutely necessary):
export default async function decorate(block) {
// Only if truly needed - avoid dependencies when possible
const lib = await import('./lib.js');
// Use library
lib.doSomething(block);
}
Fetch JSON data:
export default async function decorate(block) {
const endpoint = block.querySelector('a')?.href;
if (endpoint) {
try {
const response = await fetch(endpoint);
const data = await response.json();
// Use data to populate block
data.items.forEach((item) => {
// Create elements from data
});
} catch (error) {
console.error('Failed to load data:', error);
// Show error state or fallback
}
}
}
Add loading indicator:
export default async function decorate(block) {
// Add loading class
block.classList.add('loading');
// Perform async operation
await doSomethingAsync();
// Remove loading class
block.classList.remove('loading');
block.classList.add('loaded');
}
CSS for loading state:
.block-name.loading {
opacity: 0.5;
pointer-events: none;
}
.block-name.loading::after {
content: 'Loading...';
display: block;
}
Click events:
export default async function decorate(block) {
const button = block.querySelector('.button');
button.addEventListener('click', (e) => {
e.preventDefault();
// Handle click
});
}
Event delegation (for dynamic content):
export default async function decorate(block) {
block.addEventListener('click', (e) => {
const button = e.target.closest('.button');
if (button) {
e.preventDefault();
// Handle button click
}
});
}
Keyboard accessibility:
button.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
// Handle activation
}
});
CSS breakpoints (from AGENTS.md):
/* Mobile styles (default) */
.block-name {
display: block;
}
/* Tablet (600px+) */
@media (min-width: 600px) {
.block-name {
display: flex;
}
}
/* Desktop (900px+) */
@media (min-width: 900px) {
.block-name {
max-width: 1200px;
}
}
/* Large desktop (1200px+) */
@media (min-width: 1200px) {
.block-name {
/* Large screen styles */
}
}
Detect viewport size (if necessary):
export default async function decorate(block) {
const isMobile = window.matchMedia('(max-width: 599px)').matches;
const isTablet = window.matchMedia('(min-width: 600px) and (max-width: 899px)').matches;
const isDesktop = window.matchMedia('(min-width: 900px)').matches;
// Apply behavior based on viewport
if (isMobile) {
// Mobile-specific behavior
}
}
Listen for viewport changes:
const mediaQuery = window.matchMedia('(min-width: 900px)');
function handleViewportChange(e) {
if (e.matches) {
// Desktop behavior
} else {
// Mobile/tablet behavior
}
}
mediaQuery.addEventListener('change', handleViewportChange);
handleViewportChange(mediaQuery); // Initial check
Add ARIA attributes:
const button = document.createElement('button');
button.setAttribute('aria-label', 'Close dialog');
button.setAttribute('aria-expanded', 'false');
For custom interactive elements:
const customButton = document.createElement('div');
customButton.setAttribute('role', 'button');
customButton.setAttribute('tabindex', '0');
customButton.addEventListener('click', handleClick);
customButton.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
});
Manage focus for interactive elements:
const dialog = document.createElement('div');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
// When opening dialog
dialog.style.display = 'block';
dialog.querySelector('button').focus();
// Trap focus within dialog
dialog.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
// Trap focus logic
}
});
Bad - multiple reflows:
// ❌ WRONG - causes multiple reflows
rows.forEach((row) => {
row.style.width = '100px';
row.style.height = '50px';
row.classList.add('item');
});
Good - batch operations:
// ✅ CORRECT - minimize reflows
const fragment = document.createDocumentFragment();
rows.forEach((row) => {
row.style.cssText = 'width: 100px; height: 50px';
row.classList.add('item');
fragment.append(row);
});
block.append(fragment);
Lazy load images (usually already handled by AEM):
const img = cell.querySelector('img');
if (img && !img.loading) {
img.loading = 'lazy';
}
Lazy load functionality:
export default async function decorate(block) {
// Set up structure immediately
block.classList.add('ready');
// Lazy load expensive operations
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// Load expensive functionality when visible
loadExpensiveFeature(block);
observer.disconnect();
}
});
});
observer.observe(block);
}
Prefer vanilla JavaScript over libraries:
// ❌ WRONG - unnecessary dependency
import $ from 'jquery';
$('.block-name').addClass('active');
// ✅ CORRECT - vanilla JS
document.querySelectorAll('.block-name').forEach((el) => {
el.classList.add('active');
});
Use native browser APIs:
// Native fetch instead of axios
fetch(url).then(r => r.json()).then(data => { /* ... */ });
// Native DOM manipulation instead of jQuery
element.classList.add('class');
element.addEventListener('click', handler);
Content structure:
| Cards |
|-------|
| image1.jpg | Title 1 | Description 1 |
| image2.jpg | Title 2 | Description 2 |
JavaScript:
export default async function decorate(block) {
const rows = [...block.children];
rows.forEach((row) => {
const cells = [...row.children];
const picture = cells[0].querySelector('picture');
const title = cells[1].textContent.trim();
const description = cells[2].textContent.trim();
// Build card structure
row.classList.add('card');
row.innerHTML = '';
if (picture) {
const imageWrapper = document.createElement('div');
imageWrapper.classList.add('card-image');
imageWrapper.append(picture);
row.append(imageWrapper);
}
const content = document.createElement('div');
content.classList.add('card-content');
const h3 = document.createElement('h3');
h3.textContent = title;
content.append(h3);
const p = document.createElement('p');
p.textContent = description;
content.append(p);
row.append(content);
});
}
CSS:
.cards {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
}
@media (min-width: 600px) {
.cards {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 900px) {
.cards {
grid-template-columns: repeat(3, 1fr);
}
}
.cards .card {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.cards .card-image img {
width: 100%;
height: auto;
display: block;
}
.cards .card-content {
padding: 1rem;
}
Content structure:
| Tabs |
|------|
| Tab 1 | Content for tab 1 |
| Tab 2 | Content for tab 2 |
JavaScript:
export default async function decorate(block) {
const rows = [...block.children];
// Create tab buttons container
const tabButtons = document.createElement('div');
tabButtons.classList.add('tabs-buttons');
// Create content container
const tabContents = document.createElement('div');
tabContents.classList.add('tabs-contents');
rows.forEach((row, index) => {
const cells = [...row.children];
const tabLabel = cells[0].textContent.trim();
const tabContent = cells[1];
// Create button
const button = document.createElement('button');
button.classList.add('tab-button');
button.textContent = tabLabel;
button.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
button.setAttribute('role', 'tab');
// Create content panel
const panel = document.createElement('div');
panel.classList.add('tab-panel');
panel.setAttribute('role', 'tabpanel');
panel.append(tabContent);
if (index === 0) {
button.classList.add('active');
panel.classList.add('active');
} else {
panel.style.display = 'none';
}
// Click handler
button.addEventListener('click', () => {
// Deactivate all
tabButtons.querySelectorAll('.tab-button').forEach((btn) => {
btn.classList.remove('active');
btn.setAttribute('aria-selected', 'false');
});
tabContents.querySelectorAll('.tab-panel').forEach((p) => {
p.classList.remove('active');
p.style.display = 'none';
});
// Activate clicked
button.classList.add('active');
button.setAttribute('aria-selected', 'true');
panel.classList.add('active');
panel.style.display = 'block';
});
tabButtons.append(button);
tabContents.append(panel);
});
// Replace block content
block.textContent = '';
block.append(tabButtons);
block.append(tabContents);
}
Content structure:
| Hero |
|------|
| Headline text |
| Subheadline text |
| cta-button.jpg |
| background.jpg |
JavaScript:
export default async function decorate(block) {
const rows = [...block.children];
const headline = rows[0]?.textContent.trim();
const subheadline = rows[1]?.textContent.trim();
const ctaImage = rows[2]?.querySelector('picture');
const backgroundImage = rows[3]?.querySelector('img');
// Set background
if (backgroundImage) {
block.style.backgroundImage = `url(${backgroundImage.src})`;
block.style.backgroundSize = 'cover';
block.style.backgroundPosition = 'center';
}
// Build content
block.innerHTML = '';
const content = document.createElement('div');
content.classList.add('hero-content');
if (headline) {
const h1 = document.createElement('h1');
h1.textContent = headline;
content.append(h1);
}
if (subheadline) {
const p = document.createElement('p');
p.textContent = subheadline;
content.append(p);
}
if (ctaImage) {
content.append(ctaImage);
}
block.append(content);
}
drafts/agent/blockname-test.html (see aem-development-workflow skill)aem up --html-folder=./drafts/agenthttp://localhost:3000/drafts/agent/blockname-testexport default async function decorate(block) {
console.log('Block element:', block);
console.log('Block HTML:', block.outerHTML);
console.log('Rows:', [...block.children]);
// Your decoration code
}
Wrong:
const title = row.children[1].textContent; // Crashes if cell doesn't exist
Correct:
const title = row.children[1]?.textContent.trim() || 'Default';
Wrong:
const img = cell.querySelector('img');
cell.innerHTML = ''; // Destroys the image
// Now img is detached from DOM
Correct:
const img = cell.querySelector('img');
const imgClone = img.cloneNode(true); // Or move it before clearing
cell.innerHTML = '';
cell.append(imgClone);
Wrong:
export default async function decorate(block) {
const firstRow = block.children[0]; // Crashes if no rows
const title = firstRow.children[0].textContent;
}
Correct:
export default async function decorate(block) {
if (!block.children.length) return; // Handle empty block
const firstRow = block.children[0];
if (!firstRow?.children.length) return; // Handle empty row
const title = firstRow.children[0]?.textContent.trim();
}
Wrong:
// Assumes cell contains plain text
const name = cell.textContent;
Correct:
// Check what's actually in the cell
const link = cell.querySelector('a');
const name = link ? link.textContent.trim() : cell.textContent.trim();
Wrong:
const div = document.createElement('div');
div.addEventListener('click', handleClick); // Not keyboard accessible
Correct:
const button = document.createElement('button');
button.addEventListener('click', handleClick); // Keyboard accessible by default
[...block.children] // All rows
[...row.children] // All cells in row
cell.querySelector('img') // Find image
cell.querySelector('a') // Find link
cell.querySelector('picture') // Find picture element
cell.textContent.trim() // Get text content
document.createElement('div')
element.classList.add('class')
element.setAttribute('attr', 'value')
element.append(child)
element.textContent = 'text'
element.innerHTML = '<p>html</p>'
element.remove() // Remove from DOM
element.replaceWith(newElement) // Replace element
element.cloneNode(true) // Deep clone
block.textContent = '' // Clear contents
Block development workflow:
Remember:
Always refer to: