N
Naveenr.dev
Chapter 08
17 min read•2026-09-26
📖 Edge Delivery Services SeriesChapter 08 · 27 chapters

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:

FieldRequirement
TitleRequired
DescriptionOptional
ImageBased on design requirement
CTAOptional
VariantOptional 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:

text
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:

text
hero

in the authoring configuration to:

text
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:

json
{
  "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:

text
Hero
├── Image
├── Title
├── Description
├── CTA
└── Variant

The important part is not the JSON syntax.

The important part is what those fields mean.

I want:

text
title
description
image
cta
variant

rather than:

text
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:

FieldAuthoring need
TitleShort textual value
DescriptionSupporting copy; rich text only if required
ImageAsset selection/reference
CTALink destination and label
VariantControlled 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:

text
Default
Dark

That can be exposed as a controlled authoring choice.

I would not expose a field such as:

text
Custom CSS class

and expect authors to enter:

text
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:

text
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:

html
<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:

javascript
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:

text
blocks/
└── hero/
    ├── hero.js
    └── hero.css

Start small:

javascript
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:

javascript
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:

text
blocks/hero/hero.css

For example:

css
.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:

  1. add Hero in Universal Editor
  2. populate the fields
  3. save the content
  4. open Preview
  5. verify the delivered content
  6. load the page locally
  7. verify hero.js
  8. verify hero.css
  9. inspect the final DOM
  10. test the component visually and functionally

This catches problems that isolated code testing will not.

Universal Editor component end-to-end flow, from component definition and model through EDS delivery, the Hero DOM, decorate(block), and the final styled Hero
Universal Editor component end-to-end flow, from component definition and model through EDS delivery, the Hero DOM, decorate(block), and the final styled Hero

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:

javascript
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:

text
Image
Title
Description
CTA

Now the requirement changes:

text
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.js
  • hero.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:

text
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:

text
description

and decide:

text
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:

text
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:

text
Dark

but should not need to know that the frontend implements it using:

css
.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:

text
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:

html
<div class="hero block">

in the DOM.

Now I check the Network panel.

Was this requested?

text
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:

javascript
console.log('hero', block);

runs successfully.

The block is loaded.

Now inspect the transformed DOM.

Did the expected classes get added?

For example:

html
<h1 class="hero-title">

If yes, inspect CSS.

Is:

text
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:

  1. What does the final DOM contain?
  2. What did the delivered block contain before decoration?
  3. What was authored?
  4. Which field produced the value?
  5. 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:

StageMain question
DefinitionDoes the component exist?
FilterCan it be placed here?
ModelAre the expected fields available?
AuthoringWas the expected content entered and saved?
DeliveryDid the content reach the page?
DOMIs the expected structure present?
Block loadingDid JS/CSS load?
DecorationDid decorate(block) run?
StylingDid 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:

text
Left image
Right heading
Right description
Bottom-right CTA

That works until the design changes.

Instead I model:

text
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:

text
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:

text
label + link

another as:

text
buttonText + buttonUrl

another as:

text
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:

text
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/Title suffixes on standalone fields (xwalk/no-orphan-collapsible-fields).
  • Repeatable blocks are a container plus an item wired through matching filters and the section filter.
  • Check the deployed component-*.json before editing block JS to separate config problems from code problems.
  • Do not stage _*.json model 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.