Edge Delivery Services · 9 of 29
Building an authorable AEM Edge Delivery Services component by connecting the Universal Editor component definition, model, placement rules, authored content, delivered DOM, and frontend block implementation.
decorate(block), and CSS.So far, we have looked at the main pieces separately.
We built a block.
We looked at block contracts.
We looked at Universal Editor definitions, models, and filters.
We inspected the DOM received by decorate(block).
Now I want to connect those pieces.
For this chapter, we will use the Hero again because the content is already familiar:
The interesting part is no longer the Hero design.
The interesting part is following one component through its complete lifecycle:
component configuration → authoring → stored content → delivered structure → block implementation → browser
This is also where debugging becomes easier.
If the component does not appear in Universal Editor, that is different from the component appearing correctly but failing in the browser.
If the fields work but the delivered structure is unexpected, that is another boundary.
A complete component implementation becomes much easier once those boundaries are clear.
Before editing JSON, I write down the component contract.
For the POC Hero:
| Field | Requirement |
|---|---|
| Title | Required |
| Description | Optional |
| Image | Based on design requirement |
| CTA | Optional |
| Variant | Optional approved choice |
The frontend owns:
The author owns:
This sounds simple, but it prevents the model from slowly turning into a collection of frontend settings.
I want the component naming to remain easy to follow across the project.
For example:
Display name: Hero
Component ID: hero
Model ID: hero
Block name: hero
Block folder: blocks/hero/
These values do not always have to be identical.
But if there is no reason for them to differ, consistency helps.
When debugging, I can move from:
hero
in the authoring configuration to:
blocks/hero/
without translating several unrelated names.
The first authoring-side responsibility is making the component available.
That belongs to the component definition.
A simplified definition may conceptually resemble:
{
"title": "Hero",
"id": "hero",
"plugins": {
"xwalk": {
"page": {
"resourceType": "core/franklin/components/block/v1/block",
"template": {
"name": "Hero",
"model": "hero"
}
}
}
}
}
The exact configuration should follow the project structure and the Universal Editor version being used.
At this stage, I am solving one problem:
Universal Editor needs to know that Hero exists.
I am not implementing its fields or frontend behavior yet.
Next comes the model.
Conceptually, we want something representing:
Hero
├── Image
├── Title
├── Description
├── CTA
└── Variant
The important part is not the JSON syntax.
The important part is what those fields mean.
I want:
title
description
image
cta
variant
rather than:
row1
row2
leftColumn
rightColumn
buttonWrapper
The first group describes content.
The second group describes today's presentation.
Content normally needs to survive frontend redesigns better than layout-specific modeling does.
Not every string needs rich text.
Not every visual option needs free text.
For each field, I ask what the author actually needs to do.
For example:
| Field | Authoring need |
|---|---|
| Title | Short textual value |
| Description | Supporting copy; rich text only if required |
| Image | Asset selection/reference |
| CTA | Link destination and label |
| Variant | Controlled selection |
The model should give enough flexibility for the content requirement without exposing unnecessary states.
Suppose Title is required.
The model should communicate that requirement.
The frontend should still avoid catastrophic failure if unexpected content reaches it, but it does not need to invent a missing title.
Description and CTA are optional.
Therefore hero.js must not assume they always exist.
This alignment matters.
If the model says a field is optional but the JavaScript treats it as required, the component contract is inconsistent.
Suppose the design supports:
Default
Dark
That can be exposed as a controlled authoring choice.
I would not expose a field such as:
Custom CSS class
and expect authors to enter:
dark
A controlled option gives us:
The frontend still owns what Dark actually means visually.
Now we have:
We still need to think about placement.
Should Hero be allowed:
Those are not only authoring questions.
They affect what frontend combinations we agree to support.
The component filter should reflect the supported composition model.
For our POC, Hero should be available only in the places where the page architecture expects it.
Once the configuration is available, I test it from the author's side.
I don't immediately switch back to JavaScript.
I open Universal Editor and check:
This verifies the authoring contract before frontend debugging begins.
Suppose I can:
That tells me the authoring side is working.
It does not prove that:
blocks/hero/hero.js
is correct.
This distinction is important.
I treat the component as a chain of responsibilities.
A successful stage tells me where not to start debugging.
After authoring the Hero, I open the corresponding Preview page.
Before touching hero.js, I inspect the delivered page.
I want to confirm that the Hero content exists.
For example, I may find something conceptually similar to:
<div class="hero block">
<div>
<div>
<picture>
...
</picture>
</div>
<div>
<h1>Explore our products</h1>
<p>Find the right product for your needs.</p>
<p>
<a href="/products">Explore products</a>
</p>
</div>
</div>
</div>
Again, I inspect the actual project output rather than assuming this exact structure.
The important question is:
Did the content contract make it through delivery in the form the frontend expects?
Now I use DevTools.
I inspect:
block.children
I look at:
Only after I understand the delivered DOM do I write the final decoration logic.
This keeps the implementation tied to the real contract instead of the model I imagined while writing JSON.
Our project contains:
blocks/
└── hero/
├── hero.js
└── hero.css
Start small:
export default function decorate(block) {
console.log('hero', block);
}
Reload locally.
If the console prints the block, we know:
decorate(block) ranNow we can add actual behavior.
After inspecting the DOM, a simple Hero implementation might be:
export default function decorate(block) {
const picture = block.querySelector('picture');
const title = block.querySelector('h1, h2');
const links = [...block.querySelectorAll('a')];
if (picture) {
picture.closest('div')?.classList.add('hero-media');
}
if (title) {
title.classList.add('hero-title');
}
const [primaryCta, secondaryCta] = links;
if (primaryCta) {
primaryCta.classList.add('button', 'primary');
}
if (secondaryCta) {
secondaryCta.classList.add('button', 'secondary');
}
}
The exact selectors should follow the actual delivered structure.
What matters is that the code reflects the content contract:
The Hero stylesheet belongs with the block:
blocks/hero/hero.css
For example:
.hero {
position: relative;
display: grid;
overflow: hidden;
}
.hero .hero-media {
grid-area: 1 / 1;
}
.hero .hero-media picture,
.hero .hero-media img {
width: 100%;
height: 100%;
}
.hero .hero-media img {
display: block;
object-fit: cover;
}
.hero > div:last-child {
position: relative;
grid-area: 1 / 1;
align-self: end;
}
.hero .hero-title {
margin-top: 0;
}
The actual design will vary.
The rule stays the same:
Hero presentation belongs to Hero.
I don't move Hero-specific selectors into global CSS without a reason.
At this point I test the component as a complete feature, not just as JavaScript.
The test path is:
hero.jshero.cssThis catches problems that isolated code testing will not.
The same idea as a linear chain: Universal Editor → component definition, model, and filter → authored Hero content → EDS delivery → Hero DOM → blocks/hero/hero.js → decorate(block) → hero.css → final Hero. Each arrow is a boundary where the contract can hold or break.
Now I intentionally remove content.
Does it still render correctly?
Does hero.js throw an error?
If the design allows it, does the layout still work?
Does it wrap correctly?
Does the button remain usable?
A component is not finished when the ideal sample content works.
It is finished when the supported states work.
Frontend testing is only half of the component.
I also test whether Universal Editor allows states that should not be valid.
For example:
If Title is required, can I save Hero without it?
If only approved variants exist, can an unsupported value appear?
If Hero should not be placed inside Cards, can the author insert it there?
The best frontend error handling is often preventing invalid content from being authored in the first place.
Even with a strong model, the frontend should not assume perfect content forever.
Content can come from:
So I still write defensive code for reasonable failure cases.
For example:
const link = block.querySelector('a');
if (link) {
link.classList.add('button', 'primary');
}
The authoring model reduces invalid states.
The frontend prevents unexpected states from taking down the component.
These responsibilities complement each other.
Suppose Hero originally contains:
Image
Title
Description
CTA
Now the requirement changes:
Image
Eyebrow
Title
Description
Primary CTA
Secondary CTA
Variant
I would not treat this as only a component-models.json change.
I review:
hero.jshero.cssThe model and frontend are one component contract.
Changing one side can affect the other.
Imagine 100 pages already use Hero.
We add:
secondaryCta
as an optional field.
New pages may contain it.
Old pages will not.
The frontend should therefore support both valid states during that evolution.
This is straightforward when the new field is optional.
It becomes more complicated when we:
That is why model evolution needs planning.
Suppose we have:
description
and decide:
body
sounds better.
From a code-cleanliness perspective, that looks like a simple rename.
From a content perspective, there may already be stored values tied to the existing model.
Before changing it, I ask:
Once content exists, field names are no longer only developer preferences.
Suppose Hero supports:
Default
Dark
Testing the model dropdown is not enough.
I verify:
A variant is part of the component contract from authoring through presentation.
The author may select:
Dark
but should not need to know that the frontend implements it using:
.hero.dark {
...
}
or some other class/configuration mechanism.
This gives us room to change the implementation later.
The authoring contract says:
Use the approved Dark presentation.
The frontend decides how that presentation is implemented.
That separation is useful during redesigns.
Start on the authoring side.
Check:
Do not start by editing:
blocks/hero/hero.js
The browser block implementation does not control whether the component appears in the authoring picker.
If Hero can be selected but its fields are missing or incorrect, the definition has already done part of its job.
Now I inspect:
This is a model problem until evidence points somewhere else.
If the component exists but cannot be inserted into a specific container, I look at placement.
The question is:
Is Hero unavailable everywhere, or only in this location?
If it works elsewhere, the filter/container relationship becomes the likely boundary.
That is different from the component definition being missing.
Suppose Universal Editor allows me to create Hero and save its fields, but Preview does not contain the expected block.
Now I move from authoring toward content delivery.
I check:
I still do not assume hero.js is responsible.
If the block never reaches the browser, frontend JavaScript cannot fix it.
Suppose I see:
<div class="hero block">
in the DOM.
Now I check the Network panel.
Was this requested?
blocks/hero/hero.js
If not, investigate:
If it was requested, check:
The problem has now moved into the frontend boundary.
Suppose:
console.log('hero', block);
runs successfully.
The block is loaded.
Now inspect the transformed DOM.
Did the expected classes get added?
For example:
<h1 class="hero-title">
If yes, inspect CSS.
Is:
hero.css
loaded?
Does the selector match?
Is another rule overriding it?
At this point, changing the Universal Editor model would be moving backward to a stage that already works.
Sometimes the component looks perfect but displays the wrong value.
That is not primarily a CSS problem.
Trace the value backward.
Check:
This is especially useful when several fields contain similar text.
For a complete Universal Editor component, I use this debugging order:
| Stage | Main question |
|---|---|
| Definition | Does the component exist? |
| Filter | Can it be placed here? |
| Model | Are the expected fields available? |
| Authoring | Was the expected content entered and saved? |
| Delivery | Did the content reach the page? |
| DOM | Is the expected structure present? |
| Block loading | Did JS/CSS load? |
| Decoration | Did decorate(block) run? |
| Styling | Did CSS produce the expected layout? |
Find the first stage where the expected result is missing.
Start there.
That is much faster than changing several layers at once.
Suppose a developer updates the model to add a second CTA but does not update hero.js.
The author can now create content the frontend does not understand correctly.
The opposite is also possible.
A developer updates hero.js to expect two links, but the model still allows only one.
Neither side is necessarily broken by itself.
The contract between them is broken.
For component changes, I review the authoring and frontend impact together.
Before considering an authorable block complete, I check four areas.
decorate(block) handle supported states?This is enough to catch most component-contract problems early.
A screenshot shows one visual state.
A content model needs to support the intended lifecycle of the component.
If I model Hero directly from a screenshot, I may create fields such as:
Left image
Right heading
Right description
Bottom-right CTA
That works until the design changes.
Instead I model:
Image
Title
Description
CTA
and let the frontend decide where those elements appear.
A content model should survive reasonable presentation changes.
The opposite extreme is a component with:
Field 1
Field 2
Field 3
Field 4
or one giant rich-text area where authors can put anything.
That avoids modeling decisions, but it also removes useful structure.
The frontend then has less certainty about what it receives.
A good model is specific about content meaning without encoding unnecessary presentation.
For me, a Universal Editor component is complete only when I can follow it through the whole path.
I should be able to answer:
decorate() change?If I cannot answer those questions, I only understand part of the component.
Coming from traditional AEM, there is a familiar architectural idea here even though the implementation is different.
In traditional AEM, we often connect:
With EDS and Universal Editor, the path is different:
decorate(block)I don't try to force a one-to-one technical mapping between the two.
What carries over is the architectural discipline:
Authoring contract and rendering contract must agree.
At enterprise scale, individual component configuration becomes a design-system concern.
Without conventions, one team may model a CTA as:
label + link
another as:
buttonText + buttonUrl
another as:
actionTitle + actionTarget
All three may work.
But the platform becomes inconsistent.
I would establish shared conventions for common concepts such as:
Not every component should use an identical model.
The goal is consistent meaning where the concepts are actually shared.
The biggest lesson is that a Universal Editor component is not one JSON file and not one JavaScript file.
It is a chain.
The definition makes the component available.
The model defines what can be authored.
The filter controls where it can be placed.
The author creates content.
EDS delivers that content.
The browser receives the block DOM.
The block implementation enhances it.
CSS presents it.
If I understand that chain, debugging becomes much more predictable.
Instead of asking:
Why is Hero broken?
I can ask:
At which boundary did Hero stop matching the contract?
That is a much better engineering question.
The chain above is the clean version. Building the real ADC blocks (adc-cards, adc-form, adc-header and friends) surfaced a set of validation rules and container behaviors that are not obvious until Universal Editor rejects your config or silently drops a field. These are the ones worth remembering.
A model cell can hold at most four authorable fields. Universal Editor validation (xwalk/max-cells) fails a block model that declares more than four cells. When a component genuinely needs more fields, group related ones behind a shared prefix_ (underscore) name so they collapse into a single cell. For example settings_label, settings_required, and settings_control render as one "settings" cell rather than three. This is a modeling decision that belongs in step 4, not a workaround discovered at the end.
Some field-name suffixes trigger semantic collapsing. Validation rule xwalk/no-orphan-collapsible-fields treats field names ending in Type, Alt, Text, or Title as one half of a pair and errors if the partner is missing. We hit this with standalone fields and had to rename them — formType became endpointKey, fieldType became control. When a field is not part of an image/link pair, avoid those suffixes.
A repeatable block is a container plus an item, wired through filters. For blocks like cards, the container gets template.filter plus its own model, and each child uses a separate item resource type (for example .../block/v1/block/item). The item must be registered in a filter whose id matches the container id, and the container itself must be added to the section filter so authors can insert it. Miss any one of those and the block either will not appear or will not accept children.
Verify the deployed config before touching block JavaScript. The component definition, models, and filters are served as JSON, so a quick check tells you whether a problem is config-versus-code:
https://main--<repo>--<owner>.aem.live/component-definition.json
https://main--<repo>--<owner>.aem.live/component-models.json
https://main--<repo>--<owner>.aem.live/component-filters.json
If the field you expect is missing from those responses, the fix is in the model or definition — not in decorate(block). This maps directly onto the first-failure rule in section 32.
Do not stage the model partials when committing block source. The POC has a pre-commit hook that regenerates component-definition.json, component-models.json, and component-filters.json from _*.json partials whenever a partial is staged. That regeneration does not reproduce the hand-maintained palette and silently drops components that lack a partial. The rule we settled on: commit only the block's .js and .css; if a definition change is genuinely needed, hand-edit the generated component-*.json directly and stage no partial.
The theme across all of these is the same as the rest of the chapter. The component is a chain, and most of the surprises live at the authoring-configuration boundary — before a single line of decorate(block) runs.
decorate(block) against the actual delivered structure.prefix_ name (xwalk/max-cells).Type/Alt/Text/Title suffixes on standalone fields (xwalk/no-orphan-collapsible-fields).section filter.component-*.json before editing block JS to separate config problems from code problems._*.json model partials with block source if a build hook regenerates the palette.Our Hero is now connected across authoring and frontend delivery.
Building one component end to end is one thing. Keeping components working when something breaks is another. The next chapter, EDS Debugging Guide, works through the bugs we actually hit on the POC and a systematic way to isolate them: block discovery failures, the export-default mistake, table-to-DOM surprises, variant CSS that never matches, the key-value and filter conflict, the Preview-must-be-clicked trap, and lint errors that silently block a deploy.
(External API integration — browser calls, CORS, credentials, Edge Functions, response transformation — comes later, in Chapter 28.)
That is where the block stops being only a content component and starts participating in a wider application architecture.
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.