Building a Universal Editor Component
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.
Content Objective
- Follow one component through its full lifecycle: definition, model, filter, authoring, delivery, DOM,
decorate(block), and CSS. - Write down a content contract before touching JSON, and keep component, model, and block naming aligned.
- Model content meaning rather than current layout, and expose controlled variants instead of implementation details.
- Verify a component from the author's side before debugging frontend code.
- Debug an authorable block as a chain of stages and start at the first failing boundary.
- See the model-validation rules and container gotchas we actually hit building the ADC components on the POC.
Introduction
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:
- image
- title
- description
- CTA
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.
1. Define the Component Before Writing Configuration
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:
- layout
- typography
- responsive behavior
- CTA presentation
- image presentation
- supported variant styling
The author owns:
- title
- description
- image
- CTA content
- approved variant selection
This sounds simple, but it prevents the model from slowly turning into a collection of frontend settings.
2. Keep One Component Identity
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.
3. Register the Component
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.
4. Define the Content Model
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.
5. Choose Field Types Based on Authoring Need
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.
6. Required and Optional Fields Need to Match the Frontend
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.
7. Add Only Approved Variants
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:
- predictable values
- predictable CSS
- easier testing
- clearer authoring
- safer future changes
The frontend still owns what Dark actually means visually.
8. Define Where Hero Can Be Added
Now we have:
- a component
- a model
We still need to think about placement.
Should Hero be allowed:
- at page level?
- inside a normal section?
- inside Cards?
- inside another Hero?
- inside Tabs?
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.
9. Author the Component
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:
- Can I find Hero?
- Can I insert it in the expected location?
- Are the field labels understandable?
- Are required fields clear?
- Can I select the image?
- Can I configure the CTA?
- Are variant choices controlled?
- Can I save the component?
This verifies the authoring contract before frontend debugging begins.
10. Authoring Success Does Not Mean Frontend Success
Suppose I can:
- add Hero
- edit Title
- choose an image
- save it
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.
11. Inspect What Reaches Preview
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?
12. Inspect Before Decorating
Now I use DevTools.
I inspect:
block.children
I look at:
- row count
- cell count
- image structure
- heading structure
- description markup
- links
- variant classes or other relevant block state
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.
13. Create the Frontend Block
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:
- the Hero exists
- block discovery found it
- the module loaded
- the default export worked
decorate(block)ran
Now we can add actual behavior.
14. Implement Against the Delivered Structure
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:
- image may exist
- title should exist
- CTA may be absent
- a future supported second CTA can be handled deliberately if it belongs to the model
15. Add Block-Scoped CSS
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.
16. Test the Complete Component Path
At this point I test the component as a complete feature, not just as JavaScript.
The test path is:
- add Hero in Universal Editor
- populate the fields
- save the content
- open Preview
- verify the delivered content
- load the page locally
- verify
hero.js - verify
hero.css - inspect the final DOM
- test the component visually and functionally
This 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.
17. Test Optional Content
Now I intentionally remove content.
Hero without Description
Does it still render correctly?
Hero without CTA
Does hero.js throw an error?
Hero without Image
If the design allows it, does the layout still work?
Long Title
Does it wrap correctly?
Long CTA
Does the button remain usable?
A component is not finished when the ideal sample content works.
It is finished when the supported states work.
18. Test Authoring Constraints Too
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.
19. Authoring Validation Does Not Replace Frontend Handling
Even with a strong model, the frontend should not assume perfect content forever.
Content can come from:
- existing pages
- migrated content
- older model versions
- integration mistakes
- partially updated environments
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.
20. Model Changes Need Frontend Review
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:
- existing content
- delivered DOM changes
hero.jshero.css- optional states
- variant behavior
- authoring UX
- backward compatibility
The model and frontend are one component contract.
Changing one side can affect the other.
21. Existing Pages Matter
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:
- rename fields
- change field meaning
- remove fields
- change the component structure
- convert one field type into another
That is why model evolution needs planning.
22. Avoid Casual Field Renaming
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:
- What happens to existing content?
- Is migration required?
- Can old and new structures coexist?
- Does the delivered DOM change?
- Does frontend logic depend on the current structure?
Once content exists, field names are no longer only developer preferences.
23. Variants Need End-to-End Testing
Suppose Hero supports:
Default
Dark
Testing the model dropdown is not enough.
I verify:
- the author can select Dark
- the value is stored correctly
- the delivered block reflects the selection
- the frontend recognizes it
- the correct CSS applies
- mobile behavior still works
- optional fields still work in that variant
A variant is part of the component contract from authoring through presentation.
24. Keep Presentation Rules in the Frontend
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.
25. Debugging: Hero Does Not Appear in Universal Editor
Start on the authoring side.
Check:
- component definition
- component identifier
- configuration syntax
- placement rules
- whether the expected configuration is loaded
Do not start by editing:
blocks/hero/hero.js
The browser block implementation does not control whether the component appears in the authoring picker.
26. Debugging: Hero Appears but Fields Are Wrong
If Hero can be selected but its fields are missing or incorrect, the definition has already done part of its job.
Now I inspect:
- model identifier
- field definitions
- field types
- component-to-model relationship
- required/optional configuration
- current configuration version
This is a model problem until evidence points somewhere else.
27. Debugging: Hero Cannot Be Added Here
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.
28. Debugging: Authoring Works but Content Is 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:
- correct page
- correct environment
- saved content
- publishing/preview state as applicable
- delivered page structure
I still do not assume hero.js is responsible.
If the block never reaches the browser, frontend JavaScript cannot fix it.
29. Debugging: DOM Exists but Hero Is Not Decorated
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:
- block naming
- folder naming
- discovery
- loading flow
If it was requested, check:
- response status
- syntax errors
- module loading errors
- default export
- Console output
The problem has now moved into the frontend boundary.
30. Debugging: Decoration Runs but Layout Is Wrong
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.
31. Debugging: Correct UI, Wrong Content
Sometimes the component looks perfect but displays the wrong value.
That is not primarily a CSS problem.
Trace the value backward.
Check:
- What does the final DOM contain?
- What did the delivered block contain before decoration?
- What was authored?
- Which field produced the value?
- Are we testing the correct page/environment?
This is especially useful when several fields contain similar text.
32. The First-Failure Rule
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.
33. Keep Authoring and Frontend Changes in Sync
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.
34. Component Review Checklist
Before considering an authorable block complete, I check four areas.
Authoring
- Is the component available?
- Are labels understandable?
- Are required fields correct?
- Are options controlled?
- Are placement rules correct?
Content
- Does the expected content reach Preview?
- Are optional fields represented correctly?
- Do variants produce the expected content state?
Frontend
- Does the block load?
- Does
decorate(block)handle supported states? - Is CSS scoped?
- Are semantics preserved?
- Is responsive behavior correct?
Evolution
- What happens to existing pages?
- Can new optional fields coexist with old content?
- Are naming changes safe?
- Does a model change require migration?
This is enough to catch most component-contract problems early.
35. Don't Build the Model Around One Screenshot
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.
36. Don't Make the Model Too Generic Either
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.
37. Developer Perspective
For me, a Universal Editor component is complete only when I can follow it through the whole path.
I should be able to answer:
- Where is it registered?
- Which model does it use?
- Where can it be inserted?
- What can the author edit?
- What content is required?
- What DOM reaches the browser?
- Which block handles it?
- What does
decorate()change? - Which CSS styles it?
- What happens when optional content is missing?
If I cannot answer those questions, I only understand part of the component.
38. AEM Developer Perspective
Coming from traditional AEM, there is a familiar architectural idea here even though the implementation is different.
In traditional AEM, we often connect:
- component definition
- dialog
- stored content
- Sling Model
- HTL
- client library
With EDS and Universal Editor, the path is different:
- component definition
- component model
- component filter
- authored content
- delivered block DOM
decorate(block)- block CSS
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.
39. Architect Perspective
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:
- titles
- descriptions
- images
- links
- CTAs
- variants
- repeated items
- placement
- required fields
Not every component should use an identical model.
The goal is consistent meaning where the concepts are actually shared.
40. What I Learned From the Complete Component Path
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.
41. What Actually Broke When We Built the ADC Components on the POC
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.
Key Takeaways
- Start a Universal Editor component with a clear content contract.
- Keep component, model, and block naming aligned where practical.
- The component definition makes the component available to authoring.
- The component model defines editable content.
- Filters define supported placement.
- Model content meaning rather than current layout.
- Field types should match actual authoring needs.
- Required and optional rules must align with frontend handling.
- Expose controlled variants instead of implementation details.
- Test the component from the author's perspective before debugging frontend code.
- Authoring success does not prove frontend success.
- Inspect the delivered Preview DOM before implementing final decoration logic.
- Build
decorate(block)against the actual delivered structure. - Keep block CSS scoped to the component.
- Test the complete authoring-to-browser path.
- Test optional and invalid content states deliberately.
- Authoring validation and defensive frontend handling solve different problems.
- Treat model changes as component interface changes.
- Existing content must be considered before renaming or restructuring fields.
- Test variants end-to-end.
- Keep presentation implementation out of the authoring contract.
- Debug definition, model, placement, delivery, DOM, loading, decoration, and styling as separate stages.
- Find the first failing stage instead of changing multiple layers.
- Review authoring and frontend changes together.
- Do not model components directly from one screenshot.
- Do not make models so generic that content meaning disappears.
- At enterprise scale, common modeling conventions become part of the component design system.
- A model cell holds at most four fields; group extras behind a shared
prefix_name (xwalk/max-cells). - Avoid
Type/Alt/Text/Titlesuffixes on standalone fields (xwalk/no-orphan-collapsible-fields). - Repeatable blocks are a container plus an item wired through matching filters and the
sectionfilter. - Check the deployed
component-*.jsonbefore editing block JS to separate config problems from code problems. - Do not stage
_*.jsonmodel partials with block source if a build hook regenerates the palette.
Next Steps
Our Hero is now connected across authoring and frontend delivery.
The next step is to move beyond content-only components.
In the next chapter, EDS Blocks and External API Integration, we build a block that combines authored content with runtime data.
We will cover:
- direct browser API calls
- when browser calls are appropriate
- CORS
- public versus private APIs
- credentials
- Edge Functions
- API response transformation
- loading, success, empty, and failure states
- caching
- timeouts
- keeping API logic separate from DOM logic
- debugging requests through the Network panel
- deciding when an enterprise API gateway belongs in the architecture
That is where the block stops being only a content component and starts participating in a wider application architecture.
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.